From 0b32398414f3d82b62519c1be0ef1f4a37af730d Mon Sep 17 00:00:00 2001 From: gus <42593099+lightcloud00@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:13:43 -0400 Subject: [PATCH 01/10] feat(workers): add named remote CUA worker registry with macOS and Windows adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-bot computer modes today are Linux-only (`vm`, `cloud/vps`, `cloud/box`) or the host Mac itself (`local`). There is no way to give bot A a macOS desktop and bot B a Windows desktop from one control plane, because `vps` and its siblings each hold a single app-level SSH alias. Add a named worker registry plus a shared remote transport, and two platform adapters over it: - server/computer-workers.ts — workers keyed by id, each an SSH alias plus a declared platform and public digests. Two ids may not share one alias: that would take two independent leases against a single real desktop and each would believe it held the screen exclusively. - server/remote-worker.ts — SSH invocation, an allow-listed child environment, the per-alias lease, and the shared fail-closed readiness ladder. The probe payload crosses a trust edge, so it is parsed with zod at that boundary; per-field `.catch` degrades one bad value to "not proven" rather than discarding the report a half-configured worker needs. - server/windows-worker.ts — PowerShell probe, Session 1+ window station, named-pipe channel, Administrators rule. - server/mac-worker.ts — POSIX probe, Aqua console session, unix-socket channel, admin-group rule, and TCC. Accessibility and Screen Recording are granted per-binary and are silently revoked when the driver binary is replaced, so the grant is read live on every poll and an absent grant fails closed. - server/worker-mcp.ts — stdio bridge pinned to the one CUA MCP invocation, running under the allow-listed environment so no provider credential or loopback control token reaches the ssh child. Leases key on the alias, so a macOS bot and a Windows bot hold their desktops at the same time, and an unreachable worker degrades to offline without touching the healthy one. Tests are fake-worker only: they inject the probe's stdout and need no real guest. The macOS probe was additionally run against real macOS to confirm it parses under /bin/sh and emits valid JSON. Refs #508 Co-Authored-By: Claude Opus 5 --- scripts/bundle-server.mjs | 1 + server/computer-workers.test.ts | 89 ++++++ server/computer-workers.ts | 185 +++++++++++++ server/config.ts | 20 ++ server/mac-worker.ts | 178 ++++++++++++ server/mcp-bridge.ts | 10 +- server/proxy-paths.ts | 1 + server/remote-worker.ts | 465 ++++++++++++++++++++++++++++++++ server/windows-worker.ts | 143 ++++++++++ server/worker-mcp.ts | 49 ++++ server/worker-status.test.ts | 276 +++++++++++++++++++ server/worker-status.ts | 26 ++ 12 files changed, 1441 insertions(+), 2 deletions(-) create mode 100644 server/computer-workers.test.ts create mode 100644 server/computer-workers.ts create mode 100644 server/mac-worker.ts create mode 100644 server/remote-worker.ts create mode 100644 server/windows-worker.ts create mode 100644 server/worker-mcp.ts create mode 100644 server/worker-status.test.ts create mode 100644 server/worker-status.ts diff --git a/scripts/bundle-server.mjs b/scripts/bundle-server.mjs index db58e458..e3ea0066 100644 --- a/scripts/bundle-server.mjs +++ b/scripts/bundle-server.mjs @@ -51,6 +51,7 @@ const ENTRY_POINTS = [ "computer-proxy.ts", "container-mcp.ts", "vps-container-mcp.ts", + "worker-mcp.ts", "permission-proxy.ts", "connector-proxy.ts", "drivers/agents-proxy.ts", diff --git a/server/computer-workers.test.ts b/server/computer-workers.test.ts new file mode 100644 index 00000000..e0e9fad9 --- /dev/null +++ b/server/computer-workers.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { + findWorker, + isSafeWorkerExecutable, + listWorkers, + publicWorker, + workerConfigMapSchema, + WORKER_DRIVER_VERSION, +} from "./computer-workers.ts"; +import { parseStoredConfig } from "./config.ts"; + +const policy = "a".repeat(64); + +const twoWorkers = { + "mac-guest": { platform: "macos", sshAlias: "macguest", expectedBasePolicySha256: policy }, + "win-box": { platform: "windows", sshAlias: "winbox", expectedBasePolicySha256: policy }, +} as const; + +describe("worker registry", () => { + it("accepts one Windows and one macOS worker side by side", () => { + const parsed = workerConfigMapSchema.safeParse(twoWorkers); + expect(parsed.success).toBe(true); + const workers = listWorkers(parsed.success ? parsed.data : undefined); + expect(workers.map((worker) => [worker.id, worker.platform])).toEqual([ + ["mac-guest", "macos"], + ["win-box", "windows"], + ]); + expect(workers.every((worker) => worker.configured)).toBe(true); + expect(workers.every((worker) => worker.expectedDriverVersion === WORKER_DRIVER_VERSION)).toBe(true); + }); + + it("rejects two workers pointed at one machine", () => { + // Two ids on one alias would take two independent leases against a single + // desktop, and each would believe it held the screen exclusively. + const parsed = workerConfigMapSchema.safeParse({ + "mac-a": { platform: "macos", sshAlias: "macguest", expectedBasePolicySha256: policy }, + "mac-b": { platform: "macos", sshAlias: "macguest", expectedBasePolicySha256: policy }, + }); + expect(parsed.success).toBe(false); + expect(JSON.stringify(parsed.error?.issues)).toContain("distinct SSH alias"); + }); + + it("rejects an alias that could smuggle extra ssh arguments", () => { + const parsed = workerConfigMapSchema.safeParse({ + evil: { platform: "macos", sshAlias: "host -o ProxyCommand=curl", expectedBasePolicySha256: policy }, + }); + expect(parsed.success).toBe(false); + }); + + it("holds a worker unconfigured until its base policy is pinned", () => { + // Without a pinned digest the driver's tool ceiling is whatever happens to + // be on the worker's disk, so this must never read as usable. + const workers = listWorkers({ "mac-guest": { platform: "macos", sshAlias: "macguest" } }); + expect(workers[0].configured).toBe(false); + expect(workers[0].expectedBasePolicySha256).toBeNull(); + }); + + it("validates executable paths against the worker's own platform", () => { + expect(isSafeWorkerExecutable("windows", "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe")).toBe(true); + expect(isSafeWorkerExecutable("windows", "/Applications/Safari.app/Contents/MacOS/Safari")).toBe(false); + expect(isSafeWorkerExecutable("macos", "/Applications/Safari.app/Contents/MacOS/Safari")).toBe(true); + expect(isSafeWorkerExecutable("macos", "C:\\Windows\\explorer.exe")).toBe(false); + expect(isSafeWorkerExecutable("macos", "/usr/bin/open\u0000")).toBe(false); + }); + + it("rejects a POSIX executable configured on a Windows worker", () => { + const parsed = workerConfigMapSchema.safeParse({ + "win-box": { + platform: "windows", + sshAlias: "winbox", + expectedBasePolicySha256: policy, + ideExecutable: "/usr/local/bin/code", + }, + }); + expect(parsed.success).toBe(false); + }); + + it("keeps the SSH alias out of anything a bot or device can see", () => { + const worker = findWorker(twoWorkers, "mac-guest"); + expect(worker?.sshAlias).toBe("macguest"); + expect(JSON.stringify(publicWorker(worker!))).not.toContain("macguest"); + }); + + it("round-trips through the stored app config", () => { + const cfg = parseStoredConfig({ workers: twoWorkers }); + expect(Object.keys(cfg.workers ?? {})).toEqual(["mac-guest", "win-box"]); + }); +}); diff --git a/server/computer-workers.ts b/server/computer-workers.ts new file mode 100644 index 00000000..f62c4c4c --- /dev/null +++ b/server/computer-workers.ts @@ -0,0 +1,185 @@ +// Named remote CUA workers. +// +// A worker is one operator-owned interactive machine — a Windows PC or a +// macOS guest — reached through the operator's own SSH config. OpenMausBot +// never provisions a worker, never stores a key, a password or a bearer +// value, and never opens a listener on it: it persists only the SSH alias +// plus public configuration digests, and authentication stays entirely with +// SSH. +// +// This registry exists because the single app-level `vps.sshAlias` shape +// cannot express two targets at once. Bots address a worker by id, and the +// per-alias lease in ./remote-worker.ts keeps two workers independent, so a +// bot on Windows and a bot on macOS can hold their desktops at the same time. +import { z } from "zod"; + +/** Pinned across every worker platform; the driver's wire protocol and its + * policy/capability digests are only comparable within one exact version. */ +export const WORKER_DRIVER_VERSION = "0.20.0"; + +export const MAX_WORKERS = 8; + +const WORKER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/; +const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; +const SHA256 = /^[a-f0-9]{64}$/i; +const WINDOWS_ABSOLUTE = /^[A-Za-z]:\\/; +const POSIX_ABSOLUTE = /^\//; + +export type WorkerPlatform = "windows" | "macos"; + +export const WORKER_PLATFORMS: readonly WorkerPlatform[] = ["windows", "macos"]; + +/** Per-platform defaults. A fresh worker only needs an alias and a base-policy + * digest; everything else has a conventional value the operator can override + * when their install differs. */ +export const WORKER_DEFAULTS = { + windows: { + browserExecutable: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + browserProfile: "OpenMaus Windows Worker", + ideExecutable: "C:\\Program Files\\Microsoft VS Code\\Code.exe", + }, + macos: { + browserExecutable: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + browserProfile: "OpenMaus macOS Worker", + ideExecutable: "/Applications/Visual Studio Code.app/Contents/MacOS/Electron", + }, +} satisfies Record; + +export function isValidWorkerId(value: unknown): value is string { + return typeof value === "string" && WORKER_ID.test(value); +} + +export function isValidWorkerSshAlias(value: unknown): value is string { + return typeof value === "string" && SSH_ALIAS.test(value); +} + +export function isWorkerPlatform(value: unknown): value is WorkerPlatform { + return value === "windows" || value === "macos"; +} + +/** Executable paths reach a shell-free spawn and the CUA capability YAML, but + * they are still operator input echoed into a manifest the daemon enforces. + * Reject control characters and the shell metacharacters that would make a + * quoted YAML scalar ambiguous, and require the platform's absolute form so a + * relative path can never resolve against an unexpected working directory. */ +export function isSafeWorkerExecutable(platform: WorkerPlatform, value: string): boolean { + if (value.length === 0 || value.length > 512) return false; + if (/[\u0000-\u001f"|<>]/.test(value)) return false; + return platform === "windows" ? WINDOWS_ABSOLUTE.test(value) : POSIX_ABSOLUTE.test(value); +} + +const workerConfigSchema = z.object({ + platform: z.enum(["windows", "macos"]), + sshAlias: z.string().refine(isValidWorkerSshAlias, { + message: "must be a simple SSH config alias", + }), + displayName: z.string().max(100).refine((value) => !/[\u0000-\u001f]/.test(value), { + message: "must not contain control characters", + }).optional(), + expectedDriverVersion: z.string().max(32).refine((value) => value === "" || /^\d+\.\d+\.\d+$/.test(value), { + message: "must be an exact CUA Driver version", + }).optional(), + expectedBasePolicySha256: z.string().refine((value) => value === "" || SHA256.test(value), { + message: "must be a SHA-256 digest", + }).optional(), + browserExecutable: z.string().max(512).optional(), + browserProfile: z.string().max(100).refine((value) => !/[\u0000-\u001f]/.test(value), { + message: "must not contain control characters", + }).optional(), + ideExecutable: z.string().max(512).optional(), + paused: z.boolean().optional(), +}).strict().superRefine((worker, ctx) => { + // Path grammar depends on the sibling `platform` field, so it cannot be + // expressed on the individual string schemas above. + for (const key of ["browserExecutable", "ideExecutable"] as const) { + const value = worker[key]; + if (value === undefined || value === "") continue; + if (!isSafeWorkerExecutable(worker.platform, value)) { + ctx.addIssue({ + code: "custom", + path: [key], + message: worker.platform === "windows" + ? "must be an absolute Windows executable path without control or shell characters" + : "must be an absolute POSIX executable path without control or shell characters", + }); + } + } +}); + +export const workerConfigMapSchema = z + .record(z.string().regex(WORKER_ID, "must be a lowercase worker id"), workerConfigSchema) + .refine((workers) => Object.keys(workers).length <= MAX_WORKERS, { + message: `at most ${MAX_WORKERS} workers may be configured`, + }) + .refine( + (workers) => { + const aliases = Object.values(workers).map((worker) => worker.sshAlias); + return new Set(aliases).size === aliases.length; + }, + // Two ids sharing one alias would take two independent leases against one + // real machine, and each would believe it held the desktop exclusively. + { message: "each worker must use a distinct SSH alias" }, + ); + +export type WorkerConfig = z.output; +export type WorkerConfigMap = Record; + +export interface ResolvedWorker { + id: string; + platform: WorkerPlatform; + displayName: string; + sshAlias: string; + expectedDriverVersion: string; + expectedBasePolicySha256: string | null; + browserExecutable: string; + browserProfile: string; + ideExecutable: string; + paused: boolean; + /** False until the operator supplies the base-policy digest. An unpinned + * policy means the driver's tool ceiling is whatever happens to be on the + * worker's disk, so an unconfigured worker is never treated as usable. */ + configured: boolean; +} + +export function resolveWorker(id: string, raw: WorkerConfig): ResolvedWorker { + const defaults = WORKER_DEFAULTS[raw.platform]; + const digest = raw.expectedBasePolicySha256 && SHA256.test(raw.expectedBasePolicySha256) + ? raw.expectedBasePolicySha256.toLowerCase() + : null; + return { + id, + platform: raw.platform, + displayName: raw.displayName || id, + sshAlias: raw.sshAlias, + expectedDriverVersion: raw.expectedDriverVersion || WORKER_DRIVER_VERSION, + expectedBasePolicySha256: digest, + browserExecutable: raw.browserExecutable || defaults.browserExecutable, + browserProfile: raw.browserProfile || defaults.browserProfile, + ideExecutable: raw.ideExecutable || defaults.ideExecutable, + paused: raw.paused === true, + configured: digest !== null, + }; +} + +export function listWorkers(workers: WorkerConfigMap | undefined): ResolvedWorker[] { + if (!workers) return []; + return Object.keys(workers) + .filter(isValidWorkerId) + .sort() + .map((id) => resolveWorker(id, workers[id])); +} + +export function findWorker(workers: WorkerConfigMap | undefined, id: unknown): ResolvedWorker | null { + if (!workers || !isValidWorkerId(id)) return null; + const raw = workers[id]; + return raw ? resolveWorker(id, raw) : null; +} + +/** Redacts the transport identity before a worker is described to a bot, a + * device client, or a task event. The alias names a host in the operator's + * own SSH config; nothing downstream of the control plane needs it, and #508 + * requires it stay out of snapshots, events, logs and exports. */ +export function publicWorker(worker: ResolvedWorker): Omit { + const { sshAlias: _sshAlias, ...rest } = worker; + return rest; +} diff --git a/server/config.ts b/server/config.ts index a6758bbd..c4f37bab 100644 --- a/server/config.ts +++ b/server/config.ts @@ -9,6 +9,13 @@ import { z } from "zod"; import { writeFileAtomic } from "./atomic.ts"; import type { InstanceConfigMap } from "./contracts.ts"; import { parseJson, schemaIssue, type JsonObject, type JsonValue } from "./schema.ts"; +import { + findWorker, + listWorkers, + workerConfigMapSchema, + type ResolvedWorker, + type WorkerConfigMap, +} from "./computer-workers.ts"; const optionalText = z.string().optional(); const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; @@ -96,6 +103,8 @@ const appConfigSchema = z.object({ rooms: roomConfigSchema.optional(), localVm: localVmConfigSchema.optional(), features: featureConfigSchema.optional(), + /** Operator-owned Windows and macOS desktops, keyed by worker id. */ + workers: workerConfigMapSchema.optional(), instances: instanceConfigMapSchema.optional(), }); const appConfigPatchSchema = appConfigSchema.omit({ instances: true }); @@ -118,6 +127,9 @@ export interface AppConfig { localVm?: { mode?: "shared" | "per-bot"; maxInstances?: number }; /** Opt-in product experiments. Every flag defaults to disabled. */ features?: { skillRecorder?: boolean; showToolCalls?: boolean }; + /** Named remote CUA workers. Only the SSH alias and public digests are + * persisted; authentication stays with the operator's SSH config. */ + workers?: WorkerConfigMap; instances?: InstanceConfigMap; } export type ConfigPatch = z.output; @@ -140,6 +152,14 @@ export function vpsSshAlias(cfg: AppConfig): string | null { return isValidSshAlias(cfg.vps?.sshAlias) ? cfg.vps.sshAlias : null; } +export function configuredWorkers(cfg: AppConfig): ResolvedWorker[] { + return listWorkers(cfg.workers); +} + +export function workerById(cfg: AppConfig, id: unknown): ResolvedWorker | null { + return findWorker(cfg.workers, id); +} + export function roomTurnTimeoutMinutes(cfg: AppConfig): number { return cfg.rooms?.turnTimeoutMinutes ?? DEFAULT_ROOM_TURN_TIMEOUT_MINUTES; } diff --git a/server/mac-worker.ts b/server/mac-worker.ts new file mode 100644 index 00000000..e62cf529 --- /dev/null +++ b/server/mac-worker.ts @@ -0,0 +1,178 @@ +// macOS adapter for a named remote CUA worker. +// +// The transport, lease and shared readiness ladder live in +// ./remote-worker.ts. This module owns only what Windows has no counterpart +// for: the POSIX health probe, the Aqua console session, the unix-socket +// control channel, the admin-group rule, and TCC. +// +// TCC is the one check with no Windows analogue and the one an operator +// cannot script away: Accessibility and Screen Recording are granted +// per-binary, System Integrity Protection blocks writing the TCC database, +// and replacing the driver binary silently revokes them. So the probe reads +// the live grant on every poll rather than trusting a setup step that +// happened once. `currentMacOsPermissionStatus()` in the pinned CUA SDK is +// the non-prompting read; the worker companion surfaces it as JSON because +// TCC state belongs to the driver's own binary, not to whatever process the +// SSH session happens to start. +import { + applyHealthReport, + baseWorkerStatus, + defaultRemoteWorkerRunner, + evaluateSharedHealth, + failWorker, + finishWorkerStatus, + remoteWorkerSshBaseArgs, + WORKER_SSH_TIMEOUT_MS, + type RemoteWorkerLease, + type RemoteWorkerSshRunner, + type RemoteWorkerStatus, +} from "./remote-worker.ts"; +import type { ResolvedWorker } from "./computer-workers.ts"; + +/** Fixed by convention under the worker account's own home so the socket and + * its directory can both be owner-private. The probe reports the resolved + * absolute path and the control plane pins it into the MCP generation. */ +export const MAC_CUA_SOCKET_RELATIVE = ".openmausbot/run/cua.sock"; +export const MAC_SUPPORT_RELATIVE = "Library/Application Support/OpenMausBot"; +export const MAC_POLICY_RELATIVE = `${MAC_SUPPORT_RELATIVE}/macos-policy.yaml`; +export const MAC_CAPABILITY_RELATIVE = `${MAC_SUPPORT_RELATIVE}/active-capabilities.yaml`; + +// POSIX sh, no bashisms: the worker account's login shell is the operator's +// choice, so this runs under `/bin/sh -s` with the script on stdin. Every +// probe is read-only and none of them prompt. +const MAC_HEALTH_SCRIPT = String.raw` +set -u +support="$HOME/Library/Application Support/OpenMausBot" +sock="$HOME/.openmausbot/run/cua.sock" + +json_str() { + if [ -z "$1" ]; then printf 'null'; else printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')"; fi +} +json_bool() { if [ "$1" = "1" ]; then printf 'true'; else printf 'false'; fi; } + +driver_version=$(cua-driver --version 2>/dev/null | sed -n 's/.*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -n 1) +companion_version=$(openmausbot-worker-companion --version 2>/dev/null | sed -n 's/.*[^0-9]\([0-9][0-9]*\)$/\1/p' | head -n 1) +[ -n "$companion_version" ] || companion_version=null + +# An admin worker account could rewrite the very policy that bounds it. +privileged=0 +if id -Gn 2>/dev/null | tr ' ' '\n' | grep -qx admin; then privileged=1; fi + +# The Aqua session that owns /dev/console is the only one with a real screen. +# Its uid doubles as the session identifier: macOS has no Windows-style +# numeric window-station id, and "whose login session" is the fact that +# actually matters for driving a desktop. +console_user=$(stat -f%Su /dev/console 2>/dev/null) +current_user=$(id -un 2>/dev/null) +interactive=0 +interactive_session_id=null +if [ -n "$console_user" ] && [ "$console_user" = "$current_user" ] && launchctl print "gui/$(id -u)" >/dev/null 2>&1; then + interactive=1 + interactive_session_id=$(id -u) +fi + +# CGSSessionScreenIsLocked is absent entirely while unlocked. +locked=0 +if ioreg -n Root -d1 -a 2>/dev/null | grep -q CGSSessionScreenIsLocked; then locked=1; fi + +channel_available=0 +channel_access=missing +if [ -S "$sock" ]; then + channel_available=1 + if [ -r "$sock" ] && [ -w "$sock" ]; then channel_access=ok; else channel_access=denied; channel_available=0; fi +fi + +digest_of() { + if [ -f "$1" ]; then shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'; fi +} +policy_digest=$(digest_of "$support/macos-policy.yaml") +capability_digest=$(digest_of "$support/active-capabilities.yaml") + +status_text=$(cua-driver status --socket "$sock" 2>/dev/null | tr '[:upper:]' '[:lower:]') +policy_loaded=0 +if [ -n "$policy_digest" ] && printf '%s' "$status_text" | grep -qF "$policy_digest"; then policy_loaded=1; fi +capability_loaded=0 +if [ -n "$capability_digest" ] && printf '%s' "$status_text" | grep -qF "$capability_digest"; then capability_loaded=1; fi +permission_mode=unknown +if printf '%s' "$status_text" | grep -qw bounded; then permission_mode=bounded +elif printf '%s' "$status_text" | grep -qw standard; then permission_mode=standard +elif printf '%s' "$status_text" | grep -qw unrestricted; then permission_mode=unrestricted +fi + +# The companion reports the driver binary's own TCC grants. Absent or +# unparseable output stays false, which fails the ladder closed. +tcc=$(openmausbot-worker-companion --permissions 2>/dev/null) +accessibility=0 +screen_recording=0 +if printf '%s' "$tcc" | grep -q '"accessibility"[[:space:]]*:[[:space:]]*true'; then accessibility=1; fi +if printf '%s' "$tcc" | grep -q '"screenRecording"[[:space:]]*:[[:space:]]*true'; then screen_recording=1; fi + +printf '{' +printf '"driverVersion":%s,' "$(json_str "$driver_version")" +printf '"companionVersion":%s,' "$companion_version" +printf '"privileged":%s,' "$(json_bool "$privileged")" +printf '"interactiveSession":%s,' "$(json_bool "$interactive")" +printf '"interactiveSessionId":%s,' "$interactive_session_id" +printf '"locked":%s,' "$(json_bool "$locked")" +printf '"channelPath":%s,' "$(json_str "$sock")" +printf '"channelAvailable":%s,' "$(json_bool "$channel_available")" +printf '"channelAccess":%s,' "$(json_str "$channel_access")" +printf '"policyDigest":%s,' "$(json_str "$policy_digest")" +printf '"policyLoaded":%s,' "$(json_bool "$policy_loaded")" +printf '"permissionMode":%s,' "$(json_str "$permission_mode")" +printf '"capabilityDigest":%s,' "$(json_str "$capability_digest")" +printf '"capabilityLoaded":%s,' "$(json_bool "$capability_loaded")" +printf '"accessibilityGranted":%s,' "$(json_bool "$accessibility")" +printf '"screenRecordingGranted":%s' "$(json_bool "$screen_recording")" +printf '}' +`; + +export function macWorkerHealthArgs(sshAlias: string): string[] { + // Keep the fixed probe off argv and on stdin, matching the Windows adapter: + // one short, inspectable command in the worker's process listing. + return [...remoteWorkerSshBaseArgs(sshAlias), "/bin/sh", "-s"]; +} + +/** The macOS-only tail of the readiness ladder. Runs after the shared checks + * so a missing driver or an unlocked-screen fault is reported before TCC. */ +export function evaluateMacHealth(status: RemoteWorkerStatus): RemoteWorkerStatus | null { + if (status.accessibilityGranted !== true) { + return failWorker(status, "policy_mismatch", "worker_accessibility_denied", + "Grant Accessibility to CUA Driver in the guest's System Settings > Privacy & Security"); + } + if (status.screenRecordingGranted !== true) { + return failWorker(status, "policy_mismatch", "worker_screen_recording_denied", + "Grant Screen Recording to CUA Driver in the guest's System Settings > Privacy & Security"); + } + return null; +} + +export async function macWorkerStatus( + worker: ResolvedWorker, + options: { + runner?: RemoteWorkerSshRunner; + lease?: RemoteWorkerLease; + isBotBusy?: (botId: string) => boolean; + } = {}, +): Promise { + const status = baseWorkerStatus(worker); + if (!worker.configured) return status; + if (worker.paused) return failWorker(status, "paused", "worker_paused", "This worker is paused"); + + const runner = options.runner ?? defaultRemoteWorkerRunner; + let report: unknown; + try { + const result = await runner(macWorkerHealthArgs(worker.sshAlias), WORKER_SSH_TIMEOUT_MS, MAC_HEALTH_SCRIPT); + report = JSON.parse(result.stdout.trim()); + } catch (error) { + return failWorker(status, "offline", "worker_offline", + `Worker SSH is offline: ${error instanceof Error ? error.message.slice(0, 200) : "unknown error"}`); + } + + applyHealthReport(status, report); + const shared = evaluateSharedHealth(status); + if (shared) return shared; + const mac = evaluateMacHealth(status); + if (mac) return mac; + return finishWorkerStatus(status, worker.sshAlias, options); +} diff --git a/server/mcp-bridge.ts b/server/mcp-bridge.ts index 68ef573f..af240acc 100644 --- a/server/mcp-bridge.ts +++ b/server/mcp-bridge.ts @@ -37,6 +37,8 @@ const PROBE_TIMEOUT_MS = 10_000; export interface BridgeLiveness { command: string; args: string[]; + /** Optional child environment for a transport with a stricter boundary. */ + env?: NodeJS.ProcessEnv; } /** Run the liveness command; alive means "exited 0 within the timeout". The @@ -46,7 +48,7 @@ export function runLivenessProbe(probe: BridgeLiveness, timeoutMs = PROBE_TIMEOU return new Promise((resolve) => { const child = spawn(probe.command, probe.args, { shell: false, - env: { ...process.env, PATH: augmentedPath() }, + env: probe.env ?? { ...process.env, PATH: augmentedPath() }, stdio: ["ignore", "ignore", "ignore"], }); const timer = setTimeout(() => { @@ -125,6 +127,10 @@ export function createInactivityWatchdog(options: { export interface BridgeOptions { command: string; args: string[]; + /** Optional child environment. The default preserves existing local/VPS + * behavior; a remote worker supplies an allow-listed SSH environment so no + * API key or loopback control token can reach the ssh child. */ + env?: NodeJS.ProcessEnv; /** Names the far end in stderr messages, e.g. "Cua Driver". */ label: string; /** Enables the dead-transport watchdog. Omitted for the Local VM, whose @@ -208,7 +214,7 @@ export function createGateInterceptor(options: { export function runMcpBridge(options: BridgeOptions): void { const child = spawn(options.command, options.args, { shell: false, - env: { ...process.env, PATH: augmentedPath() }, + env: options.env ?? { ...process.env, PATH: augmentedPath() }, stdio: ["pipe", "pipe", "pipe"], }); diff --git a/server/proxy-paths.ts b/server/proxy-paths.ts index 5a67582a..3b50b22a 100644 --- a/server/proxy-paths.ts +++ b/server/proxy-paths.ts @@ -38,6 +38,7 @@ export const SPAWNED_PROXIES = { permission: resolveProxy("permission-proxy"), containerMcp: resolveProxy("container-mcp"), vpsContainerMcp: resolveProxy("vps-container-mcp"), + workerMcp: resolveProxy("worker-mcp"), agents: resolveProxy("drivers/agents-proxy"), dweb: resolveProxy("drivers/dweb-proxy"), connectors: resolveProxy("connector-proxy"), diff --git a/server/remote-worker.ts b/server/remote-worker.ts new file mode 100644 index 00000000..45229ca1 --- /dev/null +++ b/server/remote-worker.ts @@ -0,0 +1,465 @@ +// Shared transport and readiness contract for named remote CUA workers. +// +// Everything here is platform-neutral: the SSH invocation, the environment +// allow-list, the per-alias lease, and the fail-closed readiness ladder. Each +// platform adapter (./windows-worker.ts, ./mac-worker.ts) supplies only its +// own health probe and the checks that have no counterpart on the other OS. +import { spawn } from "node:child_process"; + +import { z } from "zod"; + +import { + isValidWorkerSshAlias, + type ResolvedWorker, + type WorkerPlatform, +} from "./computer-workers.ts"; +import { augmentedPath } from "./env-path.ts"; +import { SPAWNED_PROXIES } from "./proxy-paths.ts"; + +export const WORKER_COMPANION_PROTOCOL_VERSION = 1; +export const WORKER_SSH_TIMEOUT_MS = 15_000; +const LEASE_TTL_MS = 30 * 60_000; + +const SHA256 = /^[a-f0-9]{64}$/i; +/** Control characters and the shell metacharacters that would make a quoted + * YAML scalar or an argv element ambiguous. */ +const UNSAFE_PATH = /[\u0000-\u001f"|<>]/; + +export type RemoteWorkerState = + | "unconfigured" + | "offline" + | "wrong_driver_version" + | "no_interactive_session" + | "locked" + | "policy_mismatch" + | "ready" + | "busy" + | "paused"; + +export type RemoteWorkerErrorCode = + | "worker_unconfigured" + | "worker_offline" + | "worker_driver_missing" + | "worker_driver_wrong_version" + | "worker_companion_missing" + | "worker_privileged_account" + | "worker_no_interactive_session" + | "worker_channel_missing" + | "worker_channel_access_denied" + | "worker_locked" + | "worker_policy_missing" + | "worker_policy_mismatch" + | "worker_permission_mode_mismatch" + | "worker_capability_missing" + | "worker_capability_mismatch" + | "worker_accessibility_denied" + | "worker_screen_recording_denied" + | "worker_busy" + | "worker_paused"; + +export interface RemoteWorkerLeaseRecord { + sshAlias: string; + threadId: string; + botId: string; + expiresAt: number; +} + +/** One interactive desktop admits one task at a time: two concurrent turns + * would interleave real mouse and keyboard input on the same screen. Leases + * are keyed by SSH alias rather than by worker id, so distinct workers stay + * fully independent and a Windows bot and a macOS bot hold their desktops at + * the same time. `computer-workers.ts` rejects two ids sharing one alias, + * which is what makes the key sound. */ +export class RemoteWorkerLease { + private readonly records = new Map(); + private readonly ttlMs: number; + + constructor(ttlMs = LEASE_TTL_MS) { + if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new Error("remote worker lease TTL must be positive"); + this.ttlMs = ttlMs; + } + + current(sshAlias: string, isBotBusy: (botId: string) => boolean, now = Date.now()): RemoteWorkerLeaseRecord | null { + const record = this.records.get(sshAlias); + // A lease outlives neither its TTL nor its owner's turn. Dropping it when + // the bot goes idle is what stops a crashed turn from parking a desktop + // for the whole TTL. + if (record && (record.expiresAt <= now || !isBotBusy(record.botId))) this.records.delete(sshAlias); + const current = this.records.get(sshAlias); + return current ? { ...current } : null; + } + + claim( + sshAlias: string, + threadId: string, + botId: string, + isBotBusy: (botId: string) => boolean, + now = Date.now(), + ): boolean { + if (!isValidWorkerSshAlias(sshAlias)) return false; + const current = this.current(sshAlias, isBotBusy, now); + if (current && current.threadId !== threadId) return false; + this.records.set(sshAlias, { sshAlias, threadId, botId, expiresAt: now + this.ttlMs }); + return true; + } + + touch(threadId: string, now = Date.now()): void { + for (const [alias, record] of this.records) { + if (record.expiresAt <= now) this.records.delete(alias); + else if (record.threadId === threadId) record.expiresAt = now + this.ttlMs; + } + } + + release(threadId: string): void { + for (const [alias, record] of this.records) { + if (record.threadId === threadId) this.records.delete(alias); + } + } + + /** Releases every lease on one alias regardless of owner. Used when a worker + * is removed or repointed, so a stale record cannot keep reporting `busy` + * for a machine the control plane no longer addresses. */ + releaseAlias(sshAlias: string): void { + this.records.delete(sshAlias); + } +} + +/** The exact JSON a platform health probe returns, parsed at its I/O + * boundary. This payload crosses a trust edge — it is whatever a remote + * machine's shell printed — so every field is validated here rather than + * narrowed at each use. + * + * Each field carries `.catch(undefined)` so one malformed value degrades to + * "not proven" instead of discarding the whole report. That matters: a probe + * from a half-configured worker is exactly the case the operator needs + * diagnostics for, and a report that failed to parse wholesale would surface + * as a bare offline error naming nothing. */ +const healthReportSchema = z.object({ + driverVersion: z.string().max(64).nullish().catch(undefined), + companionVersion: z.number().int().nullish().catch(undefined), + /** True when the SSH account can administer the machine. A worker account + * with admin rights can rewrite the very policy that bounds it. */ + privileged: z.boolean().optional().catch(undefined), + interactiveSession: z.boolean().optional().catch(undefined), + interactiveSessionId: z.number().int().positive().nullish().catch(undefined), + locked: z.boolean().optional().catch(undefined), + channelPath: z.string().min(1).max(512) + .refine((value) => !UNSAFE_PATH.test(value)) + .nullish().catch(undefined), + channelAvailable: z.boolean().optional().catch(undefined), + channelAccess: z.enum(["ok", "missing", "denied", "unknown"]).optional().catch(undefined), + policyDigest: z.string().regex(SHA256).transform((value) => value.toLowerCase()).nullish().catch(undefined), + policyLoaded: z.boolean().optional().catch(undefined), + permissionMode: z.enum(["bounded", "standard", "unrestricted", "unknown"]).optional().catch(undefined), + capabilityDigest: z.string().regex(SHA256).transform((value) => value.toLowerCase()).nullish().catch(undefined), + capabilityLoaded: z.boolean().optional().catch(undefined), + /** macOS only. Windows has no TCC analogue and leaves these undefined, + * which the macOS ladder treats as denied. */ + accessibilityGranted: z.boolean().optional().catch(undefined), + screenRecordingGranted: z.boolean().optional().catch(undefined), +}).loose(); + +export type RemoteWorkerHealthReport = z.output; + +/** Never throws: every field catches, so an unparseable payload yields a + * report in which nothing is proven. */ +export function parseHealthReport(raw: unknown): RemoteWorkerHealthReport { + const parsed = healthReportSchema.safeParse(raw); + return parsed.success ? parsed.data : {}; +} + +export interface RemoteWorkerStatus { + workerId: string; + platform: WorkerPlatform; + displayName: string; + configured: boolean; + state: RemoteWorkerState; + ready: boolean; + paused: boolean; + expectedDriverVersion: string; + driverVersion: string | null; + companionVersion: number | null; + privileged: boolean; + interactiveSession: boolean; + interactiveSessionId: number | null; + locked: boolean; + channelPath: string | null; + channelAvailable: boolean; + channelAccess: "ok" | "missing" | "denied" | "unknown"; + policyDigest: string | null; + /** True only when the driver reports the same digest as the on-disk policy. + * A matching file is not enough: the driver loads policy once at daemon + * start, and an unset policy variable disables enforcement entirely. */ + policyLoaded: boolean; + expectedPolicyDigest: string | null; + policyMatches: boolean; + permissionMode: "bounded" | "standard" | "unrestricted" | "unknown"; + capabilityDigest: string | null; + capabilityLoaded: boolean; + accessibilityGranted: boolean | null; + screenRecordingGranted: boolean | null; + lease: { botId: string; threadId: string; expiresAt: number } | null; + errorCode: RemoteWorkerErrorCode | null; + problem: string | null; +} + +export type RemoteWorkerSshRunner = ( + args: string[], + timeoutMs?: number, + stdin?: string, +) => Promise<{ stdout: string; stderr: string }>; + +export function remoteWorkerSshBaseArgs(sshAlias: string): string[] { + if (!isValidWorkerSshAlias(sshAlias)) throw new Error("invalid worker SSH config alias"); + return ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-T", sshAlias]; +} + +/** SSH needs the operator's home directory, agent socket, locale and PATH, + * but it never needs API keys or OpenMausBot's loopback control token. Build + * an allow-list rather than trying to enumerate every possible secret. */ +export function remoteWorkerSshEnvironment(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { PATH: augmentedPath() }; + for (const name of ["HOME", "USER", "LOGNAME", "SSH_AUTH_SOCK", "TMPDIR", "LANG", "LC_ALL", "LC_CTYPE", "TERM"]) { + const value = source[name]; + if (value !== undefined) env[name] = value; + } + return env; +} + +export function defaultRemoteWorkerRunner( + args: string[], + timeoutMs = WORKER_SSH_TIMEOUT_MS, + stdin = "", +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn("ssh", args, { + shell: false, + env: remoteWorkerSshEnvironment(), + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(); + }; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + finish(() => reject(new Error("worker SSH health check timed out"))); + }, timeoutMs); + timer.unref?.(); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdin.on("error", () => { + // A fast remote failure may close stdin before Node finishes writing. + // The child close/error path below remains the authoritative result. + }); + child.stdin.end(stdin); + child.stdout.on("data", (chunk: string) => { stdout = (stdout + chunk).slice(-1024 * 1024); }); + child.stderr.on("data", (chunk: string) => { stderr = (stderr + chunk).slice(-64 * 1024); }); + child.on("error", (error) => finish(() => reject(new Error(`worker SSH could not start: ${error.message}`)))); + child.on("close", (code) => finish(() => { + if (code === 0) resolve({ stdout, stderr }); + else reject(new Error(stderr.trim().slice(-500) || `worker SSH exited ${code ?? "without a status"}`)); + })); + }); +} + +export function isSafeChannelPath(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 512 && !UNSAFE_PATH.test(value); +} + +export function baseWorkerStatus(worker: ResolvedWorker): RemoteWorkerStatus { + return { + workerId: worker.id, + platform: worker.platform, + displayName: worker.displayName, + configured: worker.configured, + state: "unconfigured", + ready: false, + paused: worker.paused, + expectedDriverVersion: worker.expectedDriverVersion, + driverVersion: null, + companionVersion: null, + privileged: false, + interactiveSession: false, + interactiveSessionId: null, + locked: false, + channelPath: null, + channelAvailable: false, + channelAccess: "unknown", + policyDigest: null, + policyLoaded: false, + expectedPolicyDigest: worker.expectedBasePolicySha256, + policyMatches: false, + permissionMode: "unknown", + capabilityDigest: null, + capabilityLoaded: false, + accessibilityGranted: null, + screenRecordingGranted: null, + lease: null, + errorCode: "worker_unconfigured", + problem: "Configure this worker's SSH alias and expected base-policy SHA-256", + }; +} + +export function failWorker( + status: RemoteWorkerStatus, + state: RemoteWorkerState, + code: RemoteWorkerErrorCode, + problem: string, +): RemoteWorkerStatus { + status.state = state; + status.errorCode = code; + status.problem = problem; + return status; +} + +/** Copies a probe result onto the status without deciding readiness. Split + * from the ladder below so a caller can render a diagnostic panel for a + * worker that will never become ready. */ +export function applyHealthReport(status: RemoteWorkerStatus, raw: unknown): RemoteWorkerStatus { + const report = parseHealthReport(raw); + status.driverVersion = report.driverVersion ?? null; + status.companionVersion = report.companionVersion ?? null; + status.privileged = report.privileged === true; + status.interactiveSessionId = report.interactiveSessionId ?? null; + // A session flag without an id is not a proven session. + status.interactiveSession = report.interactiveSession === true && status.interactiveSessionId !== null; + status.locked = report.locked === true; + status.channelPath = report.channelPath ?? null; + status.channelAvailable = report.channelAvailable === true; + status.channelAccess = report.channelAccess ?? "unknown"; + status.policyDigest = report.policyDigest ?? null; + status.policyLoaded = report.policyLoaded === true; + status.policyMatches = status.policyLoaded + && status.policyDigest !== null + && status.policyDigest === status.expectedPolicyDigest; + status.permissionMode = report.permissionMode ?? "unknown"; + status.capabilityDigest = report.capabilityDigest ?? null; + status.capabilityLoaded = report.capabilityLoaded === true; + status.accessibilityGranted = report.accessibilityGranted ?? null; + status.screenRecordingGranted = report.screenRecordingGranted ?? null; + return status; +} + +/** The readiness ladder every platform shares, ordered so the operator sees + * the most actionable failure first. Returns a failed status, or null when + * every shared check passed and the adapter should run its own. */ +export function evaluateSharedHealth(status: RemoteWorkerStatus): RemoteWorkerStatus | null { + if (!status.driverVersion) { + return failWorker(status, "wrong_driver_version", "worker_driver_missing", + "CUA Driver is not installed or not on PATH for the worker's SSH user"); + } + if (status.driverVersion !== status.expectedDriverVersion) { + return failWorker(status, "wrong_driver_version", "worker_driver_wrong_version", + `Worker CUA Driver ${status.driverVersion} does not match required ${status.expectedDriverVersion}`); + } + if (status.companionVersion !== WORKER_COMPANION_PROTOCOL_VERSION) { + return failWorker(status, "wrong_driver_version", "worker_companion_missing", + `Worker companion protocol ${WORKER_COMPANION_PROTOCOL_VERSION} is not installed`); + } + if (status.privileged) { + return failWorker(status, "policy_mismatch", "worker_privileged_account", + "The worker's SSH account must be a dedicated non-administrator user"); + } + if (!status.interactiveSession) { + return failWorker(status, "no_interactive_session", "worker_no_interactive_session", + "No interactive desktop session is running on the worker"); + } + if (status.locked) return failWorker(status, "locked", "worker_locked", "The worker's desktop is locked"); + if (status.channelAccess === "denied") { + return failWorker(status, "no_interactive_session", "worker_channel_access_denied", + "The worker's SSH user cannot reach the interactive CUA control channel"); + } + if (!status.channelAvailable || !status.channelPath) { + return failWorker(status, "no_interactive_session", "worker_channel_missing", + "The interactive CUA control channel is not available on the worker"); + } + if (!status.policyDigest) { + return failWorker(status, "policy_mismatch", "worker_policy_missing", "The worker's base policy is missing"); + } + if (!status.policyLoaded) { + return failWorker(status, "policy_mismatch", "worker_policy_mismatch", + "CUA Driver did not report the configured base policy as loaded"); + } + if (!status.policyMatches) { + return failWorker(status, "policy_mismatch", "worker_policy_mismatch", + "The worker's base-policy digest does not match the approved configuration"); + } + if (status.permissionMode !== "bounded") { + return failWorker(status, "policy_mismatch", "worker_permission_mode_mismatch", + "CUA Driver must run in bounded permission mode"); + } + if (!status.capabilityDigest) { + return failWorker(status, "policy_mismatch", "worker_capability_missing", + "The active CUA capability manifest is missing on the worker"); + } + if (!status.capabilityLoaded) { + return failWorker(status, "policy_mismatch", "worker_capability_mismatch", + "CUA Driver did not report the active capability manifest as loaded"); + } + return null; +} + +/** Applies the lease last, so `busy` never masks a configuration fault the + * operator still has to fix. */ +export function finishWorkerStatus( + status: RemoteWorkerStatus, + sshAlias: string, + options: { lease?: RemoteWorkerLease; isBotBusy?: (botId: string) => boolean }, +): RemoteWorkerStatus { + const lease = options.lease?.current(sshAlias, options.isBotBusy ?? (() => true)) ?? null; + status.lease = lease ? { botId: lease.botId, threadId: lease.threadId, expiresAt: lease.expiresAt } : null; + if (lease) return failWorker(status, "busy", "worker_busy", "This desktop is leased by another active task"); + status.state = "ready"; + status.ready = true; + status.errorCode = null; + status.problem = null; + return status; +} + +export function remoteWorkerCuaMcpSshArgs(sshAlias: string, channelPath: string): string[] { + if (!isSafeChannelPath(channelPath)) throw new Error("invalid worker CUA control channel path"); + return [...remoteWorkerSshBaseArgs(sshAlias), "cua-driver", "mcp", "--socket", channelPath]; +} + +/** The generation string pins every fact the connection depends on. Any drift + * — a driver upgrade, a re-approved policy, a new capability manifest, a moved + * control channel — produces a different generation and forces a reconnect + * rather than silently reusing a bridge bound to the old guarantees. */ +export interface RemoteWorkerMcpDescriptor { + command: string; + args: string[]; + env: Record; + platform: WorkerPlatform; + generation: string; + scope: "remote-worker-computer"; +} + +export function remoteWorkerMcp( + worker: ResolvedWorker, + channelPath: string, + control?: { url: string; token: string }, + capabilityDigest?: string, +): RemoteWorkerMcpDescriptor { + if (!worker.sshAlias) throw new Error("worker SSH alias is not configured"); + // Throws before any bridge is spawned when the channel path is unsafe. + remoteWorkerCuaMcpSshArgs(worker.sshAlias, channelPath); + return { + command: SPAWNED_PROXIES.workerMcp, + args: [worker.sshAlias, channelPath, worker.platform], + env: control ? { OMB_CONTROL_URL: control.url, OMB_CONTROL_TOKEN: control.token } : {}, + platform: worker.platform, + generation: [ + worker.expectedDriverVersion, + worker.expectedBasePolicySha256 ?? "no-policy", + capabilityDigest ?? "parked", + channelPath, + ].join(":"), + scope: "remote-worker-computer", + }; +} diff --git a/server/windows-worker.ts b/server/windows-worker.ts new file mode 100644 index 00000000..ae6e858e --- /dev/null +++ b/server/windows-worker.ts @@ -0,0 +1,143 @@ +// Windows adapter for a named remote CUA worker. +// +// Everything transport-shaped lives in ./remote-worker.ts. This module owns +// only what has no macOS counterpart: the PowerShell health probe, the +// interactive Session 1+ window station, the named-pipe control channel, and +// the Administrators-group rule. +import { + applyHealthReport, + baseWorkerStatus, + defaultRemoteWorkerRunner, + evaluateSharedHealth, + failWorker, + finishWorkerStatus, + remoteWorkerSshBaseArgs, + WORKER_SSH_TIMEOUT_MS, + type RemoteWorkerLease, + type RemoteWorkerSshRunner, + type RemoteWorkerStatus, +} from "./remote-worker.ts"; +import type { ResolvedWorker } from "./computer-workers.ts"; + +export const WINDOWS_CUA_PIPE = "\\\\.\\pipe\\cua-driver"; +export const WINDOWS_POLICY_PATH = "%LOCALAPPDATA%\\OpenMausBot\\windows-policy.yaml"; + +const WINDOWS_HEALTH_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$driverVersion = $null +try { + $versionText = (& cua-driver --version 2>&1 | Out-String).Trim() + if ($versionText -match '(\d+\.\d+\.\d+)') { $driverVersion = $Matches[1] } +} catch {} +$companionVersion = $null +try { + $companionText = (& openmausbot-worker-companion --version 2>&1 | Out-String).Trim() + if ($companionText -match '(\d+)$') { $companionVersion = [int]$Matches[1] } +} catch {} +$privileged = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name +$explorers = @(Get-Process explorer -IncludeUserName -ErrorAction SilentlyContinue | Where-Object { $_.UserName -ieq $currentUser }) +$interactiveSessions = @($explorers | ForEach-Object { $_.SessionId } | Select-Object -Unique) +$locked = @(Get-Process LogonUI -ErrorAction SilentlyContinue | Where-Object { $interactiveSessions -contains $_.SessionId }).Count -gt 0 +$channelAvailable = $false +$channelAccess = 'unknown' +try { + $pipe = [System.IO.Pipes.NamedPipeClientStream]::new('.', 'cua-driver', [System.IO.Pipes.PipeDirection]::InOut, [System.IO.Pipes.PipeOptions]::Asynchronous) + try { $pipe.Connect(1000); $channelAvailable = $pipe.IsConnected; $channelAccess = if ($channelAvailable) { 'ok' } else { 'missing' } } + finally { $pipe.Dispose() } +} catch [System.UnauthorizedAccessException] { $channelAccess = 'denied' } +catch [System.TimeoutException] { $channelAccess = 'missing' } +catch { $channelAccess = 'missing' } +$policyPath = Join-Path $env:LOCALAPPDATA 'OpenMausBot\windows-policy.yaml' +$policyDigest = $null +if (Test-Path -LiteralPath $policyPath -PathType Leaf) { $policyDigest = (Get-FileHash -Algorithm SHA256 -LiteralPath $policyPath).Hash.ToLowerInvariant() } +$daemonStatus = '' +try { $daemonStatus = (& cua-driver status --socket '\\.\pipe\cua-driver' 2>&1 | Out-String).ToLowerInvariant() } catch {} +$interactiveSessionId = $null +if ($daemonStatus -match 'session:\s*(\d+)') { $interactiveSessionId = [int]$Matches[1] } +$interactive = $interactiveSessionId -ne $null -and $interactiveSessions -contains $interactiveSessionId +$policyLoaded = $false +if ($policyDigest) { $policyLoaded = $daemonStatus.Contains($policyDigest) } +$permissionMode = 'unknown' +if ($daemonStatus -match '\bbounded\b') { $permissionMode = 'bounded' } +elseif ($daemonStatus -match '\bstandard\b') { $permissionMode = 'standard' } +elseif ($daemonStatus -match '\bunrestricted\b') { $permissionMode = 'unrestricted' } +$capabilityPath = Join-Path $env:LOCALAPPDATA 'OpenMausBot\active-capabilities.yaml' +$capabilityDigest = $null +if (Test-Path -LiteralPath $capabilityPath -PathType Leaf) { $capabilityDigest = (Get-FileHash -Algorithm SHA256 -LiteralPath $capabilityPath).Hash.ToLowerInvariant() } +$capabilityLoaded = $false +if ($capabilityDigest) { $capabilityLoaded = $daemonStatus.Contains($capabilityDigest) } +[ordered]@{ + driverVersion = $driverVersion + companionVersion = $companionVersion + privileged = $privileged + interactiveSession = $interactive + interactiveSessionId = $interactiveSessionId + locked = $locked + channelPath = '\\.\pipe\cua-driver' + channelAvailable = $channelAvailable + channelAccess = $channelAccess + policyDigest = $policyDigest + policyLoaded = $policyLoaded + permissionMode = $permissionMode + capabilityDigest = $capabilityDigest + capabilityLoaded = $capabilityLoaded +} | ConvertTo-Json -Compress +`; + +// Windows PowerShell's `-Command -` reads stdin interactively and does not +// reliably assemble multiline blocks. Keep argv short and fixed by encoding +// only this tiny bootstrap; the full fixed probe stays on stdin and is parsed +// as one script block. +const HEALTH_STDIN_WRAPPER_BASE64 = Buffer.from( + "$source = [Console]::In.ReadToEnd(); & ([ScriptBlock]::Create($source))", + "utf16le", +).toString("base64"); + +export function windowsWorkerHealthArgs(sshAlias: string): string[] { + return [ + ...remoteWorkerSshBaseArgs(sshAlias), + "powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + HEALTH_STDIN_WRAPPER_BASE64, + ]; +} + +export async function windowsWorkerStatus( + worker: ResolvedWorker, + options: { + runner?: RemoteWorkerSshRunner; + lease?: RemoteWorkerLease; + isBotBusy?: (botId: string) => boolean; + } = {}, +): Promise { + const status = baseWorkerStatus(worker); + if (!worker.configured) return status; + if (worker.paused) return failWorker(status, "paused", "worker_paused", "This worker is paused"); + + const runner = options.runner ?? defaultRemoteWorkerRunner; + let report: unknown; + try { + // Keep the fixed health program off argv. Windows OpenSSH invokes the + // user's command through cmd.exe, whose command-line ceiling is lower + // than PowerShell's encoded form of this probe. Stdin also keeps process + // listings limited to one fixed, inspectable command. + const result = await runner( + windowsWorkerHealthArgs(worker.sshAlias), + WORKER_SSH_TIMEOUT_MS, + WINDOWS_HEALTH_SCRIPT, + ); + report = JSON.parse(result.stdout.trim()); + } catch (error) { + return failWorker(status, "offline", "worker_offline", + `Worker SSH is offline: ${error instanceof Error ? error.message.slice(0, 200) : "unknown error"}`); + } + + applyHealthReport(status, report); + const failed = evaluateSharedHealth(status); + if (failed) return failed; + return finishWorkerStatus(status, worker.sshAlias, options); +} diff --git a/server/worker-mcp.ts b/server/worker-mcp.ts new file mode 100644 index 00000000..93e1c7d0 --- /dev/null +++ b/server/worker-mcp.ts @@ -0,0 +1,49 @@ +// Transparent stdio bridge to the official CUA Driver in a remote worker's +// interactive session. Authentication is owned by the operator's OpenSSH +// config; the only remote command is the pinned CUA MCP invocation, and the +// child environment is the allow-list from ./remote-worker.ts rather than +// this process's own environment. +import { isWorkerPlatform, type WorkerPlatform } from "./computer-workers.ts"; +import { runMcpBridge } from "./mcp-bridge.ts"; +import { + remoteWorkerCuaMcpSshArgs, + remoteWorkerSshBaseArgs, + remoteWorkerSshEnvironment, +} from "./remote-worker.ts"; + +const [alias = "", channelPath = "", rawPlatform = ""] = process.argv.slice(2); + +/** A no-op that exits 0 through each platform's default SSH shell. Windows + * OpenSSH hands the command to cmd.exe, which has no `true`. */ +function livenessCommand(platform: WorkerPlatform): string[] { + return platform === "windows" + ? ["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "exit 0"] + : ["/bin/sh", "-c", "exit 0"]; +} + +let args: string[]; +let livenessArgs: string[]; +try { + if (!isWorkerPlatform(rawPlatform)) throw new Error("unknown worker platform"); + args = remoteWorkerCuaMcpSshArgs(alias, channelPath); + livenessArgs = [...remoteWorkerSshBaseArgs(alias), ...livenessCommand(rawPlatform)]; +} catch { + process.stderr.write("invalid worker MCP connection\n"); + process.exit(2); +} + +const gate = (() => { + const url = process.env.OMB_CONTROL_URL ?? ""; + const token = process.env.OMB_CONTROL_TOKEN ?? ""; + return url && token ? { gate: { url, token } } : {}; +})(); +const sshEnv = remoteWorkerSshEnvironment(); + +runMcpBridge({ + command: "ssh", + args, + env: sshEnv, + label: "Worker CUA Driver", + liveness: { command: "ssh", args: livenessArgs, env: sshEnv }, + ...gate, +}); diff --git a/server/worker-status.test.ts b/server/worker-status.test.ts new file mode 100644 index 00000000..f025aaaf --- /dev/null +++ b/server/worker-status.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from "vitest"; + +import { listWorkers, type ResolvedWorker } from "./computer-workers.ts"; +import { macWorkerHealthArgs } from "./mac-worker.ts"; +import { RemoteWorkerLease, remoteWorkerMcp, remoteWorkerSshEnvironment } from "./remote-worker.ts"; +import { windowsWorkerHealthArgs } from "./windows-worker.ts"; +import { allWorkerStatuses, workerStatus } from "./worker-status.ts"; + +const policy = "a".repeat(64); +const capability = "c".repeat(64); + +const [macWorker, winWorker] = listWorkers({ + "mac-guest": { platform: "macos", sshAlias: "macguest", expectedBasePolicySha256: policy }, + "win-box": { platform: "windows", sshAlias: "winbox", expectedBasePolicySha256: policy }, +}) as [ResolvedWorker, ResolvedWorker]; + +const MAC_SOCKET = "/Users/worker/.openmausbot/run/cua.sock"; +const WIN_PIPE = "\\\\.\\pipe\\cua-driver"; + +function healthy(platform: "macos" | "windows", overrides: Record = {}) { + return JSON.stringify({ + driverVersion: "0.20.0", + companionVersion: 1, + privileged: false, + interactiveSession: true, + interactiveSessionId: platform === "macos" ? 501 : 2, + locked: false, + channelPath: platform === "macos" ? MAC_SOCKET : WIN_PIPE, + channelAvailable: true, + channelAccess: "ok", + policyDigest: policy, + policyLoaded: true, + permissionMode: "bounded", + capabilityDigest: capability, + capabilityLoaded: true, + ...(platform === "macos" ? { accessibilityGranted: true, screenRecordingGranted: true } : {}), + ...overrides, + }); +} + +/** A fake worker: no SSH, no guest, just the probe's exact stdout. */ +const runnerFor = (platform: "macos" | "windows", overrides: Record = {}) => + async () => ({ stdout: healthy(platform, overrides), stderr: "" }); + +describe("remote worker readiness", () => { + it("reports a fully configured worker of either platform as ready", async () => { + const mac = await workerStatus(macWorker, { runner: runnerFor("macos") }); + const win = await workerStatus(winWorker, { runner: runnerFor("windows") }); + expect([mac.ready, win.ready]).toEqual([true, true]); + expect([mac.state, win.state]).toEqual(["ready", "ready"]); + expect(mac.channelPath).toBe(MAC_SOCKET); + expect(win.channelPath).toBe(WIN_PIPE); + }); + + it("sends each platform its own fixed probe over stdin", () => { + expect(macWorkerHealthArgs("macguest")).toEqual([ + "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-T", "macguest", "/bin/sh", "-s", + ]); + const win = windowsWorkerHealthArgs("winbox"); + expect(win.slice(0, 6)).toEqual(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-T", "winbox"]); + expect(win.slice(6, -1)).toEqual([ + "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", + ]); + // Windows OpenSSH runs the command through cmd.exe, whose command-line + // ceiling is far below the encoded form of the whole probe. + expect(win.at(-1)!.length).toBeLessThan(1_024); + expect(() => macWorkerHealthArgs("host -o ProxyCommand=curl")).toThrow(/invalid worker SSH/); + }); + + it("refuses an administrator worker account on both platforms", async () => { + // An admin worker could rewrite the very base policy that bounds it. + for (const [worker, platform] of [[macWorker, "macos"], [winWorker, "windows"]] as const) { + const status = await workerStatus(worker, { runner: runnerFor(platform, { privileged: true }) }); + expect(status.ready).toBe(false); + expect(status.errorCode).toBe("worker_privileged_account"); + } + }); + + it("refuses a driver whose version is not the pinned one", async () => { + const status = await workerStatus(macWorker, { runner: runnerFor("macos", { driverVersion: "0.19.3" }) }); + expect(status.errorCode).toBe("worker_driver_wrong_version"); + }); + + it("refuses a policy file that the daemon never loaded", async () => { + // A matching file on disk is not enough: the driver reads policy once at + // start, and an unset policy variable disables enforcement entirely. + const status = await workerStatus(winWorker, { runner: runnerFor("windows", { policyLoaded: false }) }); + expect(status.errorCode).toBe("worker_policy_mismatch"); + expect(status.policyMatches).toBe(false); + }); + + it("refuses a locked desktop and a missing control channel", async () => { + const locked = await workerStatus(macWorker, { runner: runnerFor("macos", { locked: true }) }); + expect(locked.errorCode).toBe("worker_locked"); + const noChannel = await workerStatus(macWorker, { + runner: runnerFor("macos", { channelAvailable: false, channelAccess: "missing" }), + }); + expect(noChannel.errorCode).toBe("worker_channel_missing"); + }); + + it("fails closed when macOS TCC grants are absent", async () => { + // Accessibility and Screen Recording are per-binary and are silently + // revoked when the driver binary is replaced, so an absent grant must + // never read as ready. + const noAx = await workerStatus(macWorker, { runner: runnerFor("macos", { accessibilityGranted: false }) }); + expect(noAx.errorCode).toBe("worker_accessibility_denied"); + const noScreen = await workerStatus(macWorker, { runner: runnerFor("macos", { screenRecordingGranted: false }) }); + expect(noScreen.errorCode).toBe("worker_screen_recording_denied"); + // A probe that omits the fields entirely is "not proven", not "fine". + const silent = await workerStatus(macWorker, { + runner: async () => { + const report = JSON.parse(healthy("macos")); + delete report.accessibilityGranted; + return { stdout: JSON.stringify(report), stderr: "" }; + }, + }); + expect(silent.ready).toBe(false); + expect(silent.errorCode).toBe("worker_accessibility_denied"); + }); + + it("degrades a malformed probe field to unproven instead of discarding the report", async () => { + // A half-configured worker is exactly the case the operator needs + // diagnostics for, so one bad field must not collapse the whole report + // into a bare offline error that names nothing. + const status = await workerStatus(winWorker, { + runner: async () => ({ + stdout: JSON.stringify({ + driverVersion: "0.20.0", + companionVersion: 1, + privileged: false, + interactiveSession: true, + interactiveSessionId: 2, + locked: false, + channelPath: WIN_PIPE, + channelAvailable: true, + channelAccess: "ok", + policyDigest: "not-a-digest", + policyLoaded: true, + permissionMode: "bounded", + capabilityDigest: capability, + capabilityLoaded: true, + }), + stderr: "", + }), + }); + expect(status.driverVersion).toBe("0.20.0"); + expect(status.interactiveSession).toBe(true); + expect(status.policyDigest).toBeNull(); + expect(status.errorCode).toBe("worker_policy_missing"); + }); + + it("proves nothing when the probe returns something that is not a report", async () => { + const status = await workerStatus(macWorker, { + runner: async () => ({ stdout: '"not an object"', stderr: "" }), + }); + expect(status.ready).toBe(false); + expect(status.errorCode).toBe("worker_driver_missing"); + }); + + it("treats an unreachable worker as offline rather than throwing", async () => { + const status = await workerStatus(winWorker, { + runner: async () => { throw new Error("ssh: connect to host winbox port 22: Host is down"); }, + }); + expect(status.state).toBe("offline"); + expect(status.errorCode).toBe("worker_offline"); + expect(status.problem).toContain("Host is down"); + }); + + it("keeps one dead worker from taking the healthy one down with it", async () => { + // #508 acceptance 6: disconnect either worker and the other stays usable. + const statuses = await allWorkerStatuses([macWorker, winWorker], { + runner: async (args) => { + if (args.includes("winbox")) throw new Error("Host is down"); + return { stdout: healthy("macos"), stderr: "" }; + }, + }); + const byId = Object.fromEntries(statuses.map((status) => [status.workerId, status])); + expect(byId["mac-guest"].ready).toBe(true); + expect(byId["win-box"].state).toBe("offline"); + }); +}); + +describe("worker leases", () => { + it("lets a macOS bot and a Windows bot hold their desktops at the same time", async () => { + // The whole point of the registry: two OS-different desktops, two bots, + // one control plane, concurrently. + const lease = new RemoteWorkerLease(); + const busy = new Set(["bot-mac", "bot-win"]); + const isBotBusy = (botId: string) => busy.has(botId); + + expect(lease.claim(macWorker.sshAlias, "thread-mac", "bot-mac", isBotBusy)).toBe(true); + expect(lease.claim(winWorker.sshAlias, "thread-win", "bot-win", isBotBusy)).toBe(true); + + const mac = await workerStatus(macWorker, { runner: runnerFor("macos"), lease, isBotBusy }); + const win = await workerStatus(winWorker, { runner: runnerFor("windows"), lease, isBotBusy }); + expect(mac.lease?.botId).toBe("bot-mac"); + expect(win.lease?.botId).toBe("bot-win"); + // Each desktop reports busy to *other* callers while its own turn runs; + // neither lease blocks the other. + expect([mac.errorCode, win.errorCode]).toEqual(["worker_busy", "worker_busy"]); + }); + + it("admits one task per desktop and releases it with the turn", async () => { + const lease = new RemoteWorkerLease(); + const busy = new Set(["bot-a"]); + const isBotBusy = (botId: string) => busy.has(botId); + + expect(lease.claim(macWorker.sshAlias, "thread-a", "bot-a", isBotBusy)).toBe(true); + expect(lease.claim(macWorker.sshAlias, "thread-b", "bot-b", isBotBusy)).toBe(false); + // Re-claiming from the same thread is a renewal, not a conflict. + expect(lease.claim(macWorker.sshAlias, "thread-a", "bot-a", isBotBusy)).toBe(true); + + lease.release("thread-a"); + const free = await workerStatus(macWorker, { runner: runnerFor("macos"), lease, isBotBusy }); + expect(free.ready).toBe(true); + }); + + it("drops a lease whose owning turn ended without releasing it", () => { + const lease = new RemoteWorkerLease(); + let busy = true; + const isBotBusy = () => busy; + lease.claim(macWorker.sshAlias, "thread-a", "bot-a", isBotBusy); + busy = false; + // Otherwise a crashed turn parks the desktop for the full TTL. + expect(lease.current(macWorker.sshAlias, isBotBusy)).toBeNull(); + }); + + it("expires a lease at its TTL", () => { + const lease = new RemoteWorkerLease(1_000); + const isBotBusy = () => true; + lease.claim(macWorker.sshAlias, "thread-a", "bot-a", isBotBusy, 0); + expect(lease.current(macWorker.sshAlias, isBotBusy, 999)).not.toBeNull(); + expect(lease.current(macWorker.sshAlias, isBotBusy, 1_001)).toBeNull(); + }); + + it("frees an alias outright when its worker is removed or repointed", () => { + const lease = new RemoteWorkerLease(); + const isBotBusy = () => true; + lease.claim(macWorker.sshAlias, "thread-a", "bot-a", isBotBusy); + lease.releaseAlias(macWorker.sshAlias); + expect(lease.current(macWorker.sshAlias, isBotBusy)).toBeNull(); + }); +}); + +describe("worker MCP boundary", () => { + it("pins driver, policy, capability and channel into the generation", () => { + const descriptor = remoteWorkerMcp(macWorker, MAC_SOCKET, undefined, capability); + expect(descriptor.platform).toBe("macos"); + expect(descriptor.scope).toBe("remote-worker-computer"); + expect(descriptor.args).toEqual(["macguest", MAC_SOCKET, "macos"]); + expect(descriptor.generation).toBe(`0.20.0:${policy}:${capability}:${MAC_SOCKET}`); + // A parked capability must not look the same as an approved one. + expect(remoteWorkerMcp(macWorker, MAC_SOCKET).generation).toContain(":parked:"); + }); + + it("refuses a control channel path that could smuggle shell syntax", () => { + expect(() => remoteWorkerMcp(macWorker, "/tmp/a b|nc evil 1")).toThrow(/control channel/); + expect(() => remoteWorkerMcp(macWorker, "")).toThrow(/control channel/); + }); + + it("allows only SSH runtime metadata into the bridge process", () => { + // The bridge must never inherit provider credentials or the loopback + // control token, so the environment is an allow-list, not a deny-list. + const marker = "must-not-be-forwarded"; + const env = remoteWorkerSshEnvironment({ + HOME: "/Users/gus", + SSH_AUTH_SOCK: "/tmp/agent.sock", + LANG: "en_US.UTF-8", + EXAMPLE_PROVIDER_CREDENTIAL: marker, + OMB_CONTROL_TOKEN: marker, + SOME_OTHER_SETTING: marker, + }); + expect(Object.keys(env).sort()).toEqual(["HOME", "LANG", "PATH", "SSH_AUTH_SOCK"]); + expect(JSON.stringify(env)).not.toContain(marker); + }); +}); diff --git a/server/worker-status.ts b/server/worker-status.ts new file mode 100644 index 00000000..56a3b266 --- /dev/null +++ b/server/worker-status.ts @@ -0,0 +1,26 @@ +// One entry point for reading a named worker's readiness, so callers never +// branch on platform themselves. +import type { ResolvedWorker } from "./computer-workers.ts"; +import { macWorkerStatus } from "./mac-worker.ts"; +import type { RemoteWorkerLease, RemoteWorkerSshRunner, RemoteWorkerStatus } from "./remote-worker.ts"; +import { windowsWorkerStatus } from "./windows-worker.ts"; + +export interface WorkerStatusOptions { + runner?: RemoteWorkerSshRunner; + lease?: RemoteWorkerLease; + isBotBusy?: (botId: string) => boolean; +} + +export function workerStatus(worker: ResolvedWorker, options: WorkerStatusOptions = {}): Promise { + return worker.platform === "windows" ? windowsWorkerStatus(worker, options) : macWorkerStatus(worker, options); +} + +/** Reads every configured worker concurrently. One unreachable worker must + * never delay or fail the others: #508 requires a dead worker to degrade to + * unavailable while healthy desktops keep serving. */ +export async function allWorkerStatuses( + workers: ResolvedWorker[], + options: WorkerStatusOptions = {}, +): Promise { + return Promise.all(workers.map((worker) => workerStatus(worker, options))); +} From e5d0ce1bbe481892da9ee0ad39456e3e423862a1 Mon Sep 17 00:00:00 2001 From: gus <42593099+lightcloud00@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:29:07 -0400 Subject: [PATCH 02/10] feat(workers): let a bot act on a named Windows or macOS worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the worker registry into the product: a bot can now pick `worker` as its computer destination and name which worker it acts on, so one control plane can run a bot on a macOS guest and another on a Windows PC at the same time. Server: - `computer: "worker"` joins the per-bot destinations, with `workerId` naming the target. The bot stores only the id; the SSH alias never leaves the control plane, including from GET /api/workers. - The turn claims the worker's lease before its first await, matching Local VM. Otherwise two turns could both pass the readiness check and then both mount one physical desktop, interleaving real input on one screen. The lease renews while the turn streams and is released on completion, on error, and when the owning bot goes idle. - GET /api/workers probes every worker concurrently, so an unreachable worker neither delays nor fails the healthy one. - Editing a worker that a live turn holds is refused; one that is removed or repointed has its lease dropped, so no record keeps reporting `busy` for a machine the control plane no longer addresses. - Assignment and destination are validated together, because either field can arrive alone and `worker` without a resolvable id would otherwise fail at the start of the next turn, long after the person left Settings. Approval scope: a worker drives a real interactive desktop, so it gets the same treatment as `local` — a remembered always-allow grant does not cover it. `ApprovalScope` now names both cases rather than string-matching one. Auto mode is refused on a worker: every task is bounded by three explicit fences, so there is nothing for it to approve on its own. UI: a Worker destination and a picker showing each worker's platform and the first thing that is actually wrong, rather than a generic "not ready". Docs: docs/byo-macos.md is the guest runbook — non-admin worker account, auto-login, no screen lock, the pinned driver, and the one step nobody can script, granting Accessibility and Screen Recording to the driver binary. Verified: server and UI typecheck, production build, and the packaged-server smoke — all 10 spawned proxy paths resolve inside the packaged dir, which is the check that would have caught the new worker-mcp entry point going missing. Refs #508 Co-Authored-By: Claude Opus 5 --- docs/byo-macos.md | 169 +++++++++++++++++++++++++++++++ docs/macos-base-policy.yaml | 44 ++++++++ docs/windows-base-policy.yaml | 44 ++++++++ server/auto-approve.ts | 19 ++-- server/contracts.ts | 11 +- server/index.ts | 122 +++++++++++++++++++++- server/remote-worker.ts | 7 +- server/store.ts | 23 ++++- server/worker-status.test.ts | 4 +- src/components/ComputerPanel.tsx | 17 +++- src/components/WorkerPicker.tsx | 99 ++++++++++++++++++ src/lib/workers.ts | 50 +++++++++ src/state/bot-patch-queue.ts | 1 + src/state/store.tsx | 6 +- 14 files changed, 591 insertions(+), 25 deletions(-) create mode 100644 docs/byo-macos.md create mode 100644 docs/macos-base-policy.yaml create mode 100644 docs/windows-base-policy.yaml create mode 100644 src/components/WorkerPicker.tsx create mode 100644 src/lib/workers.ts diff --git a/docs/byo-macos.md b/docs/byo-macos.md new file mode 100644 index 00000000..e086f856 --- /dev/null +++ b/docs/byo-macos.md @@ -0,0 +1,169 @@ +# Bring your own macOS worker + +OpenMausBot keeps its control plane on one Mac and connects a bot to a macOS +machine you already run — a guest VM on the same Apple silicon Mac, or a +second physical Mac. It does not create the guest, manage a hypervisor, store +SSH credentials, open a TCP listener, mount the control plane's workspace, or +fall back to another computer when the worker fails. + +A macOS worker pairs with [a Windows worker](byo-windows.md) rather than +replacing it: workers are named independently and lease independently, so one +bot can hold a macOS desktop while another holds a Windows desktop. + +## Why a guest and not this Mac + +The `local` computer beta drives the Mac OpenMausBot is running on. That Mac +is also yours — the bot shares your screen, your keyboard and your files. A +guest gives the bot its own login session, its own home directory and its own +Accessibility grants, and it can be rebuilt from scratch when something goes +wrong. + +Apple's software licence allows up to two macOS guests on one Apple silicon +host, so a single worker guest leaves headroom. + +## Before you start + +- **Apple silicon.** macOS guests use Virtualization.framework; an Intel Mac + cannot host one. +- **Disk.** Budget 80–100 GB: a restore image is roughly 16 GB (deletable + after install) plus the guest's own disk. +- **A hypervisor.** [`tart`](https://tart.run) is the easiest to keep + reproducible — it is CLI-driven, pulls prebuilt Apple silicon images, and + `tart ip` gives you an address to put in your SSH config. UTM works too if + you would rather click through the install. + +## Create the guest + +```bash +brew install cirruslabs/cli/tart +tart clone ghcr.io/cirruslabs/macos-sequoia-base:latest omb-worker +tart set omb-worker --cpu 4 --memory 8192 --disk-size 80 +tart run omb-worker +``` + +Then, inside the guest: + +1. Create a **dedicated standard (non-administrator) account** for the worker. + Readiness refuses an account in the `admin` group: an administrator could + rewrite the very base policy that bounds it, so installing the tools as an + admin does not make that account an eligible worker. +2. Log in as the worker account and turn on **Users & Groups → automatic + login** for it. An Aqua session must exist at all times; readiness checks + that the worker account owns `/dev/console`. +3. Turn **off** screen lock and sleep (Lock Screen → *Require password … + Never*, *Turn display off … Never*). A locked screen reads as not ready. +4. Turn on **General → Sharing → Remote Login** for that account only. + +On the control-plane Mac, add the guest to your SSH config with key-only +authentication and confirm it works before going further: + +```bash +ssh omb-worker true +``` + +OpenMausBot stores only that alias. + +## Install the tools + +Inside the guest, as the worker account: + +```bash +cua-driver --version # must print exactly 0.20.0 +node --version # 24 or newer +openmausbot-worker-companion --version +``` + +Install the pinned CUA Driver release with the official instructions — do not +use an unreviewed wrapper or an ambient alternate binary. Build the companion +from the exact OpenMausBot source commit on the control-plane Mac, copy only +its `package.json` and `dist/` into a private directory owned by the worker +account, and put its `openmausbot-worker-companion` bin on that account's +`PATH`. + +The driver listens on a unix socket at `~/.openmausbot/run/cua.sock`. Both the +socket and its directory must be owned by the worker account and private to +it; readiness refuses a socket it cannot read and write. + +## Grant Accessibility and Screen Recording + +This is the one step nobody can script for you. macOS grants both permissions +**per binary**, System Integrity Protection prevents writing the permission +database, and replacing the driver binary silently revokes them. + +In the guest, open **System Settings → Privacy & Security** and add the CUA +Driver binary under both **Accessibility** and **Screen Recording**. Then +confirm the driver itself sees them: + +```bash +openmausbot-worker-companion --permissions +``` + +It prints `{"accessibility":true,"screenRecording":true}` when both are live. + +Readiness re-reads this on every poll rather than trusting that you did it +once, so a driver upgrade that drops the grants surfaces as +`worker_accessibility_denied` instead of as mysterious failures mid-task. + +## Pin the base policy + +Copy [`macos-base-policy.yaml`](macos-base-policy.yaml) into the guest at +`~/Library/Application Support/OpenMausBot/macos-policy.yaml`, then record its +digest: + +```bash +shasum -a 256 ~/Library/Application\ Support/OpenMausBot/macos-policy.yaml +``` + +Enter that digest in OpenMausBot when you add the worker. Until you do, the +worker stays *unconfigured*: without a pinned digest the driver's tool ceiling +would be whatever happens to be on the guest's disk. + +Note that a matching file is not sufficient on its own. CUA loads its policy +once at daemon start, and an unset policy variable disables enforcement +entirely, so readiness requires the daemon to *report* the same digest it +finds on disk. + +## Add the worker + +In OpenMausBot, open **Settings → Workers**, add a worker with: + +- an id (lowercase, e.g. `mac-guest`) +- platform **macOS** +- the SSH alias +- the base-policy digest + +Then assign a bot to it from that bot's Computer panel. Two workers may not +share one SSH alias — that would take two independent leases against a single +real desktop, and each would believe it held the screen exclusively. + +## What the bot can and cannot do + +One bot leases a macOS worker at a time; a second turn aimed at the same +desktop waits rather than interleaving real mouse and keyboard input. Work on +another worker, and on Linux Local VMs, continues in parallel. + +Auto mode is unavailable on a worker. Every task is bounded by three +independent fences — the stable base policy, a short-lived CUA capability +manifest, and the task manifest — so there is nothing for auto mode to +approve on its own. + +At a sign-in, password, MFA or CAPTCHA step the bot stops and asks you to +complete it on the visible screen. + +## When it is not ready + +Readiness reports the first thing that is actually wrong: + +| Code | What to fix | +| --- | --- | +| `worker_offline` | SSH cannot reach the guest | +| `worker_driver_missing` / `worker_driver_wrong_version` | CUA Driver absent, off `PATH`, or not 0.20.0 | +| `worker_companion_missing` | the companion is not installed for the worker account | +| `worker_privileged_account` | the SSH account is in the `admin` group | +| `worker_no_interactive_session` | nobody is logged in at the guest's console | +| `worker_locked` | the guest's screen is locked | +| `worker_channel_missing` / `worker_channel_access_denied` | the driver socket is absent or not private to the worker account | +| `worker_policy_missing` / `worker_policy_mismatch` | the base policy is absent, unloaded, or not the pinned digest | +| `worker_permission_mode_mismatch` | CUA Driver is not running in bounded mode | +| `worker_accessibility_denied` / `worker_screen_recording_denied` | grant the permission to the driver binary in the guest | +| `worker_busy` | another turn holds this desktop | diff --git a/docs/macos-base-policy.yaml b/docs/macos-base-policy.yaml new file mode 100644 index 00000000..55b57921 --- /dev/null +++ b/docs/macos-base-policy.yaml @@ -0,0 +1,44 @@ +# OpenMausBot macOS base policy for CUA Driver 0.20.0. +# +# This is the stable tool ceiling. The active version-3 capability manifest +# intersects it at runtime and supplies the per-task application, file, and +# browser-origin boundary. A tool must pass both layers. +allow: + tools: + - start_session + - end_session + - launch_app + - list_windows + - get_window_state + - click + - double_click + - right_click + - drag + - scroll + - press_key + - hotkey + - set_value + - wait + - bring_to_front + - browser_prepare + - get_browser_state + - browser_navigate + - browser_click + - browser_type + rules: + - tool: type_text + constraints: + text: + max_length: 4096 +deny: + tools: + - shell_execute + - run_javascript + - execute_javascript + - page + - get_desktop_state + - get_accessibility_tree + - browser_download + - install_ffmpeg + - kill_app + - start_recording diff --git a/docs/windows-base-policy.yaml b/docs/windows-base-policy.yaml new file mode 100644 index 00000000..675c0a62 --- /dev/null +++ b/docs/windows-base-policy.yaml @@ -0,0 +1,44 @@ +# OpenMausBot Windows base policy for CUA Driver 0.20.0. +# +# This is the stable tool ceiling. The active version-3 capability manifest +# intersects it at runtime and supplies the per-task application, file, and +# browser-origin boundary. A tool must pass both layers. +allow: + tools: + - start_session + - end_session + - launch_app + - list_windows + - get_window_state + - click + - double_click + - right_click + - drag + - scroll + - press_key + - hotkey + - set_value + - wait + - bring_to_front + - browser_prepare + - get_browser_state + - browser_navigate + - browser_click + - browser_type + rules: + - tool: type_text + constraints: + text: + max_length: 4096 +deny: + tools: + - shell_execute + - run_javascript + - execute_javascript + - page + - get_desktop_state + - get_accessibility_tree + - browser_download + - install_ffmpeg + - kill_app + - start_recording diff --git a/server/auto-approve.ts b/server/auto-approve.ts index bf83565f..e6d619e4 100644 --- a/server/auto-approve.ts +++ b/server/auto-approve.ts @@ -10,6 +10,8 @@ // backstop for the obvious catastrophes. Real containment is the // sandbox and the bot's own computer, not a regex. +import type { ApprovalScope } from "./contracts.ts"; + const DESTRUCTIVE = [ /\brm\s+(-[a-z]*\s+)*-[a-z]*[rf]/i, // rm -rf, rm -fr, rm -r -f /\bmkfs\b|\bdiskutil\s+erase|\bdd\s+[^|]*\bof=\/dev\//i, @@ -58,7 +60,7 @@ export function looksDestructive(text: string): boolean { * client so the two sides can never disagree about what was granted. */ const COMMAND_TOOLS = new Set(["bash", "shell", "execute", "run_command", "computer_exec", "terminal"]); -export function approvalKey(tool: string, summary: string, scope?: "local-computer"): string { +export function approvalKey(tool: string, summary: string, scope?: ApprovalScope): string { const bare = tool.replace(/^mcp__[^_]+__/, "").toLowerCase(); if (!COMMAND_TOOLS.has(bare)) return scope ? `${scope}:${tool}` : tool; // first bare word of the command, skipping env assignments and sudo @@ -110,8 +112,9 @@ export function autoVerdict( context?: { /** the turn was started by an outside event, with nobody at the keyboard */ unattended?: boolean; - /** the request controls the user's active desktop */ - scope?: "local-computer"; + /** the request drives a real interactive desktop — the user's own, or a + * worker machine they own */ + scope?: ApprovalScope; }, ): AutoVerdict { // the guards outrank the grants, so an "always allow" can never widen @@ -144,8 +147,9 @@ export function autoVerdict( if (sensitive) return { approve: null, source: "sensitive-guard", rule: sensitive }; return { approve: null, source: "no-grant" }; } - if (context?.scope === "local-computer" && !bot.autoApprove) { - // Host control is not covered by a remembered always-allow grant. + if (context?.scope !== undefined && !bot.autoApprove) { + // Desktop control is not covered by a remembered always-allow grant — + // neither the user's own screen nor a worker machine they own. // After the Auto-on-this-computer warning, unclassified GUI actions // (click/type) may auto-approve; destructive/sensitive still card. if (grant) return { approve: null, source: "local-computer-block", rule: grant.rule }; @@ -167,8 +171,9 @@ export function autoDecision( context?: { /** the turn was started by an outside event, with nobody at the keyboard */ unattended?: boolean; - /** the request controls the user's active desktop */ - scope?: "local-computer"; + /** the request drives a real interactive desktop — the user's own, or a + * worker machine they own */ + scope?: ApprovalScope; }, ): string | null { return autoVerdict(bot, tool, summary, context).approve; diff --git a/server/contracts.ts b/server/contracts.ts index 6098de4b..46efd029 100644 --- a/server/contracts.ts +++ b/server/contracts.ts @@ -11,6 +11,11 @@ export type ThreadId = string; export type TurnId = string; export type CloudBackend = "box" | "vps"; +/** A tool call that drives a real interactive desktop, rather than a + * disposable container. Both values mean the same thing to the approval + * rules: a remembered always-allow grant does not cover it. */ +export type ApprovalScope = "local-computer" | "remote-worker-computer"; + export type ProviderErrorCode = | "missing_cli" | "invalid_credentials" @@ -117,7 +122,7 @@ export type RuntimeEvent = RuntimeEventBase & tool: string; summary: string; choices?: string[]; - approvalScope?: "local-computer"; + approvalScope?: ApprovalScope; } | { type: "request.resolved"; @@ -126,7 +131,7 @@ export type RuntimeEvent = RuntimeEventBase & * harness (turn ended / settings changed), or nobody — the answerer * was already gone and the action never ran */ source: "user" | "auto" | "timeout" | "system" | "unavailable" | "peer"; - approvalScope?: "local-computer"; + approvalScope?: ApprovalScope; } | { type: "thread.token-usage.updated"; input: number; output: number; cachedInput?: number } // `setup: true` marks a failure the user fixes by installing or @@ -183,7 +188,7 @@ export interface SendTurnInput { env: Record; platform?: "darwin" | "linux" | "win32"; generation?: string; - scope?: "local-computer"; + scope?: ApprovalScope; }; /** Peer-agent comms: an MCP proxy (list_bots / ask_bot) that routes back * through the harness so this bot can message other bots. The harness diff --git a/server/index.ts b/server/index.ts index 4e1048d9..52569734 100644 --- a/server/index.ts +++ b/server/index.ts @@ -63,6 +63,8 @@ import { syncCredentialEnv, withInstanceCli, vpsSshAlias, + configuredWorkers, + workerById, DATA_DIR, EVENTS_DIR, NATIVE_DIR, @@ -129,6 +131,9 @@ import { fetchSkillFromSource } from "./skill-fetch.ts"; import { readCuaConnection } from "./local-computer.ts"; import { LocalVmIdleTimer } from "./local-vm-idle.ts"; import { LocalVmLease, LocalVmLeasePool } from "./local-vm-lease.ts"; +import { publicWorker, type ResolvedWorker } from "./computer-workers.ts"; +import { RemoteWorkerLease, remoteWorkerMcp } from "./remote-worker.ts"; +import { allWorkerStatuses, workerStatus } from "./worker-status.ts"; import { RepeatDetector, callKey } from "./repeat-detector.ts"; import * as vps from "./vps-computer.ts"; import { RoutineManager, type RoutineRunOn, type RoutineRunTrigger } from "./routines.ts"; @@ -760,6 +765,19 @@ function localVmIdleFor(target: LocalVmTarget): LocalVmIdleTimer { return idle; } +/** One lease pool for every named worker. Records key on the SSH alias, so + * a macOS bot and a Windows bot never contend, while two turns aimed at one + * desktop still serialize. */ +const workerLease = new RemoteWorkerLease(); +const workerThreadAliases = new Map(); + +function releaseWorkerThread(threadId: string): void { + const alias = workerThreadAliases.get(threadId); + if (!alias) return; + workerLease.release(threadId); + workerThreadAliases.delete(threadId); +} + function releaseLocalVmThread(threadId: string): void { const target = localVmThreadTargets.get(threadId); if (!target) return; @@ -786,8 +804,10 @@ bus.subscribe((event: RuntimeEvent) => { localVmLeaseFor(localVmTarget).touch(event.threadId); localVmIdleFor(localVmTarget).touch(); } + if (workerThreadAliases.has(event.threadId)) workerLease.touch(event.threadId); if (event.type === "turn.completed") { releaseLocalVmThread(event.threadId); + releaseWorkerThread(event.threadId); } broadcast({ kind: "runtime", event }); const routineRun = routines?.handleRuntimeEvent(event) ?? null; @@ -1550,7 +1570,8 @@ async function startTurn( const mountsCloudComputer = mountsComputerMcp || instance.driverKind === "boxAgent"; const mountsLocalComputer = instance.adapter.capabilities.localComputerMcp === true; let previewCapture: (() => Promise<{ png: string; format: string }>) | null = null; - let computerKind: "box" | "vps" | "vm" | "local" | null = null; + let computerKind: "box" | "vps" | "vm" | "local" | "worker" | null = null; + let workerTarget: ResolvedWorker | null = null; let autoVpsProblem: string | null = null; // Explicit destinations are strict. In particular, Local VM must never @@ -1594,6 +1615,34 @@ async function startTurn( if (!cua) throw new Error("CUA Driver is not ready for this computer — check permissions and restart OpenMausBot"); integrations.localComputer = cua; computerKind = "local"; + } else if (wants === "worker") { + if (!mountsComputerMcp || instance.driverKind === "boxAgent") { + throw new Error("this model engine cannot use a remote worker — choose Claude or an ACP engine, or select another computer destination"); + } + const worker = workerById(cfg, bot.workerId); + if (!worker) { + throw new Error("this bot is not assigned to a configured worker (App Settings → Workers)"); + } + // Claim before the first await, exactly as Local VM does: otherwise + // two turns could both pass the readiness check and then both mount + // the same physical desktop, interleaving real keyboard and mouse + // input on one screen. + if (!workerLease.claim(worker.sshAlias, threadId, bot.id, (id) => store.bot(id)?.busy === true)) { + throw new Error(`the ${worker.displayName} desktop is already being used by another turn — wait for that turn to finish`); + } + workerThreadAliases.set(threadId, worker.sshAlias); + const status = await workerStatus(worker); + if (!status.ready || !status.channelPath) { + throw new Error(`${status.problem ?? "this worker is not ready"} (App Settings → Workers)`); + } + integrations.localComputer = remoteWorkerMcp( + worker, + status.channelPath, + controlIntegration(bot.id), + status.capabilityDigest ?? undefined, + ); + workerTarget = worker; + computerKind = "worker"; } // A VPS is a local-agent computer mount, never a remote agent runner. @@ -1767,6 +1816,8 @@ async function startTurn( ? " You have your own self-hosted remote Linux computer through the official Cua tools. Its filesystem is disposable: everything on it is wiped whenever its container is recreated, so keep long-lived work somewhere durable — push it to a remote, or hand the results back in chat — instead of leaving it only on that computer. Inspect the desktop state before acting, prefer accessibility targets over raw coordinates, and act carefully." : computerKind === "local" ? " You can act on the user's computer through the computer tools — take a screenshot or read the desktop state first, prefer accessibility actions over raw coordinates, and act carefully." + : computerKind === "worker" + ? ` You have your own ${workerTarget?.platform === "windows" ? "Windows" : "macOS"} computer — a separate machine the user owns, reached through the official Cua tools. It is not disposable and it is not the user's own desktop: treat its files as real, do not reconfigure the machine, and stay inside the task's approved surface. Inspect the desktop state before acting, prefer accessibility targets over raw coordinates, and act carefully.` : "") + (computerKind ? " At a sign-in, password, MFA, CAPTCHA, or other protected-input step, stop and ask the user to complete it on the visible computer. Never type their password or ask them to paste a password or one-time code into chat." @@ -1806,6 +1857,7 @@ async function startTurn( } } catch (e) { releaseLocalVmThread(threadId); + releaseWorkerThread(threadId); if (activeVpsThreads.get(bot.id) === threadId) activeVpsThreads.delete(bot.id); watchdog.settle(threadId); turnUsage.delete(threadId); @@ -4068,9 +4120,35 @@ const server = createServer(async (req, res) => { } if ( body.computer !== undefined && - !["cloud", "vm", "local", "off"].includes(String(body.computer)) + !["cloud", "vm", "local", "worker", "off"].includes(String(body.computer)) ) { - return json(res, 400, { error: "computer must be cloud, vm, local, or off" }); + return json(res, 400, { error: "computer must be cloud, vm, local, worker, or off" }); + } + if (body.workerId !== undefined) { + if (body.workerId === null || body.workerId === "") { + patch.workerId = undefined; + } else if (!workerById(cfg, body.workerId)) { + return json(res, 400, { error: "workerId must name a configured worker (App Settings → Workers)" }); + } else { + patch.workerId = String(body.workerId); + } + } + { + // Assignment and destination are checked together: either field can + // arrive alone, and "worker" without a resolvable id would fail only + // at the start of the next turn, long after the person left Settings. + const nextComputer = body.computer !== undefined ? body.computer : existingBot?.computer; + const nextWorkerId = body.workerId !== undefined ? patch.workerId : existingBot?.workerId; + if (nextComputer === "worker" && !workerById(cfg, nextWorkerId)) { + return json(res, 400, { error: "choose a configured worker for this bot first (App Settings → Workers)" }); + } + // Every worker task is explicitly approved through the three fences, + // so auto mode has nothing to approve on its own and must not look + // like it does. + const nextAuto = body.autoApprove !== undefined ? body.autoApprove : existingBot?.autoApprove === true; + if (nextComputer === "worker" && nextAuto === true) { + return json(res, 400, { error: "Auto mode is unavailable while this bot uses a remote worker" }); + } } if (body.cloudBackend !== undefined && !["box", "vps"].includes(String(body.cloudBackend))) { return json(res, 400, { error: "cloudBackend must be box or vps" }); @@ -4840,6 +4918,25 @@ const server = createServer(async (req, res) => { } } + // ── named remote workers (Windows PCs and macOS guests) ── + if (method === "GET" && path === "/api/workers") { + const workers = configuredWorkers(cfg); + // Probed concurrently: an unreachable worker must not delay the + // healthy one, and each adapter already fails closed on its own. + const statuses = await allWorkerStatuses(workers, { + lease: workerLease, + isBotBusy: (botId) => store.bot(botId)?.busy === true, + }); + // The SSH alias names a host in the operator's own config. Nothing + // downstream of the control plane needs it, so it never leaves here. + return json(res, 200, { + workers: workers.map((worker, index) => ({ + ...publicWorker(worker), + status: statuses[index], + })), + }); + } + // ── app config (API keys — never echoed back, booleans only) ── if (method === "GET" && path === "/api/config") { return json(res, 200, configStatus()); @@ -4849,6 +4946,25 @@ const server = createServer(async (req, res) => { const patch = parseConfigPatch(body); if (!Object.keys(patch).length) return json(res, 400, { error: "nothing to save" }); if (providerConfigBusy) return json(res, 409, { error: "provider settings are already being updated" }); + if (patch.workers !== undefined) { + const nextAliases = new Set(configuredWorkers({ ...cfg, workers: patch.workers }).map((w) => w.sshAlias)); + const heldAndGone = configuredWorkers(cfg).filter( + (worker) => + !nextAliases.has(worker.sshAlias) + && workerLease.current(worker.sshAlias, (botId) => store.bot(botId)?.busy === true) !== null, + ); + if (heldAndGone.length > 0) { + return json(res, 409, { + error: `wait for the turn using ${heldAndGone[0].displayName} to finish before changing that worker`, + }); + } + // A worker that survived the edit unchanged keeps its lease; one that + // was removed or repointed must not leave a record reporting `busy` + // for a machine the control plane no longer addresses. + for (const worker of configuredWorkers(cfg)) { + if (!nextAliases.has(worker.sshAlias)) workerLease.releaseAlias(worker.sshAlias); + } + } if (patch.vps !== undefined) { const currentAlias = vpsSshAlias(cfg); const nextAlias = vpsSshAlias({ ...cfg, vps: patch.vps }); diff --git a/server/remote-worker.ts b/server/remote-worker.ts index 45229ca1..8f153e35 100644 --- a/server/remote-worker.ts +++ b/server/remote-worker.ts @@ -435,7 +435,10 @@ export interface RemoteWorkerMcpDescriptor { command: string; args: string[]; env: Record; - platform: WorkerPlatform; + /** The integration contract speaks Node's platform names. The worker's own + * spelling travels in argv instead, where the bridge needs it to pick a + * liveness command. */ + platform: "darwin" | "win32"; generation: string; scope: "remote-worker-computer"; } @@ -453,7 +456,7 @@ export function remoteWorkerMcp( command: SPAWNED_PROXIES.workerMcp, args: [worker.sshAlias, channelPath, worker.platform], env: control ? { OMB_CONTROL_URL: control.url, OMB_CONTROL_TOKEN: control.token } : {}, - platform: worker.platform, + platform: worker.platform === "windows" ? "win32" : "darwin", generation: [ worker.expectedDriverVersion, worker.expectedBasePolicySha256 ?? "no-policy", diff --git a/server/store.ts b/server/store.ts index d028f050..710d9add 100644 --- a/server/store.ts +++ b/server/store.ts @@ -10,7 +10,8 @@ import { peerAllowKey, type PeerAction } from "./peer-approval-key.ts"; import { DATA_DIR } from "./config.ts"; import * as mdb from "./message-db.ts"; import { workspaceDir } from "./workspace.ts"; -import { newId, type CloudBackend, type ModelSelection, type ThreadId } from "./contracts.ts"; +import { newId, type ApprovalScope, type CloudBackend, type ModelSelection, type ThreadId } from "./contracts.ts"; +import { isValidWorkerId } from "./computer-workers.ts"; import { pickBotName } from "./names.ts"; import { redactSecretsInText } from "./redact.ts"; import { botAvatarProfile, type BotAvatarCrop } from "../shared/bot-avatar.ts"; @@ -50,7 +51,7 @@ export interface OptionCardData { /** the narrow grant "always allow" remembers, e.g. "Bash:git" */ allowKey?: string; /** Local actions never share remembered grants with cloud/tool approvals. */ - approvalScope?: "local-computer"; + approvalScope?: ApprovalScope; } export interface ConnectorCardData { @@ -299,9 +300,14 @@ export interface BotRecord { modelSelection: ModelSelection; /** provider-native continuation per instance (e.g. claude session id) */ resumeCursors: Record; - /** which computer the bot acts on: its cloud box, this Mac (local CUA), - * or none. Unset = auto (box when it exists, else local when available). */ - computer?: "cloud" | "vm" | "local" | "off"; + /** which computer the bot acts on: its cloud box, this Mac (local CUA), a + * named remote worker (an operator-owned Windows PC or macOS guest), or + * none. Unset = auto (box when it exists, else local when available). */ + computer?: "cloud" | "vm" | "local" | "worker" | "off"; + /** Which named worker backs `computer: "worker"`. Workers are configured + * app-wide in `config.workers`; the bot stores only the id, never the + * transport identity. */ + workerId?: string; /** Which cloud computer backs `computer: "cloud"`; absent means Box. */ cloudBackend?: CloudBackend; /** Auto mode may prepare/start this bot's managed VPS container. Off by @@ -523,6 +529,13 @@ export class Store { delete b.autoStartVps; botsMigrated = true; } + // A worker id that is no longer a legal id could never resolve, and + // leaving it set would render a bot as assigned to a machine that + // cannot be looked up. + if (b.workerId !== undefined && !isValidWorkerId(b.workerId)) { + delete b.workerId; + botsMigrated = true; + } const avatar = botAvatarProfile(b); if (b.avatarUrl !== undefined && avatar.avatarUrl !== b.avatarUrl) { delete b.avatarUrl; diff --git a/server/worker-status.test.ts b/server/worker-status.test.ts index f025aaaf..c3e1e178 100644 --- a/server/worker-status.test.ts +++ b/server/worker-status.test.ts @@ -245,7 +245,9 @@ describe("worker leases", () => { describe("worker MCP boundary", () => { it("pins driver, policy, capability and channel into the generation", () => { const descriptor = remoteWorkerMcp(macWorker, MAC_SOCKET, undefined, capability); - expect(descriptor.platform).toBe("macos"); + // The integration contract speaks Node platform names; the worker's own + // spelling travels in argv for the bridge's liveness command. + expect(descriptor.platform).toBe("darwin"); expect(descriptor.scope).toBe("remote-worker-computer"); expect(descriptor.args).toEqual(["macguest", MAC_SOCKET, "macos"]); expect(descriptor.generation).toBe(`0.20.0:${policy}:${capability}:${MAC_SOCKET}`); diff --git a/src/components/ComputerPanel.tsx b/src/components/ComputerPanel.tsx index 3f6928e8..5b2e472d 100644 --- a/src/components/ComputerPanel.tsx +++ b/src/components/ComputerPanel.tsx @@ -32,6 +32,7 @@ import { LocalScreenPreview } from "./LocalScreenPreview"; import { LinuxLocalControl } from "./LinuxLocalControl"; import { MacLocalControl } from "./MacLocalControl"; import { LocalComputerAutoWarning } from "./LocalComputerAutoWarning"; +import { WorkerPicker } from "./WorkerPicker"; import { autoSelectsLocalComputer, instanceSupportsLocalComputer, @@ -1060,6 +1061,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { ["cloud", "Cloud"], ["vm", "Local VM"], ["local", "This computer"], + ["worker", "Worker"], ["off", "Off"], ] as const ).map(([mode, label], i) => ( @@ -1067,9 +1069,12 @@ export function ComputerPanel({ bot }: { bot: Bot }) { const disabled = (mode === "cloud" && !cloudSupported) || (mode === "vm" && !vmSupported) || + (mode === "worker" && !vmSupported) || (mode === "local" && !localSelectable); const unavailableTitle = - mode === "vm" && !vmSupported + mode === "worker" && !vmSupported + ? "This model engine cannot use a remote worker" + : mode === "vm" && !vmSupported ? "This model engine cannot use the Local VM" : mode === "cloud" && !cloudSupported ? "This model engine cannot use cloud computer tools" @@ -1084,7 +1089,9 @@ export function ComputerPanel({ bot }: { bot: Bot }) { onClick={() => { if (mode === bot.computer) return; if (mode === "local" && bot.autoApprove) setLocalAutoWarning(true); - else dispatch({ type: "updateBot", botId: bot.id, patch: { computer: mode } }); + else if (mode === "worker" && bot.autoApprove) { + dispatch({ type: "updateBot", botId: bot.id, patch: { computer: mode, autoApprove: false } }); + } else dispatch({ type: "updateBot", botId: bot.id, patch: { computer: mode } }); }} className={cn( "flex-1 py-1.5 text-[13px]", @@ -1101,6 +1108,12 @@ export function ComputerPanel({ bot }: { bot: Bot }) { })() ))} + {bot.computer === "worker" && ( + dispatch({ type: "updateBot", botId: bot.id, patch: { workerId } })} + /> + )} {(!bot.computer || bot.computer === "cloud") && ( <> void; +}) { + const [workers, setWorkers] = useState(null); + const [error, setError] = useState(null); + const visible = usePageVisible(); + + useEffect(() => { + if (!visible) return; + let alive = true; + const load = async () => { + try { + const res = await fetch("/api/workers", { headers: { "content-type": "application/json" } }); + const body = await res.json().catch(() => ({})); + if (!alive) return; + if (!res.ok) throw new Error(body.error ?? `${res.status} ${res.statusText}`); + setWorkers(Array.isArray(body.workers) ? body.workers : []); + setError(null); + } catch (e) { + if (!alive) return; + setError(e instanceof Error ? e.message : String(e)); + } + }; + void load(); + // Each poll re-probes every worker over SSH, so keep it slow and stop it + // entirely while the window is hidden. + const timer = window.setInterval(() => void load(), REFRESH_MS); + return () => { + alive = false; + window.clearInterval(timer); + }; + }, [visible]); + + if (error) { + return
{error}
; + } + if (workers === null) { + return
Checking workers…
; + } + if (workers.length === 0) { + return ( +
+
No workers configured
+
+ Add a Windows PC or a macOS guest in Settings → Workers, then choose it here. +
+
+ ); + } + + return ( +
+ {workers.map((worker) => { + const selected = worker.id === selectedWorkerId; + return ( + + ); + })} +
+ ); +} diff --git a/src/lib/workers.ts b/src/lib/workers.ts new file mode 100644 index 00000000..c8d59f5f --- /dev/null +++ b/src/lib/workers.ts @@ -0,0 +1,50 @@ +// Client view of the named remote workers. +// +// The server never sends the SSH alias: it names a host in the operator's own +// SSH config and nothing in the renderer needs it. What arrives is the +// worker's identity plus the readiness the control plane just probed. + +export type WorkerPlatform = "windows" | "macos"; + +export interface WorkerStatus { + workerId: string; + platform: WorkerPlatform; + displayName: string; + configured: boolean; + state: + | "unconfigured" + | "offline" + | "wrong_driver_version" + | "no_interactive_session" + | "locked" + | "policy_mismatch" + | "ready" + | "busy" + | "paused"; + ready: boolean; + paused: boolean; + lease: { botId: string; threadId: string; expiresAt: number } | null; + errorCode: string | null; + problem: string | null; +} + +export interface WorkerSummary { + id: string; + platform: WorkerPlatform; + displayName: string; + configured: boolean; + paused: boolean; + status: WorkerStatus; +} + +export function workerPlatformLabel(platform: WorkerPlatform): string { + return platform === "windows" ? "Windows" : "macOS"; +} + +/** What to show under a worker's name in the picker. `problem` already names + * the first thing that is actually wrong, so prefer it over a state word. */ +export function workerStatusLine(worker: WorkerSummary): string { + if (worker.status.ready) return "Ready"; + if (worker.status.lease) return "In use by another turn"; + return worker.status.problem ?? "Not ready"; +} diff --git a/src/state/bot-patch-queue.ts b/src/state/bot-patch-queue.ts index 220a165f..10b12b03 100644 --- a/src/state/bot-patch-queue.ts +++ b/src/state/bot-patch-queue.ts @@ -9,6 +9,7 @@ export type BotUpdatePatch = Partial< | "description" | "notifications" | "computer" + | "workerId" | "cloudBackend" | "autoStartVps" | "color" diff --git a/src/state/store.tsx b/src/state/store.tsx index d43b33bb..96729dab 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -40,7 +40,7 @@ export interface OptionCardData { held?: string; /** the narrow grant "always allow" remembers, e.g. "Bash:git" */ allowKey?: string; - approvalScope?: "local-computer"; + approvalScope?: "local-computer" | "remote-worker-computer"; } export interface ConnectorCardData { @@ -191,7 +191,9 @@ export interface Bot { activity?: "working" | "waiting-on-you" | "idle" | "no-signal" | "dead"; modelSelection: ModelSelection; /** Where this bot's computer runs; unset = auto (cloud box if one exists, else local). */ - computer?: "cloud" | "vm" | "local" | "off"; + computer?: "cloud" | "vm" | "local" | "worker" | "off"; + /** Which named remote worker backs `computer: "worker"`. */ + workerId?: string; /** Which cloud computer backs `computer: "cloud"`; absent means Box. */ cloudBackend?: CloudBackend; /** Allow Auto to prepare/start the managed VPS container. Off by default. */ From 7997b9c146c03c3939f22fd186a5f221c0c8f499 Mon Sep 17 00:00:00 2001 From: gus <42593099+lightcloud00@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:31:16 -0400 Subject: [PATCH 03/10] docs(workers): operator runbooks for macOS and Windows workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both platforms get the same shape: a dedicated non-administrator account, an always-present interactive session, the pinned CUA Driver, a base policy the control plane pins by digest, and a parked capability manifest. The parked manifest grants no tools at all. It is what a worker holds between tasks: readiness requires the daemon to report a loaded capability manifest, so a machine without one never becomes ready, and with the parked one the worker is reachable and provably bounded while able to do nothing until a task capability is approved. The macOS runbook carries the step that cannot be scripted — Accessibility and Screen Recording are granted per binary, SIP blocks writing the permission database, and replacing the driver binary silently revokes them. Refs #508 Co-Authored-By: Claude Opus 5 --- README.md | 10 +- docs/byo-macos.md | 16 +++ docs/byo-windows.md | 199 ++++++++++++++++++++++++++ docs/macos-parked-capabilities.yaml | 17 +++ docs/windows-parked-capabilities.yaml | 13 ++ 5 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 docs/byo-windows.md create mode 100644 docs/macos-parked-capabilities.yaml create mode 100644 docs/windows-parked-capabilities.yaml diff --git a/README.md b/README.md index df0c9cb6..1b159085 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,12 @@ already have: custom CLI binary (a versioned build or wrapper) in **Settings → Engines**. - **Local first.** One small harness server on `127.0.0.1` owns every agent process. Transcripts, keys, and events live in `~/.openmausbot`, not a cloud. -- **Agents with hands.** Each bot can use a cloud Linux desktop, an isolated Local VM, or—where the platform - safety boundary is currently certified—your own computer, plus 500+ apps through Composio. Host control is - available on macOS and Ubuntu Xorg after explicit opt-in. Ubuntu Wayland host control remains disabled while - issue #345 is resolved. +- **Agents with hands.** Each bot can use a cloud Linux desktop, an isolated Local VM, a machine you own + through a named worker—a [Windows PC](docs/byo-windows.md) or a [macOS guest](docs/byo-macos.md)—or, where + the platform safety boundary is currently certified, your own computer, plus 500+ apps through Composio. + Workers are named and leased independently, so one bot can hold a macOS desktop while another holds a + Windows one. Host control is available on macOS and Ubuntu Xorg after explicit opt-in. Ubuntu Wayland host + control remains disabled while issue #345 is resolved. ## Features diff --git a/docs/byo-macos.md b/docs/byo-macos.md index e086f856..efc06c8a 100644 --- a/docs/byo-macos.md +++ b/docs/byo-macos.md @@ -123,6 +123,22 @@ once at daemon start, and an unset policy variable disables enforcement entirely, so readiness requires the daemon to *report* the same digest it finds on disk. +## Install the parked capability manifest + +The base policy is the stable ceiling; a **capability manifest** is the +short-lived, per-task boundary that intersects it. Between tasks the guest +should hold the parked manifest, which grants no tools at all: + +```bash +cp macos-parked-capabilities.yaml \ + ~/Library/Application\ Support/OpenMausBot/active-capabilities.yaml +``` + +Readiness requires the daemon to report a loaded capability manifest, so a +guest without one never becomes ready. With the parked manifest in place the +worker is reachable and provably bounded, and can do nothing until a task +capability is approved — the correct resting state. + ## Add the worker In OpenMausBot, open **Settings → Workers**, add a worker with: diff --git a/docs/byo-windows.md b/docs/byo-windows.md new file mode 100644 index 00000000..433ce3bf --- /dev/null +++ b/docs/byo-windows.md @@ -0,0 +1,199 @@ +# Bring your own Windows worker + +OpenMausBot keeps its control plane on the Mac and connects a bot to an +already-running Windows physical machine or VM. It does not provision +Windows, manage a hypervisor, store SSH credentials, expose a TCP control +listener, mount the Mac workspace, or fall back to Linux when Windows fails. + +Docker remains the recommended Local VM and protocol-test path for Linux. A +Linux container cannot supply the interactive Windows desktop, Session 1+ +window station, Windows UI Automation, registry, and named-pipe behavior this +backend must verify. Use a real Windows installation in Parallels, VMware, +UTM, another local hypervisor, or a physical PC. + +## Security model + +The operator owns the Windows installation and the macOS OpenSSH alias. The +Windows account must be a dedicated non-administrator user. OpenMausBot stores +only the alias and expected public configuration digests. + +Readiness checks the live SSH token and refuses an account in the local +Administrators group. Installing the driver under an administrator account +does not make that account an eligible worker. + +Every task has three independent fences: + +1. A stable, deny-by-default CUA YAML policy limits the total tool ceiling. +2. A short-lived native CUA version-3 capability manifest limits one task to + either typed browser tools and exact origins, or generic input against VS + Code and File Explorer under the staged task root. CUA does not permit + browser origins and generic desktop input in one runtime; OpenMausBot keeps + those task surfaces separate. +3. A version-1 OpenMausBot task manifest binds the target, expiry, idle + timeout, staged file hashes, exact non-GUI commands, argv, working + directories, origins, results, and base-policy digest. The agent-facing + `windows_run` tool accepts only a task ID and command ID. + +Only one bot can lease a given Windows target. Work on another worker — a +[macOS guest](byo-macos.md), for instance — and on Linux Local VMs continues +in parallel: workers are named independently and lease independently. + +Returned files land in a private Mac review directory and do not overwrite the +canonical workspace. + +## Windows prerequisites + +Use Windows 11 or a currently supported Windows Server desktop with: + +- Windows OpenSSH Server configured by the operator; +- Node.js 24 or newer; +- official CUA Driver 0.20.0; +- Chrome and VS Code at the paths entered in OpenMausBot; +- a dedicated Chrome profile named **OpenMaus Windows Worker**, with Sync, + personal accounts, and unrelated extensions disabled. + +Install the driver and verify its exact version from an interactive PowerShell +session. Follow the official CUA install instructions for the pinned release; +do not use an unreviewed wrapper or ambient alternate binary. + +```powershell +cua-driver --version +node --version +``` + +## Install the companion + +Build the companion from the exact OpenMausBot source commit on the Mac: + +```bash +pnpm build:worker-companion +``` + +Copy only `worker-companion/package.json` and `worker-companion/dist/` to a +private directory owned by the Windows worker user, then expose the package's +`openmausbot-worker-companion` bin on that user's `PATH` (for example with +`npm link` from that copied directory). Verify protocol 1: + +```powershell +openmausbot-worker-companion --version +``` + +The companion has no listener. Its stdio protocol accepts only reset, +validate, activate, pause, resume, and run. Activation derives the CUA capability YAML from +the already-approved manifest, restarts the fixed official CUA autostart task, +rechecks that the executable is Driver 0.20.0, and requires `cua-driver status` +to report both bounded mode and the exact capability digest. It never accepts a remote executable, argv, environment, +working directory, policy body, capability body, or arbitrary command. +Validation freezes an immutable source baseline outside the CUA-granted task +root. Later VS Code edits are allowed within the bounded task root; +`windows_run` revalidates the untouched baseline and current path/size limits, +then generates `changes.patch` against that original snapshot. + +## Install the native policy stack + +Create `%LOCALAPPDATA%\OpenMausBot` for the Windows worker user. Copy +`docs/windows-base-policy.yaml` to +`%LOCALAPPDATA%\OpenMausBot\windows-policy.yaml` and copy +`docs/windows-parked-capabilities.yaml` to +`%LOCALAPPDATA%\OpenMausBot\active-capabilities.yaml`. + +Set the trusted launch environment for that user from an interactive +PowerShell session. These variables are read when the daemon starts; they are +not agent-controlled tool arguments. + +```powershell +$root = Join-Path $env:LOCALAPPDATA 'OpenMausBot' +[Environment]::SetEnvironmentVariable('CUA_DRIVER_POLICY_FILE', (Join-Path $root 'windows-policy.yaml'), 'User') +[Environment]::SetEnvironmentVariable('CUA_DRIVER_PERMISSION_MODE', 'bounded', 'User') +[Environment]::SetEnvironmentVariable('CUA_DRIVER_CAPABILITY_MANIFEST_FILE', (Join-Path $root 'active-capabilities.yaml'), 'User') +[Environment]::SetEnvironmentVariable('CUA_DRIVER_CAPABILITY_MANIFEST_APPROVED', '1', 'User') +``` + +Log out and back in so the Scheduled Task receives the trusted environment. +Then register and start the official interactive-user task: + +```powershell +cua-driver autostart enable +cua-driver autostart kick +query session +cua-driver status --socket \\.\pipe\cua-driver +``` + +The session must be `Active` or `Disc`, never Session 0. The status output must +show bounded mode and hashes for the loaded policy and capability file. An +unset policy variable means policy enforcement is disabled; OpenMausBot checks +the loaded digest, not merely the file on disk. Readiness also requires the +daemon's reported session ID to match an Explorer desktop owned by the SSH +user; another user's interactive session cannot satisfy the gate. + +Compute the stable base-policy digest and enter it in App Settings → Windows +Worker: + +```powershell +(Get-FileHash -Algorithm SHA256 (Join-Path $env:LOCALAPPDATA 'OpenMausBot\windows-policy.yaml')).Hash.ToLowerInvariant() +``` + +## Configure the Mac + +Create a normal OpenSSH config alias outside OpenMausBot. Authentication, +host-key policy, keys, passwords, and agent state remain owned by macOS and +must not be pasted into OpenMausBot. Enter only the validated alias, policy +digest, application paths, and profile name in App Settings → Windows Worker. + +The backend invokes only these fixed remote surfaces: + +```text +ssh ... ALIAS cua-driver mcp --socket \\.\pipe\cua-driver +ssh ... ALIAS openmausbot-worker-companion stdio +sftp ... ALIAS +``` + +It does not accept a hostname, SSH options, shell string, or remote command +from a bot or task manifest. The local SSH/SFTP child environment is an +allow-list containing only PATH, the operator home/user metadata, locale, +temporary-directory metadata, and `SSH_AUTH_SOCK`; ambient API keys and the +OpenMausBot control token are excluded. + +## Transport spike and acceptance + +Before assigning real work, use the Settings connection check and a bounded +test task to prove: + +- SSH authentication through the named alias; +- Driver 0.20.0 and companion protocol 1; +- an unlocked Session 1+ desktop; +- named-pipe access from OpenSSH; +- loaded base-policy and native capability digests; +- one allowed screenshot/state read, click, and type on the correct surface; +- rejection of a disallowed tool, application, origin, and staged-path escape; +- result collection to the Mac review directory; +- no non-loopback listener introduced by OpenMausBot or the companion. + +If named-pipe access returns `Access is denied`, stop. Do not loosen the CUA +pipe ACL and do not switch to an unrestricted daemon. The optional same-user, +same-session relay described by the design is intentionally not enabled until +the failure is reproduced and the relay passes its isolation suite. Without +that proof, Windows remains unavailable and never falls back to Linux. + +## Browser boundary + +Browser tasks expose typed browser tools only. Every navigation and input is +checked against the exact scheme, host, and port in the task manifest. Generic +desktop screenshots, window trees, clicks, and keystrokes are absent from that +runtime because they could read or operate a different tab without crossing +the origin check. Credentials, MFA, CAPTCHA, and consequential accounts remain +operator actions. The native capability binds the configured Chrome executable +and the `existing_profile` attachment class. CUA intentionally does not return +the profile's identity; the operator must verify that the selected native +window is the dedicated **OpenMaus Windows Worker** profile during the transport +spike and before each consequential browser task. + +Desktop tasks expose VS Code and File Explorer only. Chrome is not an allowed +application on that surface. A workflow that needs both must use two visible +tasks/handoffs; it cannot combine the permissions in one manifest. + +`windows_run` confines its working directory and prevents executable, argv, or +environment substitution after approval. It is not an operating-system sandbox +around the approved executable: that exact program still has the ordinary +rights of the dedicated Windows user. Approve only purpose-built build/test +binaries whose behavior is appropriate for that account. diff --git a/docs/macos-parked-capabilities.yaml b/docs/macos-parked-capabilities.yaml new file mode 100644 index 00000000..0fd03ac5 --- /dev/null +++ b/docs/macos-parked-capabilities.yaml @@ -0,0 +1,17 @@ +# Safe bootstrap state for the interactive macOS CUA daemon. +# The worker companion replaces this file atomically with a short-lived, +# approved task capability before OpenMausBot mounts the CUA MCP bridge. +# +# It grants no tools at all. A worker running this manifest is reachable and +# provably bounded, and can do nothing until a task capability is approved — +# which is the correct resting state between tasks. +version: 3 +expires_after: 8760h +idle_timeout: 20m + +allow: + tools: [] + +resources: + desktop: + display: false diff --git a/docs/windows-parked-capabilities.yaml b/docs/windows-parked-capabilities.yaml new file mode 100644 index 00000000..363cc385 --- /dev/null +++ b/docs/windows-parked-capabilities.yaml @@ -0,0 +1,13 @@ +# Safe bootstrap state for the interactive Windows CUA Scheduled Task. +# The Windows companion replaces this file atomically with a short-lived, +# approved task capability before OpenMausBot mounts the CUA MCP bridge. +version: 3 +expires_after: 8760h +idle_timeout: 20m + +allow: + tools: [] + +resources: + desktop: + display: false From f2b94d9c2e5afd1b6e3e573f3958be29b4926881 Mon Sep 17 00:00:00 2001 From: gus <42593099+lightcloud00@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:25:37 -0400 Subject: [PATCH 04/10] refactor(workers): parse worker input at the boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo's anti-slop rules reject bare `unknown` parameters, runtime `typeof` narrowing, and `unknown` returns, and every worker file tripped them. The sanctioned pattern is server/schema.ts: a JSON-typed parse, then a zod schema. - `parseHealthReport` / `applyHealthReport` take `JsonValue`, and the two adapters feed them through `parseJson()` instead of a bare `JSON.parse`. - `findWorker` and `workerById` take `JsonValue` and run the worker-id regex as a zod schema rather than narrowing by hand. - `isValidWorkerId`, `isValidWorkerSshAlias` and `isSafeChannelPath` take `string`. Every caller already had one — the `unknown` was never doing work. - The bots PATCH route tracks the validated id in its own typed local, because `patch` is a `Record` and reading the id back out of it lost the type the destination check needs. No behaviour change: the same inputs are accepted and the same ones rejected. Every file this branch touches now lints clean, against a repo-wide baseline of 1592 errors on main. Refs #508 Co-Authored-By: Claude Opus 5 --- server/computer-workers.ts | 23 ++++++++++++++--------- server/config.ts | 2 +- server/index.ts | 12 +++++++++--- server/mac-worker.ts | 5 +++-- server/remote-worker.ts | 9 +++++---- server/windows-worker.ts | 5 +++-- 6 files changed, 35 insertions(+), 21 deletions(-) diff --git a/server/computer-workers.ts b/server/computer-workers.ts index f62c4c4c..d33c51ae 100644 --- a/server/computer-workers.ts +++ b/server/computer-workers.ts @@ -12,6 +12,7 @@ // per-alias lease in ./remote-worker.ts keeps two workers independent, so a // bot on Windows and a bot on macOS can hold their desktops at the same time. import { z } from "zod"; +import type { JsonValue } from "./schema.ts"; /** Pinned across every worker platform; the driver's wire protocol and its * policy/capability digests are only comparable within one exact version. */ @@ -45,18 +46,21 @@ export const WORKER_DEFAULTS = { }, } satisfies Record; -export function isValidWorkerId(value: unknown): value is string { - return typeof value === "string" && WORKER_ID.test(value); +export function isValidWorkerId(value: string): boolean { + return WORKER_ID.test(value); } -export function isValidWorkerSshAlias(value: unknown): value is string { - return typeof value === "string" && SSH_ALIAS.test(value); +export function isValidWorkerSshAlias(value: string): boolean { + return SSH_ALIAS.test(value); } -export function isWorkerPlatform(value: unknown): value is WorkerPlatform { +export function isWorkerPlatform(value: JsonValue): value is WorkerPlatform { return value === "windows" || value === "macos"; } +/** The id as it arrives from config, a bot record, or an HTTP body. */ +const workerIdSchema = z.string().regex(WORKER_ID); + /** Executable paths reach a shell-free spawn and the CUA capability YAML, but * they are still operator input echoed into a manifest the daemon enforces. * Reject control characters and the shell metacharacters that would make a @@ -169,10 +173,11 @@ export function listWorkers(workers: WorkerConfigMap | undefined): ResolvedWorke .map((id) => resolveWorker(id, workers[id])); } -export function findWorker(workers: WorkerConfigMap | undefined, id: unknown): ResolvedWorker | null { - if (!workers || !isValidWorkerId(id)) return null; - const raw = workers[id]; - return raw ? resolveWorker(id, raw) : null; +export function findWorker(workers: WorkerConfigMap | undefined, id: JsonValue): ResolvedWorker | null { + const parsed = workerIdSchema.safeParse(id); + if (!workers || !parsed.success) return null; + const raw = workers[parsed.data]; + return raw ? resolveWorker(parsed.data, raw) : null; } /** Redacts the transport identity before a worker is described to a bot, a diff --git a/server/config.ts b/server/config.ts index c4f37bab..507745dc 100644 --- a/server/config.ts +++ b/server/config.ts @@ -156,7 +156,7 @@ export function configuredWorkers(cfg: AppConfig): ResolvedWorker[] { return listWorkers(cfg.workers); } -export function workerById(cfg: AppConfig, id: unknown): ResolvedWorker | null { +export function workerById(cfg: AppConfig, id: JsonValue): ResolvedWorker | null { return findWorker(cfg.workers, id); } diff --git a/server/index.ts b/server/index.ts index 52569734..e551eb0c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1619,7 +1619,7 @@ async function startTurn( if (!mountsComputerMcp || instance.driverKind === "boxAgent") { throw new Error("this model engine cannot use a remote worker — choose Claude or an ACP engine, or select another computer destination"); } - const worker = workerById(cfg, bot.workerId); + const worker = workerById(cfg, bot.workerId ?? null); if (!worker) { throw new Error("this bot is not assigned to a configured worker (App Settings → Workers)"); } @@ -4124,13 +4124,19 @@ const server = createServer(async (req, res) => { ) { return json(res, 400, { error: "computer must be cloud, vm, local, worker, or off" }); } + // Tracked in its own typed local: `patch` is a Record, + // so reading the id back out of it would lose the type the check below + // needs. + let assignedWorkerId: string | null | undefined; if (body.workerId !== undefined) { if (body.workerId === null || body.workerId === "") { patch.workerId = undefined; + assignedWorkerId = null; } else if (!workerById(cfg, body.workerId)) { return json(res, 400, { error: "workerId must name a configured worker (App Settings → Workers)" }); } else { - patch.workerId = String(body.workerId); + assignedWorkerId = String(body.workerId); + patch.workerId = assignedWorkerId; } } { @@ -4138,7 +4144,7 @@ const server = createServer(async (req, res) => { // arrive alone, and "worker" without a resolvable id would fail only // at the start of the next turn, long after the person left Settings. const nextComputer = body.computer !== undefined ? body.computer : existingBot?.computer; - const nextWorkerId = body.workerId !== undefined ? patch.workerId : existingBot?.workerId; + const nextWorkerId = assignedWorkerId !== undefined ? assignedWorkerId : (existingBot?.workerId ?? null); if (nextComputer === "worker" && !workerById(cfg, nextWorkerId)) { return json(res, 400, { error: "choose a configured worker for this bot first (App Settings → Workers)" }); } diff --git a/server/mac-worker.ts b/server/mac-worker.ts index e62cf529..050c3661 100644 --- a/server/mac-worker.ts +++ b/server/mac-worker.ts @@ -28,6 +28,7 @@ import { type RemoteWorkerStatus, } from "./remote-worker.ts"; import type { ResolvedWorker } from "./computer-workers.ts"; +import { parseJson, type JsonValue } from "./schema.ts"; /** Fixed by convention under the worker account's own home so the socket and * its directory can both be owner-private. The probe reports the resolved @@ -160,10 +161,10 @@ export async function macWorkerStatus( if (worker.paused) return failWorker(status, "paused", "worker_paused", "This worker is paused"); const runner = options.runner ?? defaultRemoteWorkerRunner; - let report: unknown; + let report: JsonValue; try { const result = await runner(macWorkerHealthArgs(worker.sshAlias), WORKER_SSH_TIMEOUT_MS, MAC_HEALTH_SCRIPT); - report = JSON.parse(result.stdout.trim()); + report = parseJson(result.stdout.trim()); } catch (error) { return failWorker(status, "offline", "worker_offline", `Worker SSH is offline: ${error instanceof Error ? error.message.slice(0, 200) : "unknown error"}`); diff --git a/server/remote-worker.ts b/server/remote-worker.ts index 8f153e35..58c8296c 100644 --- a/server/remote-worker.ts +++ b/server/remote-worker.ts @@ -14,6 +14,7 @@ import { type WorkerPlatform, } from "./computer-workers.ts"; import { augmentedPath } from "./env-path.ts"; +import type { JsonValue } from "./schema.ts"; import { SPAWNED_PROXIES } from "./proxy-paths.ts"; export const WORKER_COMPANION_PROTOCOL_VERSION = 1; @@ -163,7 +164,7 @@ export type RemoteWorkerHealthReport = z.output; /** Never throws: every field catches, so an unparseable payload yields a * report in which nothing is proven. */ -export function parseHealthReport(raw: unknown): RemoteWorkerHealthReport { +export function parseHealthReport(raw: JsonValue): RemoteWorkerHealthReport { const parsed = healthReportSchema.safeParse(raw); return parsed.success ? parsed.data : {}; } @@ -268,8 +269,8 @@ export function defaultRemoteWorkerRunner( }); } -export function isSafeChannelPath(value: unknown): value is string { - return typeof value === "string" && value.length > 0 && value.length <= 512 && !UNSAFE_PATH.test(value); +export function isSafeChannelPath(value: string): boolean { + return value.length > 0 && value.length <= 512 && !UNSAFE_PATH.test(value); } export function baseWorkerStatus(worker: ResolvedWorker): RemoteWorkerStatus { @@ -321,7 +322,7 @@ export function failWorker( /** Copies a probe result onto the status without deciding readiness. Split * from the ladder below so a caller can render a diagnostic panel for a * worker that will never become ready. */ -export function applyHealthReport(status: RemoteWorkerStatus, raw: unknown): RemoteWorkerStatus { +export function applyHealthReport(status: RemoteWorkerStatus, raw: JsonValue): RemoteWorkerStatus { const report = parseHealthReport(raw); status.driverVersion = report.driverVersion ?? null; status.companionVersion = report.companionVersion ?? null; diff --git a/server/windows-worker.ts b/server/windows-worker.ts index ae6e858e..d51c43b7 100644 --- a/server/windows-worker.ts +++ b/server/windows-worker.ts @@ -18,6 +18,7 @@ import { type RemoteWorkerStatus, } from "./remote-worker.ts"; import type { ResolvedWorker } from "./computer-workers.ts"; +import { parseJson, type JsonValue } from "./schema.ts"; export const WINDOWS_CUA_PIPE = "\\\\.\\pipe\\cua-driver"; export const WINDOWS_POLICY_PATH = "%LOCALAPPDATA%\\OpenMausBot\\windows-policy.yaml"; @@ -119,7 +120,7 @@ export async function windowsWorkerStatus( if (worker.paused) return failWorker(status, "paused", "worker_paused", "This worker is paused"); const runner = options.runner ?? defaultRemoteWorkerRunner; - let report: unknown; + let report: JsonValue; try { // Keep the fixed health program off argv. Windows OpenSSH invokes the // user's command through cmd.exe, whose command-line ceiling is lower @@ -130,7 +131,7 @@ export async function windowsWorkerStatus( WORKER_SSH_TIMEOUT_MS, WINDOWS_HEALTH_SCRIPT, ); - report = JSON.parse(result.stdout.trim()); + report = parseJson(result.stdout.trim()); } catch (error) { return failWorker(status, "offline", "worker_offline", `Worker SSH is offline: ${error instanceof Error ? error.message.slice(0, 200) : "unknown error"}`); From 53690c334f5adaddab7ed1e42651a90cde619f08 Mon Sep 17 00:00:00 2001 From: gus <42593099+lightcloud00@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:57:32 -0400 Subject: [PATCH 05/10] feat(workers): add the cross-platform worker companion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both remote-worker adapters shell out to `openmausbot-worker-companion`, and the readiness ladder refuses any worker whose companion does not answer protocol 1 (server/remote-worker.ts). Nothing shipped that binary, so no worker could reach ready and `docs/byo-*.md` pointed at a `pnpm build:worker-companion` script that did not exist. This adds it, derived from the Windows-only companion and generalized: - `--version` answers protocol 1, parsed by both adapters' health probes. - `--permissions` is new. It reports the *driver binary's own* Accessibility and Screen Recording grants through the pinned CUA SDK's non-prompting `currentMacOsPermissionStatus()`. macOS TCC has no Windows analogue, so Windows reports null and its ladder never consults it. The read is live on every poll by design: grants are per-binary, SIP blocks writing the TCC database, and replacing the driver silently revokes them, so a grant made once during setup is not evidence of a grant now. It never calls `requestMacOsPermissions()` — an SSH-driven probe has nobody at the screen to answer a dialog, and a probe blocked on one reads as a hung worker. - `stdio` implements pause and resume, the two operations that bound a worker at rest. Resume writes the deny-all parked capability and requires the daemon to report back both that digest and the pinned base policy before it answers, so a driver that quietly loaded a different policy never passes. reset/validate/activate/run land with the server-side task layer. Parsing happens once, at the wire, following server/schema.ts: a JSON-typed parse then a zod schema, so nothing downstream inspects shapes and the wire can name an operation and a digest and nothing else. Unrecognized fields are dropped rather than forwarded, and the environment handed to the driver is a fixed allow-list. The parked manifests are embedded because the companion ships standalone, and a test asserts they stay byte-identical to docs/*-parked-capabilities.yaml — an operator installing the documented file and a companion writing a different one would disagree on the digest and the worker would never come up bounded. Further tests pin the exact stdout both adapters grep for; drift there would silently read as "not granted" forever. worker-companion/** is added to the vitest include globs. Without it the new tests would collect as zero and pass, which is the failure scripts/test-floor.mjs exists to catch. Refs #508 Co-Authored-By: Claude Opus 5 --- docs/byo-macos.md | 10 +- docs/byo-windows.md | 8 +- package.json | 1 + tsconfig.worker-companion.build.json | 12 ++ vite.config.ts | 1 + worker-companion/README.md | 44 ++++++ worker-companion/package.json | 16 ++ worker-companion/src/capability.ts | 71 +++++++++ worker-companion/src/driver.ts | 106 +++++++++++++ worker-companion/src/index.ts | 70 +++++++++ worker-companion/src/permissions.ts | 37 +++++ worker-companion/src/platform.ts | 55 +++++++ worker-companion/src/wire.ts | 81 ++++++++++ worker-companion/test/companion.test.ts | 189 ++++++++++++++++++++++++ 14 files changed, 694 insertions(+), 7 deletions(-) create mode 100644 tsconfig.worker-companion.build.json create mode 100644 worker-companion/README.md create mode 100644 worker-companion/package.json create mode 100644 worker-companion/src/capability.ts create mode 100644 worker-companion/src/driver.ts create mode 100644 worker-companion/src/index.ts create mode 100644 worker-companion/src/permissions.ts create mode 100644 worker-companion/src/platform.ts create mode 100644 worker-companion/src/wire.ts create mode 100644 worker-companion/test/companion.test.ts diff --git a/docs/byo-macos.md b/docs/byo-macos.md index efc06c8a..aeff26fa 100644 --- a/docs/byo-macos.md +++ b/docs/byo-macos.md @@ -75,10 +75,12 @@ openmausbot-worker-companion --version Install the pinned CUA Driver release with the official instructions — do not use an unreviewed wrapper or an ambient alternate binary. Build the companion -from the exact OpenMausBot source commit on the control-plane Mac, copy only -its `package.json` and `dist/` into a private directory owned by the worker -account, and put its `openmausbot-worker-companion` bin on that account's -`PATH`. +from the exact OpenMausBot source commit on the control-plane Mac with +`pnpm build:worker-companion`, copy only its `package.json` and `dist/` into a +private directory owned by the worker account, install its dependencies there +(`npm install --omit=dev`), and put its `openmausbot-worker-companion` bin on +that account's `PATH`. The dependency is the pinned CUA SDK: the companion +reads the driver's own Accessibility and Screen Recording grants through it. The driver listens on a unix socket at `~/.openmausbot/run/cua.sock`. Both the socket and its directory must be owned by the worker account and private to diff --git a/docs/byo-windows.md b/docs/byo-windows.md index 433ce3bf..d62033a8 100644 --- a/docs/byo-windows.md +++ b/docs/byo-windows.md @@ -70,7 +70,8 @@ pnpm build:worker-companion ``` Copy only `worker-companion/package.json` and `worker-companion/dist/` to a -private directory owned by the Windows worker user, then expose the package's +private directory owned by the Windows worker user, install its dependencies +there (`npm install --omit=dev`), then expose the package's `openmausbot-worker-companion` bin on that user's `PATH` (for example with `npm link` from that copied directory). Verify protocol 1: @@ -78,8 +79,9 @@ private directory owned by the Windows worker user, then expose the package's openmausbot-worker-companion --version ``` -The companion has no listener. Its stdio protocol accepts only reset, -validate, activate, pause, resume, and run. Activation derives the CUA capability YAML from +The companion has no listener. Its stdio protocol accepts only pause and +resume in this release; reset, validate, activate and run arrive with the +server-side task layer. Activation derives the CUA capability YAML from the already-approved manifest, restarts the fixed official CUA autostart task, rechecks that the executable is Driver 0.20.0, and requires `cua-driver status` to report both bounded mode and the exact capability digest. It never accepts a remote executable, argv, environment, diff --git a/package.json b/package.json index 7baefbd9..ea41b489 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "preview": "vite preview", "build:server": "tsc -p tsconfig.server.build.json && node scripts/bundle-server.mjs", "build:companion": "tsc -p tsconfig.companion.build.json", + "build:worker-companion": "tsc -p tsconfig.worker-companion.build.json", "build:speech": "node electron/build-speech-helper.mjs", "build:recorder": "node electron/build-recorder-helper.mjs", "build:cua": "node scripts/prepare-cua.mjs", diff --git a/tsconfig.worker-companion.build.json b/tsconfig.worker-companion.build.json new file mode 100644 index 00000000..73b4a94f --- /dev/null +++ b/tsconfig.worker-companion.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.server.json", + "compilerOptions": { + "noEmit": false, + "outDir": "worker-companion/dist", + "rootDir": "worker-companion/src", + "rewriteRelativeImportExtensions": true, + "declaration": false, + "sourceMap": false + }, + "include": ["worker-companion/src"] +} diff --git a/vite.config.ts b/vite.config.ts index 2f5cb7c0..18bb2b3f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ "electron/**/*.test.mjs", "src/**/*.test.ts", "companion/**/*.test.ts", + "worker-companion/**/*.test.ts", "scripts/**/*.test.mjs", ], setupFiles: ["server/testing/setup.ts"], diff --git a/worker-companion/README.md b/worker-companion/README.md new file mode 100644 index 00000000..ce7b96e1 --- /dev/null +++ b/worker-companion/README.md @@ -0,0 +1,44 @@ +# OpenMausBot worker companion + +The versioned helper that runs as the non-administrative interactive worker +user on a macOS or Windows desktop. It has no listener. OpenMausBot reaches it +only through the operator-owned OpenSSH alias and the fixed +`openmausbot-worker-companion stdio` command. + +Build on the Mac with `pnpm build:worker-companion`, copy +`worker-companion/package.json` and `worker-companion/dist/` to the worker, +install dependencies there, and expose the package's +`openmausbot-worker-companion` bin on that user's `PATH`. Node 24 or newer is +required. + +## Protocol 1 + +Two out-of-band flags, read by the control plane's health probe: + +- `--version` prints `openmausbot-worker-companion 1`. The probe parses the + trailing integer as the protocol version and refuses any worker that does not + answer exactly `WORKER_COMPANION_PROTOCOL_VERSION`. +- `--permissions` prints `{"accessibility":bool,"screenRecording":bool}` on + macOS, read live from the pinned CUA SDK's non-prompting + `currentMacOsPermissionStatus()`. This has no Windows analogue, so on Windows + it prints `{"accessibility":null,"screenRecording":null}` and the Windows + ladder never consults it. + + The read is live on every poll by design: macOS TCC grants are per-binary, + System Integrity Protection blocks writing the TCC database, and replacing the + driver binary silently revokes them. A grant made once during setup is not + evidence of a grant now. + +`stdio` accepts one JSON request per line. This version implements the two +operations that bound a worker at rest: + +- `pause` — revoke every capability and stop the driver. +- `resume` — write the built-in deny-all parked capability and bring the driver + back up bounded. The parked manifest grants no tools at all, so a resumed + worker is reachable and provably bounded and can do nothing until a task + capability is approved. + +It never accepts executable names, arguments, environment variables, working +directories, policies, or capability YAML over the wire. The task-manifest +operations (`reset`, `validate`, `activate`, `run`) land with the server-side +task layer. diff --git a/worker-companion/package.json b/worker-companion/package.json new file mode 100644 index 00000000..29e6fb23 --- /dev/null +++ b/worker-companion/package.json @@ -0,0 +1,16 @@ +{ + "name": "openmausbot-worker-companion", + "version": "1.0.0", + "private": true, + "type": "module", + "bin": { + "openmausbot-worker-companion": "dist/index.js" + }, + "dependencies": { + "@trycua/cua-driver": "0.20.0", + "zod": "4.4.3" + }, + "engines": { + "node": ">=24" + } +} diff --git a/worker-companion/src/capability.ts b/worker-companion/src/capability.ts new file mode 100644 index 00000000..4dabccd9 --- /dev/null +++ b/worker-companion/src/capability.ts @@ -0,0 +1,71 @@ +// The parked capability: the worker's resting state. +// +// It grants no tools at all, so a worker running it is reachable and provably +// bounded and can do nothing until a task capability is approved. These strings +// are byte-identical to docs/macos-parked-capabilities.yaml and +// docs/windows-parked-capabilities.yaml; worker-companion/test/parked.test.ts +// fails if they ever drift, because an operator who installs the documented +// file and a companion that writes a different one would disagree on the digest +// and the worker would never come up bounded. +import { createHash, randomBytes } from "node:crypto"; +import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +import { activeCapabilityPath, type WorkerPlatform, workerPlatform } from "./platform.ts"; + +const MAC_PARKED = `# Safe bootstrap state for the interactive macOS CUA daemon. +# The worker companion replaces this file atomically with a short-lived, +# approved task capability before OpenMausBot mounts the CUA MCP bridge. +# +# It grants no tools at all. A worker running this manifest is reachable and +# provably bounded, and can do nothing until a task capability is approved — +# which is the correct resting state between tasks. +version: 3 +expires_after: 8760h +idle_timeout: 20m + +allow: + tools: [] + +resources: + desktop: + display: false +`; + +const WINDOWS_PARKED = `# Safe bootstrap state for the interactive Windows CUA Scheduled Task. +# The Windows companion replaces this file atomically with a short-lived, +# approved task capability before OpenMausBot mounts the CUA MCP bridge. +version: 3 +expires_after: 8760h +idle_timeout: 20m + +allow: + tools: [] + +resources: + desktop: + display: false +`; + +export function parkedCapability(platform: WorkerPlatform = workerPlatform()): string { + return platform === "darwin" ? MAC_PARKED : WINDOWS_PARKED; +} + +export const capabilityDigest = (content: string): string => + createHash("sha256").update(content).digest("hex"); + +/** Replace the active capability atomically and owner-private. A partially + * written capability file is a capability the driver may read as broader than + * intended, so this never writes the live path in place. */ +export function writeActiveCapability(content: string, platform: WorkerPlatform = workerPlatform()): void { + const target = activeCapabilityPath(platform); + mkdirSync(dirname(target), { recursive: true }); + const temporary = `${target}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`; + try { + writeFileSync(temporary, content, { mode: 0o600 }); + renameSync(temporary, target); + } catch (error) { + rmSync(temporary, { force: true }); + throw error; + } +} diff --git a/worker-companion/src/driver.ts b/worker-companion/src/driver.ts new file mode 100644 index 00000000..d9ac9aa0 --- /dev/null +++ b/worker-companion/src/driver.ts @@ -0,0 +1,106 @@ +// Fixed-argv control of the local CUA Driver. +// +// Every invocation here is a constant: no shell, no caller-supplied executable, +// argv, cwd or environment. The companion's whole security value is that the +// wire cannot name a program to run. +import { spawn } from "node:child_process"; + +import { capabilityDigest, parkedCapability, writeActiveCapability } from "./capability.ts"; +import { childEnvironment, cuaSocket } from "./platform.ts"; +import { asDigest, type Sha256Digest } from "./wire.ts"; + +export const EXPECTED_DRIVER_VERSION = "0.20.0"; +const MAX_CAPTURE_BYTES = 1024 * 1024; +const READY_TIMEOUT_MS = 15_000; + +export interface RunResult { stdout: string; stderr: string; code: number | null } + +export function runFixed( + executable: string, + args: string[], + timeoutMs: number, + acceptNonZero = false, +): Promise { + return new Promise((resolveResult, reject) => { + const child = spawn(executable, args, { + shell: false, + env: childEnvironment(), + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(); + }; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + finish(() => reject(new Error(`${executable} timed out`))); + }, timeoutMs); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { stdout = (stdout + chunk).slice(-MAX_CAPTURE_BYTES); }); + child.stderr.on("data", (chunk: string) => { stderr = (stderr + chunk).slice(-MAX_CAPTURE_BYTES); }); + child.on("error", (error) => finish(() => reject(new Error(`${executable} could not start: ${error.message}`)))); + child.on("close", (code) => finish(() => { + if (code === 0 || acceptNonZero) resolveResult({ stdout, stderr, code }); + else reject(new Error(stderr.trim().slice(-500) || `${executable} exited ${code ?? "without a status"}`)); + })); + }); +} + +/** The driver version is pinned, not floored. A newer driver may have different + * tool semantics than the capability manifests were written against, and the + * control plane refuses the worker anyway, so refuse it here with a message + * that names the mismatch. */ +export async function assertDriverVersion(): Promise { + const result = await runFixed("cua-driver", ["--version"], 10_000); + const match = `${result.stdout}\n${result.stderr}`.match(/\b(\d+\.\d+\.\d+)\b/); + if (match?.[1] !== EXPECTED_DRIVER_VERSION) { + throw new Error(`CUA Driver ${match?.[1] ?? "missing"} does not match required ${EXPECTED_DRIVER_VERSION}`); + } +} + +export async function pauseWorker(): Promise { + const socket = cuaSocket(); + await runFixed("cua-driver", ["revoke", "--all", "--socket", socket], 10_000, true); + await runFixed("cua-driver", ["stop", "--socket", socket], 10_000, true); + const status = await runFixed("cua-driver", ["status", "--socket", socket], 5_000, true); + if (status.code === 0) throw new Error("CUA Driver is still running after pause"); +} + +/** Bring the worker back up holding the deny-all parked capability. + * + * The caller must name the base-policy digest it pinned. Requiring it here + * means a resumed worker proves it is enforcing the same ceiling the control + * plane recorded — a driver that silently loaded a different policy from disk + * never satisfies the poll below. */ +export async function resumeParkedWorker(expectedBasePolicySha256: Sha256Digest): Promise { + await assertDriverVersion(); + const content = parkedCapability(); + writeActiveCapability(content); + const parkedDigest = asDigest(capabilityDigest(content)); + const socket = cuaSocket(); + // Best effort: Windows runs the driver from a Scheduled Task that this kicks, + // while a macOS guest may run it from a LaunchAgent that needs no kick. The + // status poll below is the real gate either way. + await runFixed("cua-driver", ["autostart", "kick"], READY_TIMEOUT_MS, true); + const deadline = Date.now() + READY_TIMEOUT_MS; + let diagnostic = ""; + while (Date.now() < deadline) { + const status = await runFixed("cua-driver", ["status", "--socket", socket], 5_000, true); + diagnostic = `${status.stdout}\n${status.stderr}`.toLowerCase(); + if ( + status.code === 0 && + diagnostic.includes(parkedDigest) && + diagnostic.includes(expectedBasePolicySha256.toLowerCase()) && + diagnostic.includes("bounded") + ) return parkedDigest; + await new Promise((wait) => setTimeout(wait, 250)); + } + throw new Error(`bounded CUA capability did not become active: ${diagnostic.trim().slice(-300) || "no status"}`); +} diff --git a/worker-companion/src/index.ts b/worker-companion/src/index.ts new file mode 100644 index 00000000..d77ee72c --- /dev/null +++ b/worker-companion/src/index.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env node +// OpenMausBot worker companion v1. +// +// Runs as the already-authenticated, non-administrative interactive worker user +// on macOS or Windows. It has no listener: OpenMausBot reaches it only over the +// operator-owned SSH alias, either as one of the two out-of-band flags the +// health probe reads, or as the fixed `stdio` command. +// +// The wire can name an operation and a digest. It can never name an executable, +// argv, environment variable, working directory, policy, or capability YAML. +import readline from "node:readline"; + +import { pauseWorker, resumeParkedWorker } from "./driver.ts"; +import { formatPermissions, readPermissions } from "./permissions.ts"; +import { type CompanionRequest, type CompanionResponse, PROTOCOL_VERSION, parseRequest } from "./wire.ts"; + +const MAX_REQUEST_BYTES = 1024 * 1024; + +async function handle(request: CompanionRequest): Promise { + if (request.op === "pause") { + await pauseWorker(); + return { ok: true, version: PROTOCOL_VERSION, paused: true }; + } + const capabilitySha256 = await resumeParkedWorker(request.expectedBasePolicySha256); + return { ok: true, version: PROTOCOL_VERSION, paused: false, capabilitySha256 }; +} + +const reply = (response: CompanionResponse): void => { + process.stdout.write(`${JSON.stringify(response)}\n`); +}; + +const [, , subcommand] = process.argv; + +if (process.argv.includes("--version")) { + // The probe parses the trailing integer as the protocol version. + process.stdout.write(`openmausbot-worker-companion ${PROTOCOL_VERSION}\n`); +} else if (process.argv.includes("--permissions")) { + // Never fails the caller: the probe treats absent or unparseable output as + // "not granted", and that is the correct fail-closed reading of an error here + // too. Diagnostics go to stderr so stdout stays machine-readable. + void (async () => { + try { + process.stdout.write(`${formatPermissions(await readPermissions())}\n`); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.stdout.write(`${formatPermissions({ accessibility: false, screenRecording: false })}\n`); + process.exitCode = 1; + } + })(); +} else if (subcommand === "stdio") { + const input = readline.createInterface({ input: process.stdin, terminal: false }); + let answered = false; + input.on("line", (line: string) => { + // One request per invocation: a long-lived session would let a single + // approved connection be reused for a later, unapproved operation. + if (answered) return; + answered = true; + void (async () => { + try { + if (Buffer.byteLength(line) > MAX_REQUEST_BYTES) throw new Error("request too large"); + reply(await handle(parseRequest(line))); + } catch (error) { + reply({ ok: false, error: error instanceof Error ? error.message : String(error) }); + } + })(); + }); +} else { + process.stderr.write("usage: openmausbot-worker-companion --version | --permissions | stdio\n"); + process.exitCode = 2; +} diff --git a/worker-companion/src/permissions.ts b/worker-companion/src/permissions.ts new file mode 100644 index 00000000..e4cedc2f --- /dev/null +++ b/worker-companion/src/permissions.ts @@ -0,0 +1,37 @@ +// The macOS TCC read. +// +// This is the one health check with no Windows analogue and the one an operator +// cannot script away: Accessibility and Screen Recording are granted per-binary, +// System Integrity Protection blocks writing the TCC database, and replacing the +// driver binary silently revokes them. So this reads the live grant on every +// poll rather than trusting a setup step that happened once. +// +// `currentMacOsPermissionStatus()` is the non-prompting read. Its sibling +// `requestMacOsPermissions()` raises the system dialog and must never be called +// here: an SSH-driven probe has no one at the screen to answer it, and a probe +// that blocks on a dialog reads to the control plane as a hung worker. +import { workerPlatform } from "./platform.ts"; + +export interface PermissionReport { + accessibility: boolean | null; + screenRecording: boolean | null; +} + +const UNSUPPORTED: PermissionReport = { accessibility: null, screenRecording: null }; + +export async function readPermissions(): Promise { + if (workerPlatform() !== "darwin") return UNSUPPORTED; + // Imported lazily so a Windows worker never loads the darwin native module. + const { currentMacOsPermissionStatus } = await import("@trycua/cua-driver"); + const status = currentMacOsPermissionStatus(); + // Anything other than an explicit true is reported false so the control + // plane's ladder fails closed on a driver that answers unexpectedly. + return { + accessibility: status?.accessibility === true, + screenRecording: status?.screenRecording === true, + }; +} + +/** The exact line the health probe parses. Kept on one line, no trailing + * whitespace, so a `grep` for `"accessibility": true` cannot straddle it. */ +export const formatPermissions = (report: PermissionReport): string => JSON.stringify(report); diff --git a/worker-companion/src/platform.ts b/worker-companion/src/platform.ts new file mode 100644 index 00000000..0ae8b18d --- /dev/null +++ b/worker-companion/src/platform.ts @@ -0,0 +1,55 @@ +// Per-OS locations and process environment for the worker companion. +// +// The two adapters in server/ pin these same paths from the control-plane +// side (`MAC_CUA_SOCKET_RELATIVE` and friends in server/mac-worker.ts, +// `WINDOWS_CUA_PIPE` and `WINDOWS_POLICY_PATH` in server/windows-worker.ts). +// They are duplicated rather than imported because the companion ships to the +// worker as a standalone package with no view of the server tree. +import { homedir } from "node:os"; +import { join } from "node:path"; + +export type WorkerPlatform = "darwin" | "win32"; + +export function workerPlatform(): WorkerPlatform { + if (process.platform === "darwin" || process.platform === "win32") return process.platform; + throw new Error(`unsupported worker platform: ${process.platform}`); +} + +const localAppData = () => process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"); + +/** The CUA control channel: a unix socket under the worker's own home on + * macOS, the fixed named pipe on Windows. */ +export function cuaSocket(platform: WorkerPlatform = workerPlatform()): string { + return platform === "darwin" + ? join(homedir(), ".openmausbot", "run", "cua.sock") + : "\\\\.\\pipe\\cua-driver"; +} + +export function supportDirectory(platform: WorkerPlatform = workerPlatform()): string { + return platform === "darwin" + ? join(homedir(), "Library", "Application Support", "OpenMausBot") + : join(localAppData(), "OpenMausBot"); +} + +export function policyPath(platform: WorkerPlatform = workerPlatform()): string { + return join(supportDirectory(platform), platform === "darwin" ? "macos-policy.yaml" : "windows-policy.yaml"); +} + +export function activeCapabilityPath(platform: WorkerPlatform = workerPlatform()): string { + return join(supportDirectory(platform), "active-capabilities.yaml"); +} + +/** Fixed allow-list. Never inherit the caller's environment wholesale: the SSH + * session's environment is attacker-adjacent and the driver is the one process + * on this box that can drive the whole desktop. */ +export function childEnvironment(platform: WorkerPlatform = workerPlatform()): NodeJS.ProcessEnv { + const names = platform === "darwin" + ? ["PATH", "HOME", "TMPDIR", "USER", "LOGNAME", "SHELL", "LANG"] + : [ + "SystemRoot", "WINDIR", "PATH", "PATHEXT", "TEMP", "TMP", "USERPROFILE", + "LOCALAPPDATA", "APPDATA", "ProgramFiles", "ProgramFiles(x86)", "ProgramData", + ]; + return Object.fromEntries( + names.flatMap((name) => (process.env[name] === undefined ? [] : [[name, process.env[name]!]])), + ); +} diff --git a/worker-companion/src/wire.ts b/worker-companion/src/wire.ts new file mode 100644 index 00000000..690de5a6 --- /dev/null +++ b/worker-companion/src/wire.ts @@ -0,0 +1,81 @@ +// The one place untrusted bytes become domain values. +// +// Everything downstream works on parsed types, so no function in driver.ts or +// capability.ts ever has to ask what shape its argument is. That is the point +// of the boundary: the companion's security rests on the wire being unable to +// name a program, a path or a policy, and that is far easier to audit when the +// vocabulary of the wire is this short. +// +// Same shape as server/schema.ts — a JSON-typed parse followed by a zod schema +// — so the two ends of this protocol are validated the same way. +import { z } from "zod"; + +export type JsonPrimitive = string | number | boolean | null; +export interface JsonObject { + [key: string]: JsonValue; +} +export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; + +/** JSON.parse without a reviver can only produce JSON-compatible values. */ +function parseJson(text: string): JsonValue { + return JSON.parse(text); +} + +/** The protocol version this build speaks. */ +export const PROTOCOL_VERSION = 1; + +const digestSchema = z + .string() + .regex(/^[a-f0-9]{64}$/i, "invalid expected base-policy digest") + .brand<"Sha256Digest">(); + +/** A hex SHA-256 that has already been validated. */ +export type Sha256Digest = z.output; + +const versionSchema = z.literal(PROTOCOL_VERSION).optional(); + +const requestSchema = z.discriminatedUnion("op", [ + z.object({ version: versionSchema, op: z.literal("pause") }), + z.object({ + version: versionSchema, + op: z.literal("resume"), + expectedBasePolicySha256: digestSchema, + }), + // reset / validate / activate / run arrive with the server-side task layer. +]); + +export type CompanionRequest = z.output; + +export type CompanionResponse = + | { readonly ok: true; readonly version: number; readonly paused: true } + | { + readonly ok: true; + readonly version: number; + readonly paused: false; + readonly capabilitySha256: Sha256Digest; + } + | { readonly ok: false; readonly error: string }; + +/** Brand a digest this process computed itself. */ +export function asDigest(hex: string): Sha256Digest { + return digestSchema.parse(hex); +} + +/** Parse one line of the stdio protocol. Throws with a message the caller + * returns verbatim as `{ok:false,error}`. */ +export function parseRequest(line: string): CompanionRequest { + let payload: JsonValue; + try { + payload = parseJson(line); + } catch { + throw new Error("invalid JSON"); + } + const parsed = requestSchema.safeParse(payload); + if (parsed.success) return parsed.data; + const issue = parsed.error.issues[0]; + // A rejected `op` is the common case and deserves the clearer message; the + // schema's own text carries the rest (a bad digest, a wrong version). + throw new Error( + issue && issue.path[0] !== "op" ? issue.message : "unsupported operation", + ); +} diff --git a/worker-companion/test/companion.test.ts b/worker-companion/test/companion.test.ts new file mode 100644 index 00000000..61d7e13e --- /dev/null +++ b/worker-companion/test/companion.test.ts @@ -0,0 +1,189 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { capabilityDigest, parkedCapability } from "../src/capability.ts"; +import { formatPermissions } from "../src/permissions.ts"; +import { asDigest, parseRequest } from "../src/wire.ts"; +import { + activeCapabilityPath, + childEnvironment, + cuaSocket, + policyPath, + supportDirectory, +} from "../src/platform.ts"; +import { + MAC_CAPABILITY_RELATIVE, + MAC_CUA_SOCKET_RELATIVE, + MAC_POLICY_RELATIVE, +} from "../../server/mac-worker.ts"; +import { WINDOWS_CUA_PIPE } from "../../server/windows-worker.ts"; + +// The companion ships to the worker as a standalone package, so it embeds the +// parked manifest rather than importing docs/. That duplication is only safe +// while the two stay byte-identical: an operator who installs the documented +// file and a companion that writes a different one disagree on the digest, and +// the worker never comes up bounded. This is the test that keeps them honest. +describe("parked capability", () => { + it.each([ + ["darwin", "docs/macos-parked-capabilities.yaml"], + ["win32", "docs/windows-parked-capabilities.yaml"], + ] as const)("embedded %s manifest matches the documented file", (platform, docPath) => { + const documented = readFileSync(new URL(`../../${docPath}`, import.meta.url), "utf8"); + expect(parkedCapability(platform)).toBe(documented); + }); + + it("grants no tools on either platform", () => { + for (const platform of ["darwin", "win32"] as const) { + expect(parkedCapability(platform)).toContain("tools: []"); + } + }); + + it("digests the exact bytes it is given", () => { + // sha256 of the empty string — proves no trimming or normalisation. + expect(capabilityDigest("")).toBe( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ); + }); +}); + +// The control plane greps the companion's stdout with a fixed pattern +// (server/mac-worker.ts). These assertions are that contract written down: if +// the JSON shape drifts, the probe silently reads "not granted" forever and +// every macOS worker fails at worker_accessibility_denied with nothing obviously +// wrong on the guest. +describe("--permissions output", () => { + const GRANTED = /"accessibility"[\s]*:[\s]*true/; + const RECORDING = /"screenRecording"[\s]*:[\s]*true/; + + it("matches the probe's pattern when both are granted", () => { + const line = formatPermissions({ accessibility: true, screenRecording: true }); + expect(line).toBe('{"accessibility":true,"screenRecording":true}'); + expect(line).toMatch(GRANTED); + expect(line).toMatch(RECORDING); + }); + + it("fails closed for every non-granted shape", () => { + for (const report of [ + { accessibility: false, screenRecording: true }, + { accessibility: true, screenRecording: false }, + { accessibility: null, screenRecording: null }, + ] as const) { + const line = formatPermissions(report); + const bothGranted = GRANTED.test(line) && RECORDING.test(line); + expect(bothGranted).toBe(false); + } + }); + + it("emits a single line so a grep cannot straddle records", () => { + expect(formatPermissions({ accessibility: true, screenRecording: true })).not.toContain("\n"); + }); +}); + +// The companion cannot import the server's constants at runtime, so these +// assert the two independently-declared copies still describe one desktop. +describe("platform paths agree with the server adapters", () => { + it("resolves the macOS socket, policy and capability the probe reads", () => { + expect(cuaSocket("darwin")).toBe(join(homedir(), ...MAC_CUA_SOCKET_RELATIVE.split("/"))); + expect(policyPath("darwin")).toBe(join(homedir(), ...MAC_POLICY_RELATIVE.split("/"))); + expect(activeCapabilityPath("darwin")).toBe(join(homedir(), ...MAC_CAPABILITY_RELATIVE.split("/"))); + }); + + it("uses the fixed Windows pipe the probe connects to", () => { + expect(cuaSocket("win32")).toBe(WINDOWS_CUA_PIPE); + }); + + it("keeps the capability beside the policy", () => { + for (const platform of ["darwin", "win32"] as const) { + expect(activeCapabilityPath(platform).startsWith(supportDirectory(platform))).toBe(true); + expect(policyPath(platform).startsWith(supportDirectory(platform))).toBe(true); + } + }); +}); + +describe("child environment", () => { + const MAC_ALLOWED = ["PATH", "HOME", "TMPDIR", "USER", "LOGNAME", "SHELL", "LANG"]; + + it("passes only allow-listed names through", () => { + const env = childEnvironment("darwin"); + expect(Object.keys(env).every((name) => MAC_ALLOWED.includes(name))).toBe(true); + }); + + it("drops any variable outside the list, whatever the SSH session carried", () => { + const probe = "OMB_UNLISTED_PROBE_VARIABLE"; + const before = process.env[probe]; + process.env[probe] = "should-not-propagate"; + try { + for (const platform of ["darwin", "win32"] as const) { + expect(childEnvironment(platform)).not.toHaveProperty(probe); + } + } finally { + if (before === undefined) delete process.env[probe]; + else process.env[probe] = before; + } + }); +}); + +// The wire is the companion's entire attack surface. These assert what it +// refuses, not just what it accepts: the security claim in the README is that +// a request can name an operation and a digest and nothing else. +describe("stdio request parsing", () => { + it("accepts pause", () => { + expect(parseRequest('{"op":"pause"}')).toEqual({ op: "pause" }); + }); + + it("accepts resume with a valid digest", () => { + const digest = "a".repeat(64); + expect(parseRequest(`{"op":"resume","expectedBasePolicySha256":"${digest}"}`)).toEqual({ + op: "resume", + expectedBasePolicySha256: digest, + }); + }); + + it("accepts an explicit matching protocol version", () => { + expect(parseRequest('{"version":1,"op":"pause"}')).toEqual({ version: 1, op: "pause" }); + }); + + it("rejects a mismatched protocol version", () => { + expect(() => parseRequest('{"version":2,"op":"pause"}')).toThrow(); + }); + + it("rejects malformed JSON", () => { + expect(() => parseRequest("{not json")).toThrow("invalid JSON"); + }); + + it.each([ + ["unknown op", '{"op":"exfiltrate"}'], + ["missing op", "{}"], + ["task-layer op not in this release", '{"op":"run","taskId":"t","commandId":"c"}'], + ])("rejects %s", (_label, line) => { + expect(() => parseRequest(line)).toThrow("unsupported operation"); + }); + + it.each([ + ["absent", '{"op":"resume"}'], + ["too short", `{"op":"resume","expectedBasePolicySha256":"${"a".repeat(63)}"}`], + ["not hex", `{"op":"resume","expectedBasePolicySha256":"${"z".repeat(64)}"}`], + ["not a string", '{"op":"resume","expectedBasePolicySha256":123}'], + ])("rejects a resume whose digest is %s", (_label, line) => { + expect(() => parseRequest(line)).toThrow(); + }); + + it("ignores extra fields rather than letting them reach the driver", () => { + const parsed = parseRequest('{"op":"pause","executable":"/bin/sh","argv":["-c","id"]}'); + expect(parsed).toEqual({ op: "pause" }); + expect(parsed).not.toHaveProperty("executable"); + expect(parsed).not.toHaveProperty("argv"); + }); +}); + +describe("asDigest", () => { + it("brands a digest this process computed", () => { + expect(asDigest(capabilityDigest("x"))).toBe(capabilityDigest("x")); + }); + + it("refuses anything that is not a sha256", () => { + expect(() => asDigest("nope")).toThrow(); + }); +}); From 6b5587e00ed6560187c0548255d497456f10f41c Mon Sep 17 00:00:00 2001 From: gus <42593099+lightcloud00@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:32:52 -0400 Subject: [PATCH 06/10] fix(workers): pin the hashed manifests to LF on every platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows CI caught this: the parked-manifest test failed there because git checks the docs out as CRLF while the companion embeds LF, so a byte-for-byte comparison of identical content disagreed. The test bug is the small half. The real one is that these files are hashed, not merely read — worker-companion requires the CUA daemon to report back the exact sha256 of the manifest it wrote, and docs/byo-*.md has the operator pin the base policy by digest. A Windows operator following the runbook against a CRLF checkout would compute a digest that never matches the one the control plane expects, with both files looking correct on screen. So .gitattributes pins the four digest-sensitive manifests to `text eol=lf` regardless of the checking-out machine's core.autocrlf, and the test normalises line endings because what it asserts is content drift, not encoding. Verified: a naive comparison against CRLF content reproduces the CI failure, the normalised one passes, and `git check-attr` confirms eol=lf resolves for all four files. Refs #508 Co-Authored-By: Claude Opus 5 --- .gitattributes | 13 +++++++++++++ worker-companion/test/companion.test.ts | 6 +++++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..fab1f451 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# The worker capability and policy manifests are hashed, not merely read. +# +# worker-companion embeds the parked manifest byte-for-byte and requires the CUA +# daemon to report back that exact sha256 before a worker is considered bounded, +# and docs/byo-*.md has the operator pin the base policy by digest. A CRLF +# checkout on Windows silently changes both digests, so a Windows operator +# following the runbook would compute a hash that never matches the one the +# control plane expects — with nothing visibly wrong in either file. +# +# These must therefore arrive with LF on every platform, regardless of the +# checking-out machine's core.autocrlf. +docs/*-parked-capabilities.yaml text eol=lf +docs/*-base-policy.yaml text eol=lf diff --git a/worker-companion/test/companion.test.ts b/worker-companion/test/companion.test.ts index 61d7e13e..eedc93ff 100644 --- a/worker-companion/test/companion.test.ts +++ b/worker-companion/test/companion.test.ts @@ -30,8 +30,12 @@ describe("parked capability", () => { ["darwin", "docs/macos-parked-capabilities.yaml"], ["win32", "docs/windows-parked-capabilities.yaml"], ] as const)("embedded %s manifest matches the documented file", (platform, docPath) => { + // Line endings are normalised because this asserts *content* drift, and a + // Windows checkout may convert them. The separate hazard — that a CRLF copy + // hashes differently from the LF one the companion writes — is handled at + // source by the `text eol=lf` rules in .gitattributes, not here. const documented = readFileSync(new URL(`../../${docPath}`, import.meta.url), "utf8"); - expect(parkedCapability(platform)).toBe(documented); + expect(parkedCapability(platform).replace(/\r\n/g, "\n")).toBe(documented.replace(/\r\n/g, "\n")); }); it("grants no tools on either platform", () => { From c828d6c730c9d16926a57af76d6434045f7c1fff Mon Sep 17 00:00:00 2001 From: gus <42593099+lightcloud00@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:47:43 -0400 Subject: [PATCH 07/10] feat(workers): add the task manifest and CUA capability fences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first two of the three fences a worker task passes through. The base policy is the stable ceiling (already pinned by digest in #533); this adds the two that are derived per task. **The task manifest** is the document an operator approves. Every mutable execution field lives inside it and it is hashed, so approval is approval of an exact document — re-registering a changed document under the same task id silently drops its approval, which is the point of the digest. Generalising the Windows-only original left the validation rules shared and reduced everything OS-specific to one profile table, so "which executables are forbidden" has exactly one answer per platform. The macOS list is a basename list because POSIX has no extension to key off: `open` and `osascript` matter as much as the shells, since either turns a bounded command into arbitrary execution, and a bundle path like Terminal.app/Contents/MacOS/Terminal is caught by the same rule. Windows path comparison folds case and separators; POSIX comparison does neither, because folding would let two different binaries compare equal. **The CUA capability** is the short-lived boundary that intersects the base policy, derived entirely from an approved manifest so no new authority enters. Browser and desktop surfaces stay disjoint: a generic click reaches anything on screen, so an origin-scoped browser capability that also exposed generic input would make the origin list decorative. Tests assert exactly that. Also: the registry gained per-worker revocation, so one worker going offline does not revoke approvals on the other — #508 acceptance item 6. 71 tests. Both files lint clean against a 1592-error repo baseline. Not yet wired: task approval and the SSH transport that stages and runs against these documents. This commit is the contract they will both depend on. Refs #508 Co-Authored-By: Claude Opus 5 --- server/worker-cua-capability.test.ts | 122 ++++++++ server/worker-cua-capability.ts | 141 +++++++++ server/worker-task-manifest.test.ts | 259 +++++++++++++++++ server/worker-task-manifest.ts | 415 +++++++++++++++++++++++++++ 4 files changed, 937 insertions(+) create mode 100644 server/worker-cua-capability.test.ts create mode 100644 server/worker-cua-capability.ts create mode 100644 server/worker-task-manifest.test.ts create mode 100644 server/worker-task-manifest.ts diff --git a/server/worker-cua-capability.test.ts b/server/worker-cua-capability.test.ts new file mode 100644 index 00000000..38e2e303 --- /dev/null +++ b/server/worker-cua-capability.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; + +import { WORKER_DEFAULTS, type ResolvedWorker, type WorkerPlatform } from "./computer-workers.ts"; +import type { JsonValue } from "./schema.ts"; +import { workerCuaCapabilityDigest, workerCuaCapabilityManifest } from "./worker-cua-capability.ts"; +import { + WORKER_TASK_IDLE_TIMEOUT_MS, + WORKER_TASK_MANIFEST_VERSION, + parseWorkerTaskManifest, +} from "./worker-task-manifest.ts"; + +const POLICY = "a".repeat(64); +const NOW = 1_800_000_000_000; +const ROOT = { windows: "C:\\omb\\tasks\\task-1", macos: "/Users/worker/.openmausbot/tasks/task-1" } as const; + +function worker(platform: WorkerPlatform): ResolvedWorker { + const defaults = WORKER_DEFAULTS[platform]; + return { + id: platform === "windows" ? "win-box" : "mac-guest", + platform, + displayName: "w", + sshAlias: platform === "windows" ? "omb-win" : "omb-mac", + expectedDriverVersion: "0.20.0", + expectedBasePolicySha256: POLICY, + browserExecutable: defaults.browserExecutable, + browserProfile: defaults.browserProfile, + ideExecutable: defaults.ideExecutable, + paused: false, + configured: true, + }; +} + +function capability(platform: WorkerPlatform, overrides: Record = {}, root: string = ROOT[platform]) { + const w = worker(platform); + // SAFETY: the literal below is composed only of JSON primitives, arrays and + // plain objects, and `overrides` is already typed as JsonValue. + const manifest = parseWorkerTaskManifest({ + version: WORKER_TASK_MANIFEST_VERSION, + platform, + workerId: w.id, + taskId: "task-1", + threadId: "thread-1", + createdAt: NOW, + expiresAt: NOW + 60 * 60_000, + idleTimeoutMs: WORKER_TASK_IDLE_TIMEOUT_MS, + target: { sshAlias: w.sshAlias, basePolicySha256: POLICY }, + files: [], + commands: [{ + id: "build", + executable: platform === "windows" ? "C:\\tools\\build.exe" : "/opt/homebrew/bin/just", + argv: [], + cwd: "src", + timeoutMs: 60_000, + }], + origins: [], + resultPaths: ["result.json", "changes.patch"], + ...overrides, + } as JsonValue, w, NOW); + return workerCuaCapabilityManifest(manifest, root, NOW); +} + +describe.each(["windows", "macos"] as const)("%s CUA capability", (platform) => { + it("is a version 3 manifest with a bounded lifetime", () => { + const yaml = capability(platform); + expect(yaml).toContain("version: 3"); + expect(yaml).toMatch(/expires_after: \d+s/); + expect(yaml).toMatch(/idle_timeout: \d+s/); + }); + + it("never enables the display", () => { + expect(capability(platform)).toContain("display: false"); + }); + + // This is the property the whole split exists to protect: a generic click can + // reach anything on screen, so an origin-scoped browser capability that also + // exposed generic input would make the origin list decorative. + it("gives a browser task origins and no generic input", () => { + const yaml = capability(platform, { surface: "browser", origins: ["https://example.com"] }); + expect(yaml).toContain('- "https://example.com"'); + expect(yaml).toContain("- browser_navigate"); + for (const generic of ["- click", "- type_text", "- press_key", "- hotkey", "- launch_app"]) { + expect(yaml).not.toContain(generic); + } + }); + + it("gives a desktop task generic input, the file manager, and no origins", () => { + const yaml = capability(platform); + expect(yaml).toContain("- click"); + expect(yaml).toContain("- type_text"); + expect(yaml).not.toContain("origins:"); + expect(yaml).not.toContain("- browser_navigate"); + const fileManager = platform === "windows" + ? "C:\\\\Windows\\\\explorer.exe" + : "/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder"; + expect(yaml).toContain(fileManager); + }); + + it("scopes desktop file access to the task root only", () => { + const yaml = capability(platform); + const root = ROOT[platform]; + const encoded = JSON.stringify(root); + expect(yaml).toContain(`read:`); + expect(yaml).toContain(`write:`); + expect(yaml.split(encoded).length - 1).toBe(2); + }); + + it("refuses an expired manifest", () => { + expect(() => capability(platform, { expiresAt: NOW + 500 })).toThrow(/expired/); + }); + + it("refuses a task root that is not absolute for the platform", () => { + const wrong = platform === "windows" ? "/tmp/task" : "C:\\tmp\\task"; + expect(() => capability(platform, {}, wrong)).toThrow(/absolute/); + }); + + it("digests deterministically", () => { + expect(workerCuaCapabilityDigest(capability(platform))) + .toBe(workerCuaCapabilityDigest(capability(platform))); + expect(workerCuaCapabilityDigest(capability(platform))) + .not.toBe(workerCuaCapabilityDigest(capability(platform, { surface: "browser", origins: ["https://a.example"] }))); + }); +}); diff --git a/server/worker-cua-capability.ts b/server/worker-cua-capability.ts new file mode 100644 index 00000000..c39a70bb --- /dev/null +++ b/server/worker-cua-capability.ts @@ -0,0 +1,141 @@ +// The second fence: the short-lived CUA capability manifest. +// +// The base policy is the stable ceiling; this is the per-task boundary that +// intersects it, derived entirely from an already-approved task manifest so no +// new authority can enter here. The worker companion writes it and then +// requires the daemon to report back this exact digest before the task runs. +// +// Browser and generic desktop input are deliberately split. CUA refuses an +// origin-scoped browser manifest that also exposes generic input, because a +// generic click can reach anything on screen and would make the origin list +// decorative. +import { createHash } from "node:crypto"; + +import type { WorkerPlatform } from "./computer-workers.ts"; +import type { WorkerTaskManifest } from "./worker-task-manifest.ts"; + +const WINDOWS_ABSOLUTE = /^[A-Za-z]:\\/; +const POSIX_ABSOLUTE = /^\//; + +const FILE_MANAGER = { + windows: "C:\\Windows\\explorer.exe", + macos: "/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder", +} satisfies Record; + +/** JSON double-quoted strings are valid YAML scalars, which avoids hand-rolling + * quoting rules for Windows paths, profile names, and origins. */ +const yamlString = (value: string): string => JSON.stringify(value); + +function assertTaskRoot(platform: WorkerPlatform, taskRoot: string): void { + const absolute = platform === "windows" ? WINDOWS_ABSOLUTE : POSIX_ABSOLUTE; + if (!absolute.test(taskRoot) || /[\u0000\r\n]/.test(taskRoot)) { + throw new Error(`Worker CUA task root must be an absolute ${platform} path`); + } +} + +function lifetime(manifest: WorkerTaskManifest, now: number) { + const expiresSeconds = Math.floor((manifest.expiresAt - now) / 1_000); + if (expiresSeconds < 1) throw new Error("Worker task capability manifest is expired"); + const idleSeconds = Math.max(1, Math.min(expiresSeconds, Math.floor(manifest.idleTimeoutMs / 1_000))); + return { expiresSeconds, idleSeconds }; +} + +const BROWSER_TOOLS = [ + "start_session", + "end_session", + "list_windows", + "browser_prepare", + "get_browser_state", + "browser_navigate", + "browser_click", + "browser_type", +]; + +const DESKTOP_TOOLS = [ + "start_session", + "end_session", + "launch_app", + "list_windows", + "get_window_state", + "click", + "double_click", + "right_click", + "drag", + "scroll", + "type_text", + "press_key", + "hotkey", + "set_value", + "wait", + "bring_to_front", +]; + +const app = (executable: string): string[] => [ + ` - executable: ${yamlString(executable)}`, + " launch: true", + " windows: all", + " terminate: driver_launched", +]; + +/** Build the native CUA v3 capability manifest the interactive daemon loads. */ +export function workerCuaCapabilityManifest( + manifest: WorkerTaskManifest, + taskRoot: string, + now = Date.now(), +): string { + assertTaskRoot(manifest.platform, taskRoot); + const { expiresSeconds, idleSeconds } = lifetime(manifest, now); + const head = [ + "version: 3", + `expires_after: ${expiresSeconds}s`, + `idle_timeout: ${idleSeconds}s`, + "", + "allow:", + " tools:", + ]; + + if (manifest.surface === "browser") { + if (manifest.origins.length === 0) throw new Error("Browser CUA capability requires exact origins"); + return [ + ...head, + ...BROWSER_TOOLS.map((tool) => ` - ${tool}`), + "", + "resources:", + " apps:", + ...app(manifest.target.browserExecutable), + " browser:", + " profiles:", + " - kind: existing_profile", + " origins:", + ...manifest.origins.map((origin) => ` - ${yamlString(origin)}`), + " desktop:", + " display: false", + "", + ].join("\n"); + } + + if (manifest.origins.length > 0) throw new Error("Desktop CUA capability cannot include browser origins"); + return [ + ...head, + ...DESKTOP_TOOLS.map((tool) => ` - ${tool}`), + "", + "resources:", + " apps:", + ...app(manifest.target.ideExecutable), + ...app(FILE_MANAGER[manifest.platform]), + " files:", + " read:", + ` - dir: ${yamlString(taskRoot)}`, + " recursive: true", + " write:", + ` - dir: ${yamlString(taskRoot)}`, + " recursive: true", + " desktop:", + " display: false", + "", + ].join("\n"); +} + +export function workerCuaCapabilityDigest(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} diff --git a/server/worker-task-manifest.test.ts b/server/worker-task-manifest.test.ts new file mode 100644 index 00000000..4b59aa34 --- /dev/null +++ b/server/worker-task-manifest.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vitest"; + +import { WORKER_DEFAULTS, type ResolvedWorker, type WorkerPlatform } from "./computer-workers.ts"; +import type { JsonValue } from "./schema.ts"; +import { + WORKER_TASK_IDLE_TIMEOUT_MS, + WORKER_TASK_MANIFEST_VERSION, + WORKER_TASK_MAX_LIFETIME_MS, + WorkerTaskRegistry, + parseWorkerTaskManifest, + workerTaskManifestDigest, +} from "./worker-task-manifest.ts"; + +const POLICY = "a".repeat(64); +const NOW = 1_800_000_000_000; + +function worker(platform: WorkerPlatform, overrides: Partial = {}): ResolvedWorker { + const defaults = WORKER_DEFAULTS[platform]; + return { + id: platform === "windows" ? "win-box" : "mac-guest", + platform, + displayName: platform === "windows" ? "Windows box" : "macOS guest", + sshAlias: platform === "windows" ? "omb-win" : "omb-mac", + expectedDriverVersion: "0.20.0", + expectedBasePolicySha256: POLICY, + browserExecutable: defaults.browserExecutable, + browserProfile: defaults.browserProfile, + ideExecutable: defaults.ideExecutable, + paused: false, + configured: true, + ...overrides, + }; +} + +/** A minimal manifest that parses, so each test can change exactly one thing. */ +function manifest(platform: WorkerPlatform, overrides: Record = {}): JsonValue { + const w = worker(platform); + // SAFETY: every value below is a JSON primitive, array, or plain object, and + // `overrides` is already typed as JsonValue, so the literal is JSON by + // construction. + return { + version: WORKER_TASK_MANIFEST_VERSION, + platform, + workerId: w.id, + taskId: "task-1", + threadId: "thread-1", + createdAt: NOW, + expiresAt: NOW + 60 * 60_000, + idleTimeoutMs: WORKER_TASK_IDLE_TIMEOUT_MS, + target: { sshAlias: w.sshAlias, basePolicySha256: POLICY }, + files: [], + commands: [{ + id: "build", + executable: platform === "windows" ? "C:\\tools\\build.exe" : "/opt/homebrew/bin/just", + argv: ["build"], + cwd: "src", + timeoutMs: 60_000, + }], + origins: [], + resultPaths: ["result.json", "changes.patch"], + ...overrides, + } as JsonValue; +} + +const parse = (platform: WorkerPlatform, overrides: Record = {}, w = worker(platform)) => + parseWorkerTaskManifest(manifest(platform, overrides), w, NOW); + +describe.each(["windows", "macos"] as const)("%s task manifest", (platform) => { + it("parses a well-formed manifest and defaults the surface to desktop", () => { + const parsed = parse(platform); + expect(parsed.surface).toBe("desktop"); + expect(parsed.platform).toBe(platform); + expect(parsed.target.browserExecutable).toBe(WORKER_DEFAULTS[platform].browserExecutable); + }); + + it("refuses a manifest bound to the other platform", () => { + const other = platform === "windows" ? "macos" : "windows"; + expect(() => parseWorkerTaskManifest(manifest(platform), worker(other), NOW)).toThrow(); + }); + + it("refuses a manifest naming a different worker id", () => { + expect(() => parse(platform, { workerId: "someone-else" })).toThrow(/different worker/); + }); + + it("refuses a mismatched SSH alias", () => { + const w = worker(platform); + expect(() => parse(platform, { target: { sshAlias: "other-host", basePolicySha256: POLICY } }, w)) + .toThrow(/SSH alias/); + }); + + it("refuses a mismatched base-policy digest", () => { + const w = worker(platform); + expect(() => parse(platform, { target: { sshAlias: w.sshAlias, basePolicySha256: "b".repeat(64) } }, w)) + .toThrow(/policy digest/); + }); + + it("refuses a worker with no pinned base policy", () => { + const unpinned = worker(platform, { configured: false, expectedBasePolicySha256: null }); + expect(() => parseWorkerTaskManifest(manifest(platform), unpinned, NOW)).toThrow(/pinned base policy/); + }); + + it("refuses an expired manifest and one that outlives the two-hour ceiling", () => { + expect(() => parse(platform, { expiresAt: NOW - 1 })).toThrow(/not currently valid/); + expect(() => parse(platform, { expiresAt: NOW + WORKER_TASK_MAX_LIFETIME_MS + 1_000 })).toThrow(/lifetime/); + }); + + it("requires both result artefacts", () => { + expect(() => parse(platform, { resultPaths: ["result.json", "other.txt"] })).toThrow(/result\.json/); + }); + + it("keeps browser and desktop surfaces disjoint", () => { + expect(() => parse(platform, { surface: "browser", origins: [] })).toThrow(/exact origin/); + expect(() => parse(platform, { surface: "desktop", origins: ["https://example.com"] })) + .toThrow(/cannot declare browser origins/); + }); + + it.each([ + ["a wildcard", "https://*.example.com"], + ["a path", "https://example.com/app"], + ["a query", "https://example.com/?a=1"], + ["embedded credentials", "https://user:pw@example.com"], + ["a non-http scheme", "file:///etc/passwd"], + ])("refuses an origin with %s", (_label, origin) => { + expect(() => parse(platform, { surface: "browser", origins: [origin] })).toThrow(/origin must be exact/); + }); + + it("refuses a GUI application as a structured command — that surface belongs to CUA", () => { + const w = worker(platform); + expect(() => parse(platform, { + commands: [{ id: "c", executable: w.ideExecutable, argv: [], cwd: "src", timeoutMs: 1_000 }], + })).toThrow(/driven through CUA/); + }); + + it("refuses a relative executable", () => { + expect(() => parse(platform, { + commands: [{ id: "c", executable: "build.exe", argv: [], cwd: "src", timeoutMs: 1_000 }], + })).toThrow(/absolute path/); + }); + + it("refuses a staged file on a credential-shaped path", () => { + expect(() => parse(platform, { + files: [{ path: "config/id_rsa", size: 1, sha256: "c".repeat(64) }], + })).toThrow(); + }); + + it("digests canonically, so key order cannot change what was approved", () => { + const a = parse(platform); + // The same document with every top-level key emitted in reverse order. + // SAFETY: manifest() returns a plain JSON object literal, so reading its + // own keys back as a record is sound. + const base = manifest(platform) as Record; + const entries = Object.entries(base).reverse(); + expect(entries.map(([key]) => key)).not.toEqual(Object.keys(base)); + // SAFETY: re-assembling that object's own entries cannot make it non-JSON. + const reversed = Object.fromEntries(entries) as JsonValue; + const reordered = parseWorkerTaskManifest(reversed, worker(platform), NOW); + expect(workerTaskManifestDigest(reordered)).toBe(workerTaskManifestDigest(a)); + }); +}); + +// The blocklists differ per platform and each one is the difference between a +// bounded command and arbitrary execution, so they are asserted separately. +describe("forbidden executables", () => { + it.each([ + "C:\\Windows\\System32\\cmd.exe", + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + "C:\\Windows\\System32\\reg.exe", + "C:\\Windows\\System32\\wscript.exe", + ])("refuses %s on Windows", (executable) => { + expect(() => parse("windows", { + commands: [{ id: "c", executable, argv: [], cwd: "src", timeoutMs: 1_000 }], + })).toThrow(/forbidden/); + }); + + it.each([ + "/bin/sh", + "/bin/bash", + "/bin/zsh", + "/usr/bin/osascript", + "/usr/bin/open", + "/usr/bin/sudo", + "/usr/bin/env", + "/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal", + ])("refuses %s on macOS", (executable) => { + expect(() => parse("macos", { + commands: [{ id: "c", executable, argv: [], cwd: "src", timeoutMs: 1_000 }], + })).toThrow(/forbidden/); + }); + + it("still allows an ordinary build binary on each platform", () => { + expect(() => parse("windows", { + commands: [{ id: "c", executable: "C:\\tools\\msbuild.exe", argv: [], cwd: "src", timeoutMs: 1_000 }], + })).not.toThrow(); + expect(() => parse("macos", { + commands: [{ id: "c", executable: "/opt/homebrew/bin/cargo", argv: [], cwd: "src", timeoutMs: 1_000 }], + })).not.toThrow(); + }); + + it("requires a .exe on Windows only", () => { + expect(() => parse("windows", { + commands: [{ id: "c", executable: "C:\\tools\\build", argv: [], cwd: "src", timeoutMs: 1_000 }], + })).toThrow(/\.exe/); + }); +}); + +describe("WorkerTaskRegistry", () => { + const record = () => parse("macos"); + + it("does not treat registration as approval", () => { + const registry = new WorkerTaskRegistry(); + const entry = registry.register(record()); + expect(entry.approvedAt).toBeNull(); + expect(registry.approved(entry.manifest.taskId, entry.digest, NOW)).toBeNull(); + }); + + it("approves against the exact digest and refuses any other", () => { + const registry = new WorkerTaskRegistry(); + const entry = registry.register(record()); + expect(registry.approve(entry.manifest.taskId, "d".repeat(64), NOW)).toBe(false); + expect(registry.approve(entry.manifest.taskId, entry.digest, NOW)).toBe(true); + expect(registry.approved(entry.manifest.taskId, entry.digest, NOW)).not.toBeNull(); + }); + + it("drops approval when the document changes under the same task id", () => { + const registry = new WorkerTaskRegistry(); + const first = registry.register(record()); + registry.approve(first.manifest.taskId, first.digest, NOW); + const changed = parse("macos", { threadId: "thread-2" }); + const second = registry.register(changed); + expect(second.digest).not.toBe(first.digest); + expect(second.approvedAt).toBeNull(); + expect(registry.approved(second.manifest.taskId, second.digest, NOW)).toBeNull(); + }); + + it("expires approval on idle timeout", () => { + const registry = new WorkerTaskRegistry(); + const entry = registry.register(record()); + registry.approve(entry.manifest.taskId, entry.digest, NOW); + const idle = NOW + WORKER_TASK_IDLE_TIMEOUT_MS; + expect(registry.approved(entry.manifest.taskId, entry.digest, idle)).toBeNull(); + }); + + it("revoking one worker leaves the other worker's approvals intact", () => { + const registry = new WorkerTaskRegistry(); + const mac = registry.register(parse("macos")); + const win = registry.register(parseWorkerTaskManifest( + manifest("windows", { taskId: "task-2" }), + worker("windows"), + NOW, + )); + registry.approve(mac.manifest.taskId, mac.digest, NOW); + registry.approve(win.manifest.taskId, win.digest, NOW); + + registry.revokeWorker("mac-guest"); + + expect(registry.approved(mac.manifest.taskId, mac.digest, NOW)).toBeNull(); + expect(registry.approved(win.manifest.taskId, win.digest, NOW)).not.toBeNull(); + }); +}); diff --git a/server/worker-task-manifest.ts b/server/worker-task-manifest.ts new file mode 100644 index 00000000..7cab64a9 --- /dev/null +++ b/server/worker-task-manifest.ts @@ -0,0 +1,415 @@ +// The task manifest: the third fence. +// +// The base policy is the stable ceiling and the CUA capability is the +// short-lived per-task boundary that intersects it. This is the document that +// says what one approved task may actually do — every mutable execution field +// lives inside it, it is hashed, and the hash is what an operator approves. +// +// Derived from the Windows-only manifest and generalised: everything that was +// OS-specific reduces to the small profile table below, so the validation rules +// themselves are shared and there is exactly one place where "which shells are +// forbidden" is answered per platform. +import { createHash } from "node:crypto"; +import { lstatSync, readFileSync, realpathSync } from "node:fs"; +import { isAbsolute, relative, resolve, sep } from "node:path"; + +import { z } from "zod"; + +import { isSafeWorkerExecutable, type ResolvedWorker, type WorkerPlatform } from "./computer-workers.ts"; +import { parseJson, type JsonValue } from "./schema.ts"; + +export const WORKER_TASK_MANIFEST_VERSION = 1 as const; +export const WORKER_TASK_MAX_LIFETIME_MS = 2 * 60 * 60_000; +export const WORKER_TASK_IDLE_TIMEOUT_MS = 20 * 60_000; +export const WORKER_TASK_MAX_FILE_BYTES = 50 * 1024 * 1024; +export const WORKER_TASK_MAX_TOTAL_BYTES = 200 * 1024 * 1024; +export const WORKER_TASK_MAX_COMMAND_MS = 30 * 60_000; + +const ID = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; +const SHA256 = /^[a-f0-9]{64}$/i; +const SAFE_RELATIVE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,511}$/; +const BLOCKED_FILE = + /(^|\/)(?:\.git(?:\/|$)|\.env(?:\.[^/]*)?$|credentials?(?:\.[^/]*)?$|secrets?(?:\.[^/]*)?$|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.[^/]*)?$|[^/]+\.(?:key|pem|p12|pfx|keystore)$)/i; + +/** Per-platform answers to the three questions the shared rules ask: which + * executables are never allowed, which one is the file manager, and how two + * executable paths are compared for equality. */ +interface PlatformProfile { + /** The file manager, which the CUA desktop capability may launch. */ + readonly fileManager: string; + /** Shells, terminals, script hosts and administrative surfaces. Matched on + * the basename so a bundle path like + * /Applications/Utilities/Terminal.app/Contents/MacOS/Terminal is caught. */ + readonly blockedExecutable: RegExp; + /** Windows paths are case-insensitive and separator-agnostic; POSIX paths + * are neither, and folding them would let two different binaries compare + * equal. */ + readonly normalize: (value: string) => string; +} + +const PLATFORM_PROFILES = { + windows: { + fileManager: "C:\\Windows\\explorer.exe", + blockedExecutable: + /(?:^|\\)(?:cmd|powershell|pwsh|wt|windowsterminal|reg|regedit|mmc|taskmgr|control|mshta|wscript|cscript)\.exe$/i, + normalize: (value: string) => value.replaceAll("/", "\\").toLowerCase(), + }, + macos: { + fileManager: "/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder", + // No extension to key off on POSIX, so this is a basename list. `open` and + // `osascript` matter as much as the shells: either one turns a bounded + // command into arbitrary execution. + blockedExecutable: + /(?:^|\/)(?:sh|bash|zsh|dash|ksh|csh|tcsh|fish|osascript|open|sudo|su|env|xargs|launchctl|python|python3|perl|ruby|node|deno|bun|Terminal|iTerm|iTerm2|Script Editor)$/, + normalize: (value: string) => value, + }, +} satisfies Record; + +export interface WorkerTaskFile { + path: string; + size: number; + sha256: string; +} + +export interface WorkerTaskCommand { + id: string; + executable: string; + argv: string[]; + cwd: string; + timeoutMs: number; +} + +export interface WorkerTaskManifest { + version: typeof WORKER_TASK_MANIFEST_VERSION; + /** CUA rejects browser-origin scope combined with generic desktop input, so + * each task selects exactly one native capability surface. */ + surface: "browser" | "desktop"; + platform: WorkerPlatform; + workerId: string; + taskId: string; + threadId: string; + createdAt: number; + expiresAt: number; + idleTimeoutMs: typeof WORKER_TASK_IDLE_TIMEOUT_MS; + target: { + sshAlias: string; + basePolicySha256: string; + browserExecutable: string; + browserProfile: string; + ideExecutable: string; + }; + files: WorkerTaskFile[]; + commands: WorkerTaskCommand[]; + origins: string[]; + resultPaths: string[]; +} + +const relativePath = z.string().refine((value) => { + if (!SAFE_RELATIVE.test(value) || value.includes("//") || value.endsWith("/")) return false; + const parts = value.split("/"); + return !parts.some((part) => part === "." || part === "..") && !BLOCKED_FILE.test(value); +}, { message: "must be a safe, non-secret relative task path" }); + +// The executable grammar depends on the sibling `platform` field, so it is +// checked in the superRefine below rather than here. +const executablePath = z.string().max(512).refine( + (value) => !/[\u0000-\u001f"|<>]/.test(value), + { message: "must not contain control characters or shell metacharacters" }, +); + +const fileSchema = z.object({ + path: relativePath, + size: z.number().int().min(0).max(WORKER_TASK_MAX_FILE_BYTES), + sha256: z.string().regex(SHA256).transform((value) => value.toLowerCase()), +}).strict(); + +const commandSchema = z.object({ + id: z.string().regex(ID), + executable: executablePath, + argv: z.array(z.string().max(4096).refine((value) => !/[\u0000\r\n]/.test(value))).max(128), + cwd: relativePath, + timeoutMs: z.number().int().min(1_000).max(WORKER_TASK_MAX_COMMAND_MS), +}).strict(); + +const manifestSchema = z.object({ + version: z.literal(WORKER_TASK_MANIFEST_VERSION), + surface: z.enum(["browser", "desktop"]).optional(), + platform: z.enum(["windows", "macos"]), + workerId: z.string().regex(ID), + taskId: z.string().regex(ID), + threadId: z.string().regex(ID), + createdAt: z.number().int().positive(), + expiresAt: z.number().int().positive(), + idleTimeoutMs: z.literal(WORKER_TASK_IDLE_TIMEOUT_MS), + target: z.object({ + sshAlias: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/), + basePolicySha256: z.string().regex(SHA256).transform((value) => value.toLowerCase()), + browserExecutable: executablePath.optional(), + browserProfile: z.string().min(1).max(100).refine((value) => !/[\u0000-\u001f]/.test(value)).optional(), + ideExecutable: executablePath.optional(), + }).strict(), + files: z.array(fileSchema).max(512), + commands: z.array(commandSchema).min(1).max(128), + origins: z.array(z.string().max(2048)).max(128), + resultPaths: z.array(relativePath).min(2).max(256), +}).strict(); + +/** An origin the browser capability may reach. Exact only: a wildcard, a path, + * a query, or embedded credentials would make the origin list decorative. */ +function exactOrigin(value: string): string | null { + if (value.includes("*")) return null; + try { + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) return null; + if (url.pathname !== "/" || url.search || url.hash) return null; + return url.origin; + } catch { + return null; + } +} + +/** Parse an untrusted manifest and bind it to one configured worker. */ +export function parseWorkerTaskManifest( + value: JsonValue, + worker: ResolvedWorker, + now = Date.now(), +): WorkerTaskManifest { + const parsed = manifestSchema.safeParse(value); + if (!parsed.success) throw new Error(`Invalid worker task manifest: ${z.prettifyError(parsed.error)}`); + + if (parsed.data.platform !== worker.platform) { + throw new Error("Worker task platform does not match the configured worker"); + } + if (parsed.data.workerId !== worker.id) { + throw new Error("Worker task manifest names a different worker"); + } + const profile = PLATFORM_PROFILES[worker.platform]; + + const manifest: WorkerTaskManifest = { + ...parsed.data, + surface: parsed.data.surface ?? (parsed.data.origins.length > 0 ? "browser" : "desktop"), + target: { + ...parsed.data.target, + browserExecutable: parsed.data.target.browserExecutable ?? worker.browserExecutable, + browserProfile: parsed.data.target.browserProfile ?? worker.browserProfile, + ideExecutable: parsed.data.target.ideExecutable ?? worker.ideExecutable, + }, + }; + + if (!worker.configured || !worker.expectedBasePolicySha256) { + throw new Error("Worker task target has no pinned base policy"); + } + if (manifest.target.sshAlias !== worker.sshAlias) { + throw new Error("Worker task target does not match the configured SSH alias"); + } + if (manifest.target.basePolicySha256 !== worker.expectedBasePolicySha256) { + throw new Error("Worker task policy digest does not match the configured base policy"); + } + + const sameExecutable = (a: string, b: string) => profile.normalize(a) === profile.normalize(b); + if ( + !sameExecutable(manifest.target.browserExecutable, worker.browserExecutable) || + manifest.target.browserProfile !== worker.browserProfile || + !sameExecutable(manifest.target.ideExecutable, worker.ideExecutable) + ) { + throw new Error("Worker task application target does not match the configured worker"); + } + + if (manifest.createdAt > now + 60_000 || manifest.expiresAt <= now) { + throw new Error("Worker task manifest is not currently valid"); + } + if (manifest.expiresAt - manifest.createdAt > WORKER_TASK_MAX_LIFETIME_MS) { + throw new Error("Worker task manifest lifetime exceeds two hours"); + } + + const total = manifest.files.reduce((sum, file) => sum + file.size, 0); + if (total > WORKER_TASK_MAX_TOTAL_BYTES) throw new Error("Worker task staged files exceed 200 MB"); + + const unique = (values: string[], label: string) => { + if (new Set(values.map((entry) => entry.toLowerCase())).size !== values.length) { + throw new Error(`Worker task ${label} must be unique`); + } + }; + unique(manifest.files.map((file) => file.path), "file paths"); + unique(manifest.commands.map((command) => command.id), "command ids"); + unique(manifest.resultPaths, "result paths"); + if (!manifest.resultPaths.includes("result.json") || !manifest.resultPaths.includes("changes.patch")) { + throw new Error("Worker task results must include result.json and changes.patch"); + } + + manifest.origins = manifest.origins.map((origin) => { + const normalized = exactOrigin(origin); + if (!normalized || normalized !== origin) throw new Error(`Worker task origin must be exact: ${origin}`); + return normalized; + }); + unique(manifest.origins, "origins"); + if (manifest.surface === "browser" && manifest.origins.length === 0) { + throw new Error("A browser worker task requires at least one exact origin"); + } + if (manifest.surface === "desktop" && manifest.origins.length > 0) { + throw new Error("A desktop worker task cannot declare browser origins; use a separate browser task"); + } + + // The CUA policy is limited to browser / IDE / file manager. The companion's + // structured runner may additionally launch an exact build or test binary + // named in this approved manifest — but never a shell, terminal, script host, + // registry or administrative surface, and never a GUI app that belongs to + // CUA, because a GUI launched behind SSH would sit outside the capability. + const guiApps = new Set( + [manifest.target.browserExecutable, manifest.target.ideExecutable, profile.fileManager] + .map(profile.normalize), + ); + for (const command of manifest.commands) { + if (!isSafeWorkerExecutable(worker.platform, command.executable)) { + throw new Error(`Worker task executable must be an absolute path: ${command.executable}`); + } + const normalized = profile.normalize(command.executable); + if (profile.blockedExecutable.test(command.executable)) { + throw new Error(`Worker task executable is forbidden: ${command.executable}`); + } + if (worker.platform === "windows" && !normalized.endsWith(".exe")) { + throw new Error(`Worker task executable must be a .exe: ${command.executable}`); + } + if (guiApps.has(normalized)) { + throw new Error(`GUI executable must be driven through CUA, not the command runner: ${command.executable}`); + } + } + return manifest; +} + +function canonical(value: JsonValue): JsonValue { + if (Array.isArray(value)) return value.map(canonical); + if (value === null || !(value instanceof Object)) return value; + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, child]) => [key, canonical(child)]), + ); +} + +/** Canonical JSON: key order must not change the digest an operator approved. */ +export function workerTaskManifestJson(manifest: WorkerTaskManifest): string { + return JSON.stringify(canonical(parseJson(JSON.stringify(manifest)))); +} + +export function workerTaskManifestDigest(manifest: WorkerTaskManifest): string { + return createHash("sha256").update(workerTaskManifestJson(manifest)).digest("hex"); +} + +/** Verify the exact local stage without following symlinks or reading a blocked + * credential path. The manifest path filter runs before any filesystem access, + * and every resolved file must stay below the task root — checked both before + * and after realpath, because a symlink swapped in between would otherwise + * escape. */ +export function verifyWorkerTaskFiles(root: string, manifest: WorkerTaskManifest): void { + if (!isAbsolute(root)) throw new Error("Worker task staging root must be absolute"); + const rootReal = realpathSync(root); + const escapes = (within: string) => + !within || within === ".." || within.startsWith(`..${sep}`) || isAbsolute(within); + + for (const file of manifest.files) { + if (BLOCKED_FILE.test(file.path)) throw new Error(`Worker task file is blocked: ${file.path}`); + const candidate = resolve(rootReal, ...file.path.split("/")); + if (escapes(relative(rootReal, candidate))) { + throw new Error(`Worker task file escapes the staging root: ${file.path}`); + } + const stat = lstatSync(candidate); + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error(`Worker task file is not a regular file: ${file.path}`); + } + if (escapes(relative(rootReal, realpathSync(candidate)))) { + throw new Error(`Worker task file resolves outside the staging root: ${file.path}`); + } + if (stat.size !== file.size) throw new Error(`Worker task file size changed: ${file.path}`); + const digest = createHash("sha256").update(readFileSync(candidate)).digest("hex"); + if (digest !== file.sha256) throw new Error(`Worker task file hash changed: ${file.path}`); + } +} + +export interface WorkerTaskRecord { + manifest: WorkerTaskManifest; + digest: string; + approvedAt: number | null; + lastUsedAt: number | null; +} + +/** In-memory approval and idle fence. A restart intentionally forgets approval: + * the manifest stays data, but remote execution needs a fresh card. */ +export class WorkerTaskRegistry { + private readonly records = new Map(); + + register(manifest: WorkerTaskManifest): WorkerTaskRecord { + const digest = workerTaskManifestDigest(manifest); + const current = this.records.get(manifest.taskId); + // Re-registering the identical document keeps any approval it already has; + // a changed document silently drops it, which is the point of the digest. + const record: WorkerTaskRecord = current?.digest === digest + ? current + : { manifest, digest, approvedAt: null, lastUsedAt: null }; + this.records.set(manifest.taskId, record); + return structuredClone(record); + } + + approve(taskId: string, digest: string, now = Date.now()): boolean { + const record = this.records.get(taskId); + if (!record || record.digest !== digest || record.manifest.expiresAt <= now) return false; + record.approvedAt = now; + record.lastUsedAt = now; + return true; + } + + approved(taskId: string, digest: string, now = Date.now()): WorkerTaskRecord | null { + const record = this.records.get(taskId); + if (!record || record.digest !== digest || record.approvedAt === null || record.lastUsedAt === null) return null; + if (record.manifest.expiresAt <= now || now - record.lastUsedAt >= record.manifest.idleTimeoutMs) { + record.approvedAt = null; + record.lastUsedAt = null; + return null; + } + record.lastUsedAt = now; + return structuredClone(record); + } + + get(taskId: string): WorkerTaskRecord | null { + const record = this.records.get(taskId); + return record ? structuredClone(record) : null; + } + + forThread(threadId: string): WorkerTaskRecord | null { + const records = [...this.records.values()].filter((record) => record.manifest.threadId === threadId); + const record = records.sort((a, b) => b.manifest.createdAt - a.manifest.createdAt)[0]; + return record ? structuredClone(record) : null; + } + + /** Every task on one worker, newest first — used to revoke a worker's whole + * surface when it goes offline without touching the other worker's tasks. */ + forWorker(workerId: string): WorkerTaskRecord[] { + return [...this.records.values()] + .filter((record) => record.manifest.workerId === workerId) + .sort((a, b) => b.manifest.createdAt - a.manifest.createdAt) + .map((record) => structuredClone(record)); + } + + revoke(taskId: string): void { + const record = this.records.get(taskId); + if (record) { + record.approvedAt = null; + record.lastUsedAt = null; + } + } + + revokeWorker(workerId: string): void { + for (const record of this.records.values()) { + if (record.manifest.workerId !== workerId) continue; + record.approvedAt = null; + record.lastUsedAt = null; + } + } + + revokeAll(): void { + for (const record of this.records.values()) { + record.approvedAt = null; + record.lastUsedAt = null; + } + } +} From 041255e4d46670cbffe462fa87709198ecfda201 Mon Sep 17 00:00:00 2001 From: gus <42593099+lightcloud00@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:46:50 -0400 Subject: [PATCH 08/10] feat(workers): approve, stage and run a task on a remote worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the task layer the manifest and capability fences were written for. Nothing on this branch could previously stage a task, approve one, or run one; the two documents were data no code path read. The chain, and who decides what: propose the control plane parses and binds the manifest, registers it, and puts its digest in front of a person. Nothing reaches the worker before that card is answered. stage the local files are re-hashed without following symlinks, then streamed as length-prefixed frames. Path rules are applied per frame, before the manifest that will later confirm them — the manifest arrives in the same stream, so staging cannot depend on it. validate the worker re-reads the staged manifest, checks it against the approved digest, and re-applies the executable rules itself. activate both ends derive the capability independently and must produce the same digest. The control plane sends the instant it derived at, never the document; the worker rebuilds it against its own task root and refuses anything it cannot reproduce. run the wire names a command id. The program and argv come out of the approved document. The four tools ride the same MCP server as the CUA tools they unlock, injected into the bridge's tools/list rather than mounted as a new integration, so no driver and no turn contract changes. The proxy stays thin: every tool call is an RPC to a loopback endpoint that owns the registry, the card and SSH, and it fails closed — an unreachable harness cannot have approved anything. Approval is deliberately unrememberable. A worker's CUA bridge is pre-allowed to the CLI, so this card is the only human gate; an always-allow grant over a digest that changes with every document could only ever be wrong, so the card offers Allow and Deny and nothing else. Per-worker revocation keeps #508 item 6 honest: one worker going offline drops its own approvals and leaves the other's alone. Three things worth naming for review: - The companion carries its own copy of the executable rules and the capability builder. That is the point as much as the cost — a control plane that has been tampered with cannot hand this worker a broader boundary than the worker would derive for itself. A parity test drives both ends against the same corpus. - Validation checks that every declared input is unchanged, not that the file set is exact. A task writes its own build output and result artefacts into the same root, so an exact-set rule would make success look like tampering. - No constructor parameter properties: the server runs under Node's strip-only TypeScript mode, which rejects them at import time. tsc and vitest both transpile, so only booting the real server catches it. Fake-worker protocol tests cover the whole chain on macOS, Linux and Windows CI — #508 acceptance item 8. Items 1-6 and the Windows/Podman receipt remain unproven: they need real hardware. --- server/index.ts | 76 ++++- server/mcp-bridge.test.ts | 146 ++++++++ server/mcp-bridge.ts | 229 +++++++++++-- server/remote-worker.ts | 26 +- server/testing/worker-task.ts | 93 +++++ server/worker-mcp.ts | 12 + server/worker-task-approval.test.ts | 180 ++++++++++ server/worker-task-approval.ts | 217 ++++++++++++ server/worker-task-client.ts | 81 +++++ server/worker-task-frames.test.ts | 175 ++++++++++ server/worker-task-frames.ts | 147 ++++++++ server/worker-task-service.test.ts | 302 +++++++++++++++++ server/worker-task-service.ts | 282 ++++++++++++++++ server/worker-task-transport.test.ts | 333 ++++++++++++++++++ server/worker-task-transport.ts | 432 ++++++++++++++++++++++++ worker-companion/src/driver.ts | 38 ++- worker-companion/src/frames.ts | 145 ++++++++ worker-companion/src/index.ts | 91 ++++- worker-companion/src/manifest.ts | 283 ++++++++++++++++ worker-companion/src/platform.ts | 11 + worker-companion/src/task.ts | 390 +++++++++++++++++++++ worker-companion/src/wire.ts | 73 +++- worker-companion/test/companion.test.ts | 19 +- worker-companion/test/task.test.ts | 264 +++++++++++++++ 24 files changed, 3999 insertions(+), 46 deletions(-) create mode 100644 server/testing/worker-task.ts create mode 100644 server/worker-task-approval.test.ts create mode 100644 server/worker-task-approval.ts create mode 100644 server/worker-task-client.ts create mode 100644 server/worker-task-frames.test.ts create mode 100644 server/worker-task-frames.ts create mode 100644 server/worker-task-service.test.ts create mode 100644 server/worker-task-service.ts create mode 100644 server/worker-task-transport.test.ts create mode 100644 server/worker-task-transport.ts create mode 100644 worker-companion/src/frames.ts create mode 100644 worker-companion/src/manifest.ts create mode 100644 worker-companion/src/task.ts create mode 100644 worker-companion/test/task.test.ts diff --git a/server/index.ts b/server/index.ts index e551eb0c..dee0b40a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -134,6 +134,13 @@ import { LocalVmLease, LocalVmLeasePool } from "./local-vm-lease.ts"; import { publicWorker, type ResolvedWorker } from "./computer-workers.ts"; import { RemoteWorkerLease, remoteWorkerMcp } from "./remote-worker.ts"; import { allWorkerStatuses, workerStatus } from "./worker-status.ts"; +import { WorkerTaskRegistry } from "./worker-task-manifest.ts"; +import { + cancelWorkerTaskApprovalsForThread, + dismissStaleWorkerTaskCards, + resolveWorkerTaskApproval, +} from "./worker-task-approval.ts"; +import { WorkerTaskService } from "./worker-task-service.ts"; import { RepeatDetector, callKey } from "./repeat-detector.ts"; import * as vps from "./vps-computer.ts"; import { RoutineManager, type RoutineRunOn, type RoutineRunTrigger } from "./routines.ts"; @@ -267,6 +274,16 @@ function controlIntegration(botId: string) { }; } +/** Where the worker MCP bridge sends the four task tools. Same loopback shape + * as computer control: the bridge is a separate per-turn process and owns no + * authority of its own. */ +function taskIntegration(botId: string) { + return { + url: `http://127.0.0.1:${PORT}/api/internal/worker-task?botId=${encodeURIComponent(botId)}`, + token: COMMS_TOKEN, + }; +} + /** Run a turn on `targetBotId` and resolve with its assistant text — the * synchronous half of ask_bot. Subscribes to the bus, folds assistant_text * for that thread, resolves on turn.completed (or a 4-min ceiling). */ @@ -771,11 +788,21 @@ function localVmIdleFor(target: LocalVmTarget): LocalVmIdleTimer { const workerLease = new RemoteWorkerLease(); const workerThreadAliases = new Map(); +/** Approvals and activations for every worker, in one place. Per-worker + * revocation lives on the registry so a worker going offline can drop its own + * approvals without touching the other's — #508 acceptance item 6. */ +const workerTasks = new WorkerTaskRegistry(); + function releaseWorkerThread(threadId: string): void { const alias = workerThreadAliases.get(threadId); if (!alias) return; workerLease.release(threadId); workerThreadAliases.delete(threadId); + // A turn that ends holds no approval into the next one. The document stays + // registered; permission to execute it does not survive the turn. + const record = workerTasks.forThread(threadId); + if (record) workerTasks.revoke(record.manifest.taskId); + cancelWorkerTaskApprovalsForThread(threadId); } function releaseLocalVmThread(threadId: string): void { @@ -961,9 +988,11 @@ bus.subscribe((event: RuntimeEvent) => { title: permission && event.approvalScope === "local-computer" ? "Local computer approval" - : permission - ? "Approval needed" - : "Your bot has a question", + : permission && event.approvalScope === "remote-worker-computer" + ? "Worker computer approval" + : permission + ? "Approval needed" + : "Your bot has a question", subtitle: event.summary, options: event.choices?.length ? event.choices : permission ? ["Allow", "Deny"] : [], requestId: event.requestId, @@ -1640,6 +1669,7 @@ async function startTurn( status.channelPath, controlIntegration(bot.id), status.capabilityDigest ?? undefined, + taskIntegration(bot.id), ); workerTarget = worker; computerKind = "worker"; @@ -1983,6 +2013,21 @@ const approvalBus: ApprovalBus = { store, broadcast }; if (stale) console.log(`peer approvals: dismissed ${stale} card(s) left by a previous run`); } +// The four worker task tools are answered here, never in the MCP bridge: the +// registry, the approval card and the SSH transport all live in this process. +const workerTaskService = new WorkerTaskService({ + bus: approvalBus, + registry: workerTasks, + workerFor: (bot) => workerById(cfg, bot.workerId ?? null), +}); + +// Same reasoning as the peer sweep above: a worker task card on disk belongs to +// a resolver that died with the previous process. +{ + const stale = dismissStaleWorkerTaskCards(approvalBus); + if (stale) console.log(`worker tasks: dismissed ${stale} card(s) left by a previous run`); +} + // Handoffs a previous process queued but never ran: the source turn is // dead (no turn survives a restart) so they would otherwise wait forever. // Run them now, through the same drain — target and approvePeerComms are @@ -3034,6 +3079,17 @@ const server = createServer(async (req, res) => { return res.end(Buffer.from(upstream.bytes)); } // ── computer control: proxies read the hold, bots plead for help ── + if (path === "/api/internal/worker-task" && method === "POST") { + const botId = url.searchParams.get("botId") ?? ""; + const bot = store.bot(botId); + if (!bot) return json(res, 404, { error: "no such bot" }); + const body = await readBody(req); + // Propose blocks on a human, so this request is deliberately long-lived; + // the bridge's own timeout is the ceiling, and the approval's 15-minute + // timer resolves it either way. + const outcome = await workerTaskService.handle(bot, body); + return json(res, outcome.status, outcome.error ? { error: outcome.error } : { text: outcome.text ?? "" }); + } if (path === "/api/internal/computer-control") { const botId = url.searchParams.get("botId") ?? ""; const bot = store.bot(botId); @@ -4583,6 +4639,10 @@ const server = createServer(async (req, res) => { if (resolvePeerComms(approvalBus, String(body.requestId), behavior)) { return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" }); } + // worker-task intercept: same harness-native shape as peer approval. + if (resolveWorkerTaskApproval(String(body.requestId), behavior)) { + return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" }); + } const outcome = await answerRequest(bot.threadId, bot.modelSelection.instanceId, String(body.requestId), behavior, body.message, { id: bot.id, name: bot.name }); return json(res, 200, { ok: true, outcome }); } @@ -4602,6 +4662,9 @@ const server = createServer(async (req, res) => { if (resolvePeerComms(approvalBus, requestId, behavior)) { return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" }); } + if (resolveWorkerTaskApproval(requestId, behavior)) { + return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" }); + } const group = store.groupByThread(threadId); // busyBotId is in-memory only, so an approval that outlives its turn — or // the process — leaves a durable card with no speaker behind it. Fall back @@ -4933,6 +4996,13 @@ const server = createServer(async (req, res) => { lease: workerLease, isBotBusy: (botId) => store.bot(botId)?.busy === true, }); + // A worker that is no longer ready cannot be holding the capability its + // task was approved against, so its approvals are dropped here — and only + // its own. #508 item 6 is precisely that killing one worker leaves the + // other usable, approvals included. + for (const status of statuses) { + if (!status.ready) workerTaskService.forgetWorker(status.workerId); + } // The SSH alias names a host in the operator's own config. Nothing // downstream of the control plane needs it, so it never leaves here. return json(res, 200, { diff --git a/server/mcp-bridge.test.ts b/server/mcp-bridge.test.ts index b3f66fbb..5fc84aae 100644 --- a/server/mcp-bridge.test.ts +++ b/server/mcp-bridge.test.ts @@ -8,8 +8,11 @@ import { createGateInterceptor, createInactivityWatchdog, createLineSplitter, + createTaskInterceptor, runLivenessProbe, + WORKER_TASK_TOOLS, } from "./mcp-bridge.ts"; +import type { WorkerTaskClient, WorkerTaskOp } from "./worker-task-client.ts"; /** a probe whose answers the test scripts one call at a time */ function scriptedProbe(answers: boolean[]) { @@ -226,3 +229,146 @@ describe("createGateInterceptor", () => { expect(forwarded).toHaveLength(1); }); }); + +// The bridge's second exception to transparency. A remote worker's CUA session +// is bounded until an approved task unlocks it, so the tools that do the +// unlocking have to live on the same MCP server as the tools they gate. +describe("createTaskInterceptor", () => { + function harness(reply = { text: "done", isError: false }) { + const forwarded: string[] = []; + const emitted: string[] = []; + const calls: { op: WorkerTaskOp; payload: Record }[] = []; + const client: WorkerTaskClient = { + configured: true, + call: (op, payload) => { + calls.push({ op, payload }); + return Promise.resolve(reply); + }, + }; + const interceptor = createTaskInterceptor({ + client, + forward: (line) => forwarded.push(line), + emit: (line) => emitted.push(line), + }); + return { ...interceptor, forwarded, emitted, calls }; + } + + /** inbound() runs on a serialized queue, so a test has to let it drain + * before asserting what was forwarded or remembered. */ + const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + + const listRequest = JSON.stringify({ jsonrpc: "2.0", id: 7, method: "tools/list" }); + const listResult = JSON.stringify({ + jsonrpc: "2.0", + id: 7, + result: { tools: [{ name: "screenshot" }, { name: "click" }] }, + }); + + it("appends the task tools to the far end's list", async () => { + const h = harness(); + h.inbound(listRequest); + await tick(); + h.outbound(listResult); + const names = JSON.parse(h.emitted[0]).result.tools.map((tool: { name: string }) => tool.name); + expect(names).toEqual([ + "screenshot", + "click", + ...WORKER_TASK_TOOLS.map((tool) => tool.name), + ]); + }); + + it("forwards the tools/list request untouched", async () => { + const h = harness(); + h.inbound(listRequest); + await tick(); + expect(h.forwarded).toEqual([listRequest]); + }); + + it("does not append twice if the far end already advertises a task tool", async () => { + const h = harness(); + h.inbound(listRequest); + await tick(); + h.outbound(JSON.stringify({ + jsonrpc: "2.0", + id: 7, + result: { tools: [{ name: "worker_task_run" }] }, + })); + const names = JSON.parse(h.emitted[0]).result.tools.map((tool: { name: string }) => tool.name); + expect(names.filter((name: string) => name === "worker_task_run")).toHaveLength(1); + }); + + it("leaves a tools/list result it never saw the request for alone", () => { + const h = harness(); + h.outbound(listResult); + expect(h.emitted).toEqual([listResult]); + }); + + it("answers a task tool call itself and never forwards it", async () => { + const h = harness(); + h.inbound(JSON.stringify({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "worker_task_run", arguments: { commandId: "build" } }, + })); + await tick(); + expect(h.forwarded).toEqual([]); + expect(h.calls).toEqual([{ op: "run", payload: { commandId: "build" } }]); + const frame = JSON.parse(h.emitted[0]); + expect(frame.id).toBe(3); + expect(frame.result.content[0].text).toBe("done"); + expect(frame.result.isError).toBe(false); + }); + + it("passes a Cua Driver tool call straight through", async () => { + const h = harness(); + const line = JSON.stringify({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "click" } }); + h.inbound(line); + await tick(); + expect(h.forwarded).toEqual([line]); + expect(h.calls).toEqual([]); + }); + + it("passes a line that is not JSON through untouched, in both directions", async () => { + const h = harness(); + h.inbound("not a frame"); + h.outbound("also not a frame"); + await tick(); + expect(h.forwarded).toEqual(["not a frame"]); + expect(h.emitted).toEqual(["also not a frame"]); + }); + + it("surfaces a refusal from the harness as an error result", async () => { + const h = harness({ text: "OpenMausBot could not be reached", isError: true }); + h.inbound(JSON.stringify({ + jsonrpc: "2.0", + id: 5, + method: "tools/call", + params: { name: "worker_task_propose", arguments: { manifest: {} } }, + })); + await tick(); + expect(JSON.parse(h.emitted[0]).result.isError).toBe(true); + }); + + it("answers task calls in the order they arrived", async () => { + const h = harness(); + for (const id of [1, 2, 3]) { + h.inbound(JSON.stringify({ + jsonrpc: "2.0", + id, + method: "tools/call", + params: { name: "worker_task_status" }, + })); + } + await tick(); + expect(h.emitted.map((line) => JSON.parse(line).id)).toEqual([1, 2, 3]); + }); + + it("every task tool describes itself, so a model can tell them apart", () => { + for (const tool of WORKER_TASK_TOOLS) { + expect(tool.name).toMatch(/^worker_task_/); + expect(tool.description.length).toBeGreaterThan(40); + expect(tool.inputSchema.type).toBe("object"); + } + }); +}); diff --git a/server/mcp-bridge.ts b/server/mcp-bridge.ts index af240acc..3b263e1c 100644 --- a/server/mcp-bridge.ts +++ b/server/mcp-bridge.ts @@ -2,14 +2,25 @@ // (container-mcp.ts for the Local VM, vps-container-mcp.ts for the BYO VPS). // It defines no tools and parses no MCP messages: bytes in, bytes out. // -// The single exception to that transparency is the who-is-driving gate -// (opt-in via `gate`). While the person holds control of this computer in -// the app, a `tools/call` from the agent is answered with a refusal HERE, -// on the near side, and never forwarded — Cua Driver on the far side has -// no concept of a person holding the wheel, so the refusal cannot come -// from anywhere else. Everything that is not a tools/call still passes -// through untouched, and with no gate configured the bridge remains the -// byte-for-byte pipe described above. +// There are two exceptions to that transparency, both opt-in. +// +// The who-is-driving gate (`gate`). While the person holds control of this +// computer in the app, a `tools/call` from the agent is answered with a +// refusal HERE, on the near side, and never forwarded — Cua Driver on the far +// side has no concept of a person holding the wheel, so the refusal cannot +// come from anywhere else. +// +// The worker task tools (`task`). A remote worker's CUA session is bounded by +// a capability that only an approved task manifest can activate, so the tools +// that propose, run and read back a task belong on the same MCP server as the +// CUA tools they gate — otherwise a bot would hold a computer it has no way to +// unlock. They are appended to the far end's `tools/list` and answered here +// against the harness's loopback control endpoint, which owns the registry, +// the approval card and the SSH transport. The bridge itself stays thin. +// +// Everything that is not a tools/call still passes through untouched, and with +// neither option configured the bridge remains the byte-for-byte pipe +// described above. // // Two behaviors live here so neither entry point can drift: // 1. Exit without truncation. `process.exit()` in a close/error handler @@ -25,6 +36,7 @@ import { StringDecoder } from "node:string_decoder"; import { CONTROL_REFUSAL_PLAIN, createControlClient } from "./control-client.ts"; import { augmentedPath } from "./env-path.ts"; +import { createWorkerTaskClient, type WorkerTaskClient, type WorkerTaskOp } from "./worker-task-client.ts"; // 45s of TOTAL silence before the bridge even probes. An MCP session is // legitimately quiet between tool calls and a slow screenshot can take tens @@ -139,6 +151,9 @@ export interface BridgeOptions { /** Enables the who-is-driving gate: the harness's loopback control * endpoint plus its per-boot token. Absent → fully transparent bridge. */ gate?: { url: string; token: string }; + /** Enables the worker task tools: the harness's loopback task endpoint plus + * its per-boot token. Absent → the far end's tool list is untouched. */ + task?: { url: string; token: string }; } /** Collect a byte stream into complete newline-terminated lines. MCP's @@ -211,6 +226,167 @@ export function createGateInterceptor(options: { }; } +/** The tools a bot needs to unlock and drive a remote worker. They are named + * for what they authorize, not for what they touch: `propose` is the call that + * puts a card in front of a person, and nothing else here can run until it has + * been answered. */ +export const WORKER_TASK_TOOLS = [ + { + name: "worker_task_propose", + description: + "Propose a task manifest for this worker and ask the person to approve it. " + + "The manifest names every file to stage, every command that may run, and the " + + "browser origins the task may reach. Nothing is staged, activated or run until " + + "the person approves this exact document, and changing any field requires a new " + + "approval. Call this before any computer tool: until a task is approved the " + + "worker holds a capability that grants no tools at all.", + inputSchema: { + type: "object", + properties: { + manifest: { + type: "object", + description: "The worker task manifest, version 1.", + }, + }, + required: ["manifest"], + }, + }, + { + name: "worker_task_status", + description: + "Report the approved task on this worker for the current conversation: its id, " + + "digest, the command ids it may run, and how long the approval has left.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "worker_task_run", + description: + "Run one command from the approved manifest by its id, on the worker, inside the " + + "task's staged directory. The command's program and arguments come from the " + + "approved document — this call selects one, it cannot describe one.", + inputSchema: { + type: "object", + properties: { commandId: { type: "string", description: "A command id from the approved manifest." } }, + required: ["commandId"], + }, + }, + { + name: "worker_task_results", + description: + "Read back the task's declared result artefacts from the worker. Only paths the " + + "approved manifest lists as results are readable.", + inputSchema: { type: "object", properties: {} }, + }, +]; + +const TASK_TOOL_OPS = { + worker_task_propose: "propose", + worker_task_run: "run", + worker_task_status: "status", + worker_task_results: "results", +} satisfies Record; + +/** The tool names this bridge answers itself. */ +type TaskToolName = keyof typeof TASK_TOOL_OPS; + +function taskOpFor(name: string): WorkerTaskOp | null { + // SAFETY: the assertion is guarded by the own-property check on the very + // same object, so `name` is one of this literal's keys by construction. + return Object.hasOwn(TASK_TOOL_OPS, name) ? TASK_TOOL_OPS[name as TaskToolName] : null; +} + +/** Injects the worker task tools into the far end's surface. + * + * Two halves, both on serialized queues for the reason the gate is: answering + * frame N+1 before frame N would reorder the agent's protocol stream. + * + * inbound a `tools/call` for one of ours is answered here and never + * forwarded; everything else passes through, and the id of every + * `tools/list` is remembered + * outbound the result of a remembered `tools/list` gets our descriptors + * appended; every other frame is emitted unchanged + */ +export interface TaskInterceptor { + /** A line from the agent, heading for the far end. */ + inbound: (line: string) => void; + /** A line from the far end, heading for the agent. */ + outbound: (line: string) => void; +} + +export function createTaskInterceptor(options: { + client: WorkerTaskClient; + forward: (line: string) => void; + emit: (line: string) => void; +}): TaskInterceptor { + // Bounded so a far end that never answers a tools/list cannot grow this + // without limit over a long session. + const listIds = new Set(); + const remember = (id: string) => { + if (listIds.size > 64) listIds.clear(); + listIds.add(id); + }; + let queue: Promise = Promise.resolve(); + + const inbound = (line: string) => { + queue = queue.then(async () => { + let frame: any = null; + try { + frame = JSON.parse(line); + } catch { + // not a frame we understand — never stand between the agent and its + // driver on anything but a recognized call + } + if (!frame) { + options.forward(line); + return; + } + if (frame.method === "tools/list" && frame.id !== undefined && frame.id !== null) { + remember(String(frame.id)); + options.forward(line); + return; + } + const op = frame.method === "tools/call" ? taskOpFor(String(frame.params?.name ?? "")) : null; + if (!op) { + options.forward(line); + return; + } + const args = frame.params?.arguments; + const reply = await options.client.call(op, args && args instanceof Object ? { ...args } : {}); + options.emit( + JSON.stringify({ + jsonrpc: "2.0", + id: frame.id ?? null, + result: { content: [{ type: "text", text: reply.text }], isError: reply.isError }, + }), + ); + }); + }; + + const outbound = (line: string) => { + let frame: any = null; + try { + frame = JSON.parse(line); + } catch { + options.emit(line); + return; + } + const id = frame?.id === undefined || frame?.id === null ? null : String(frame.id); + if (!id || !listIds.has(id) || !Array.isArray(frame?.result?.tools)) { + options.emit(line); + return; + } + listIds.delete(id); + const existing = new Set(frame.result.tools.map((tool: any) => String(tool?.name ?? ""))); + frame.result.tools = [ + ...frame.result.tools, + ...WORKER_TASK_TOOLS.filter((tool) => !existing.has(tool.name)), + ]; + options.emit(JSON.stringify(frame)); + }; + + return { inbound, outbound }; +} + export function runMcpBridge(options: BridgeOptions): void { const child = spawn(options.command, options.args, { shell: false, @@ -222,26 +398,41 @@ export function runMcpBridge(options: BridgeOptions): void { child.stdin.on("error", () => {}); child.stderr.pipe(process.stderr); + const emit = (line: string) => process.stdout.write(line + "\n"); + const toChild = (line: string) => child.stdin.write(line + "\n"); + const tasks = options.task + ? createTaskInterceptor({ + client: createWorkerTaskClient({ url: options.task.url, token: options.task.token }), + forward: toChild, + emit, + }) + : null; + let detach: () => void; - if (options.gate) { - const client = createControlClient({ url: options.gate.url, token: options.gate.token }); - const inbound = createLineSplitter( - createGateInterceptor({ + if (options.gate || tasks) { + // The gate runs first when both are configured: while a person holds the + // wheel, nothing executes — proposing or running a task included. + const deliver = tasks ? tasks.inbound : toChild; + let entry = deliver; + if (options.gate) { + const client = createControlClient({ url: options.gate.url, token: options.gate.token }); + entry = createGateInterceptor({ isHeld: async () => (await client.state(true)).held, - forward: (line) => child.stdin.write(line + "\n"), - refuse: (line) => process.stdout.write(line + "\n"), - }), - ); + forward: deliver, + refuse: emit, + }); + } + const inbound = createLineSplitter(entry); const onStdin = (chunk: Buffer) => inbound.push(chunk); process.stdin.on("data", onStdin); process.stdin.on("end", () => { inbound.flush(); child.stdin.end(); }); - // Injected refusals must never land inside one of the child's - // half-written frames, so the child's stdout is re-emitted at line + // Injected refusals and tool results must never land inside one of the + // child's half-written frames, so the child's stdout is re-emitted at line // granularity as well. - const outbound = createLineSplitter((line) => process.stdout.write(line + "\n")); + const outbound = createLineSplitter(tasks ? tasks.outbound : emit); child.stdout.on("data", (chunk) => outbound.push(chunk)); child.stdout.on("end", () => outbound.flush()); detach = () => { diff --git a/server/remote-worker.ts b/server/remote-worker.ts index 58c8296c..a8672d8e 100644 --- a/server/remote-worker.ts +++ b/server/remote-worker.ts @@ -444,11 +444,32 @@ export interface RemoteWorkerMcpDescriptor { scope: "remote-worker-computer"; } +/** The bridge process's own environment: the two loopback endpoints, and + * nothing else. Built in statements rather than conditional spreads so an + * absent endpoint is visibly an omission rather than an empty object folded + * into a literal. */ +function bridgeEnvironment( + control?: { url: string; token: string }, + task?: { url: string; token: string }, +) { + const env: Record = {}; + if (control) { + env.OMB_CONTROL_URL = control.url; + env.OMB_CONTROL_TOKEN = control.token; + } + if (task) { + env.OMB_TASK_URL = task.url; + env.OMB_TASK_TOKEN = task.token; + } + return env; +} + export function remoteWorkerMcp( worker: ResolvedWorker, channelPath: string, control?: { url: string; token: string }, capabilityDigest?: string, + task?: { url: string; token: string }, ): RemoteWorkerMcpDescriptor { if (!worker.sshAlias) throw new Error("worker SSH alias is not configured"); // Throws before any bridge is spawned when the channel path is unsafe. @@ -456,7 +477,10 @@ export function remoteWorkerMcp( return { command: SPAWNED_PROXIES.workerMcp, args: [worker.sshAlias, channelPath, worker.platform], - env: control ? { OMB_CONTROL_URL: control.url, OMB_CONTROL_TOKEN: control.token } : {}, + // Both loopback endpoints reach only the bridge process. The ssh child it + // spawns gets `remoteWorkerSshEnvironment()` instead, which carries neither + // of these, so nothing here can travel to the worker. + env: bridgeEnvironment(control, task), platform: worker.platform === "windows" ? "win32" : "darwin", generation: [ worker.expectedDriverVersion, diff --git a/server/testing/worker-task.ts b/server/testing/worker-task.ts new file mode 100644 index 00000000..c0643063 --- /dev/null +++ b/server/testing/worker-task.ts @@ -0,0 +1,93 @@ +// Fixtures for the worker task layer, shared by the transport, approval, +// service and bridge tests so they cannot disagree about what a valid task +// looks like. +import { WORKER_DEFAULTS, type ResolvedWorker, type WorkerPlatform } from "../computer-workers.ts"; +import type { JsonValue } from "../schema.ts"; +import { + parseWorkerTaskManifest, + WORKER_TASK_IDLE_TIMEOUT_MS, + WORKER_TASK_MANIFEST_VERSION, + type WorkerTaskManifest, +} from "../worker-task-manifest.ts"; + +export const TASK_POLICY = "a".repeat(64); +export const TASK_NOW = 1_800_000_000_000; + +/** The platform whose path layout this host can actually produce. A macOS task + * root is absolute-POSIX and a Windows one is drive-lettered, and the capability + * builder refuses the wrong shape — so a test that touches real paths has to + * follow the host it runs on. */ +export const HOST_TASK_PLATFORM: WorkerPlatform = process.platform === "win32" ? "windows" : "macos"; + +/** A real, harmless executable that is absolute and not on either platform's + * forbidden list. `node` would be rejected: it is a script host. */ +export const HARMLESS_EXECUTABLE = process.platform === "win32" + ? "C:\\Windows\\System32\\hostname.exe" + : "/bin/echo"; + +export function workerFixture( + platform: WorkerPlatform = HOST_TASK_PLATFORM, + overrides: Partial = {}, +): ResolvedWorker { + const defaults = WORKER_DEFAULTS[platform]; + return { + id: platform === "windows" ? "win-box" : "mac-guest", + platform, + displayName: platform === "windows" ? "Windows box" : "macOS guest", + sshAlias: platform === "windows" ? "omb-win" : "omb-mac", + expectedDriverVersion: "0.20.0", + expectedBasePolicySha256: TASK_POLICY, + browserExecutable: defaults.browserExecutable, + browserProfile: defaults.browserProfile, + ideExecutable: defaults.ideExecutable, + paused: false, + configured: true, + ...overrides, + }; +} + +/** A manifest document that parses, so each test changes exactly one thing. */ +export function manifestFixture( + platform: WorkerPlatform = HOST_TASK_PLATFORM, + overrides: Record = {}, +): JsonValue { + const worker = workerFixture(platform); + return { + version: WORKER_TASK_MANIFEST_VERSION, + platform, + workerId: worker.id, + taskId: "task-1", + threadId: "thread-1", + createdAt: TASK_NOW, + expiresAt: TASK_NOW + 60 * 60_000, + idleTimeoutMs: WORKER_TASK_IDLE_TIMEOUT_MS, + target: { sshAlias: worker.sshAlias, basePolicySha256: TASK_POLICY }, + files: [], + commands: [{ + id: "build", + executable: HARMLESS_EXECUTABLE, + argv: ["hello"], + cwd: "src", + timeoutMs: 60_000, + }], + origins: [], + resultPaths: ["result.json", "changes.patch"], + ...overrides, + } as JsonValue; +} + +export function parsedManifest( + platform: WorkerPlatform = HOST_TASK_PLATFORM, + overrides: Record = {}, + worker = workerFixture(platform), +): WorkerTaskManifest { + return parseWorkerTaskManifest(manifestFixture(platform, overrides), worker, TASK_NOW); +} + +/** The shape a companion task root has on each platform, for a fake that has to + * report one back without a real worker. */ +export function fakeTaskRoot(platform: WorkerPlatform, taskId: string): string { + return platform === "windows" + ? `C:\\Users\\worker\\AppData\\Local\\OpenMausBot\\tasks\\${taskId}` + : `/Users/worker/Library/Application Support/OpenMausBot/tasks/${taskId}`; +} diff --git a/server/worker-mcp.ts b/server/worker-mcp.ts index 93e1c7d0..565559ea 100644 --- a/server/worker-mcp.ts +++ b/server/worker-mcp.ts @@ -37,6 +37,17 @@ const gate = (() => { const token = process.env.OMB_CONTROL_TOKEN ?? ""; return url && token ? { gate: { url, token } } : {}; })(); +// The task tools ride the same MCP server as the CUA tools they unlock, so a +// bot never holds a bounded worker with no way to propose the task that would +// unbind it. +const task = (() => { + const url = process.env.OMB_TASK_URL ?? ""; + const secret = process.env.OMB_TASK_TOKEN ?? ""; + return url && secret ? { task: { url, token: secret } } : {}; +})(); +// Note which environment the ssh child gets: the allow-list below, which +// carries neither loopback secret. Both stay in this process, where the two +// interceptors use them, and never cross to the worker. const sshEnv = remoteWorkerSshEnvironment(); runMcpBridge({ @@ -46,4 +57,5 @@ runMcpBridge({ label: "Worker CUA Driver", liveness: { command: "ssh", args: livenessArgs, env: sshEnv }, ...gate, + ...task, }); diff --git a/server/worker-task-approval.test.ts b/server/worker-task-approval.test.ts new file mode 100644 index 00000000..f99fd92b --- /dev/null +++ b/server/worker-task-approval.test.ts @@ -0,0 +1,180 @@ +// The only human gate on the worker path, so these tests care about two things +// above all: that the card says enough for a person to actually decide, and +// that nothing about it can be answered by anything other than a person. +import { rmSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { autoVerdict } from "./auto-approve.ts"; +import { DATA_DIR } from "./config.ts"; +import type { ModelSelection } from "./contracts.ts"; +import { Store, type BotRecord } from "./store.ts"; +import { HOST_TASK_PLATFORM, parsedManifest, workerFixture } from "./testing/worker-task.ts"; +import { + cancelWorkerTaskApprovalsForThread, + cancelWorkerTaskApprovalsForWorker, + describeWorkerTask, + dismissStaleWorkerTaskCards, + requestWorkerTaskApproval, + resolveWorkerTaskApproval, + type WorkerApprovalBus, +} from "./worker-task-approval.ts"; +import { workerTaskManifestDigest } from "./worker-task-manifest.ts"; + +const selection = (): ModelSelection => ({ instanceId: "claude", model: "fake-model" }); +const worker = workerFixture(); +const manifest = parsedManifest(); +const digest = workerTaskManifestDigest(manifest); + +let store: Store; +let bus: WorkerApprovalBus; +let bot: BotRecord; + +beforeEach(() => { + rmSync(DATA_DIR, { recursive: true, force: true }); + store = new Store(selection); + bus = { store, broadcast: () => {} }; + bot = store.createBot(); +}); + +// The pending map lives in the module, exactly as peer-approval's does. Settle +// anything a test left open while its store is still alive — settling a card +// after the store is gone would throw inside the cleanup itself. +afterEach(() => { + cancelWorkerTaskApprovalsForWorker(worker.id); + cancelWorkerTaskApprovalsForWorker("other-worker"); +}); + +const openCard = (threadId: string) => + store.messagesFor(threadId).find((message) => message.card?.tool?.startsWith("worker_task:")); + +describe("the approval card", () => { + it("says which machine, what surface, and exactly what will run", () => { + const text = describeWorkerTask(worker, manifest); + expect(text).toContain(worker.displayName); + expect(text).toContain(HOST_TASK_PLATFORM === "windows" ? "Windows" : "macOS"); + expect(text).toContain("desktop"); + expect(text).toContain(manifest.commands[0].executable); + expect(text).toContain("Expires"); + }); + + it("never names the SSH alias", () => { + // #508 item 7: the transport identity stays out of anything a bot, a + // device client, or an export can read — and a card is all three. + expect(describeWorkerTask(worker, manifest)).not.toContain(worker.sshAlias); + }); + + it("lists the origins a browser task may reach", () => { + const browser = parsedManifest(HOST_TASK_PLATFORM, { surface: "browser", origins: ["https://example.com"] }); + expect(describeWorkerTask(worker, browser)).toContain("https://example.com"); + }); + + it("offers Allow and Deny, and never an always-allow grant", () => { + void requestWorkerTaskApproval(bus, worker, manifest, digest, bot.threadId); + const card = openCard(bot.threadId)?.card; + expect(card?.options).toEqual(["Allow", "Deny"]); + // An always-allow key would be a grant over a digest that changes with + // every document — it could only ever be wrong. + expect(card?.allowKey).toBeUndefined(); + expect(card?.approvalScope).toBe("remote-worker-computer"); + expect(card?.tool).toBe(`worker_task:${digest.slice(0, 12)}`); + }); +}); + +describe("answering", () => { + it("resolves allow and settles the card", async () => { + const pending = requestWorkerTaskApproval(bus, worker, manifest, digest, bot.threadId); + const requestId = openCard(bot.threadId)?.card?.requestId ?? ""; + expect(resolveWorkerTaskApproval(requestId, "allow")).toBe(true); + await expect(pending).resolves.toBe("allow"); + expect(openCard(bot.threadId)?.card?.answered).toBe("allow"); + }); + + it("resolves deny and settles the card", async () => { + const pending = requestWorkerTaskApproval(bus, worker, manifest, digest, bot.threadId); + const requestId = openCard(bot.threadId)?.card?.requestId ?? ""; + expect(resolveWorkerTaskApproval(requestId, "deny")).toBe(true); + await expect(pending).resolves.toBe("deny"); + }); + + it("treats anything that is not an explicit allow as a denial", async () => { + const pending = requestWorkerTaskApproval(bus, worker, manifest, digest, bot.threadId); + const requestId = openCard(bot.threadId)?.card?.requestId ?? ""; + resolveWorkerTaskApproval(requestId, "answer"); + await expect(pending).resolves.toBe("deny"); + }); + + it("passes an unknown request id through to the provider adapter", () => { + expect(resolveWorkerTaskApproval("not-a-worker-task", "allow")).toBe(false); + }); + + it("cannot be answered twice", async () => { + const pending = requestWorkerTaskApproval(bus, worker, manifest, digest, bot.threadId); + const requestId = openCard(bot.threadId)?.card?.requestId ?? ""; + resolveWorkerTaskApproval(requestId, "allow"); + await pending; + expect(resolveWorkerTaskApproval(requestId, "allow")).toBe(false); + }); +}); + +describe("cancellation", () => { + it("one worker going offline leaves the other worker's approval pending", async () => { + const other = workerFixture(HOST_TASK_PLATFORM, { id: "other-worker", displayName: "Other" }); + const mine = requestWorkerTaskApproval(bus, worker, manifest, digest, bot.threadId); + const second = store.createBot(); + const theirs = requestWorkerTaskApproval(bus, other, manifest, digest, second.threadId); + + // #508 acceptance item 6, at the approval layer: disconnecting one worker + // must not disturb anything belonging to the other. + expect(cancelWorkerTaskApprovalsForWorker(worker.id)).toBe(1); + await expect(mine).resolves.toBe("deny"); + + const theirRequestId = openCard(second.threadId)?.card?.requestId ?? ""; + expect(resolveWorkerTaskApproval(theirRequestId, "allow")).toBe(true); + await expect(theirs).resolves.toBe("allow"); + }); + + it("an interrupted turn denies its own thread's approval rather than waiting out the timer", async () => { + const pending = requestWorkerTaskApproval(bus, worker, manifest, digest, bot.threadId); + expect(cancelWorkerTaskApprovalsForThread(bot.threadId)).toBe(1); + await expect(pending).resolves.toBe("deny"); + expect(openCard(bot.threadId)?.card?.dismissed).toBe(true); + }); +}); + +describe("stale cards from a previous run", () => { + it("are settled at boot, so the composer is not blocked forever", () => { + // A card with no in-memory resolver: exactly what a crashed process leaves. + store.appendMessage(bot.threadId, { + role: "bot", + kind: "options", + card: { + title: "Run a task on macOS guest", + subtitle: "…", + options: ["Allow", "Deny"], + requestId: "gone-with-the-process", + tool: `worker_task:${digest.slice(0, 12)}`, + }, + }); + expect(dismissStaleWorkerTaskCards(bus)).toBe(1); + expect(openCard(bot.threadId)?.card?.answered).toBe("deny"); + }); + + it("leaves a card whose approval is still pending alone", () => { + void requestWorkerTaskApproval(bus, worker, manifest, digest, bot.threadId); + expect(dismissStaleWorkerTaskCards(bus)).toBe(0); + expect(openCard(bot.threadId)?.card?.answered).toBeUndefined(); + }); +}); + +describe("the auto-approval rules agree", () => { + const scoped = { scope: "remote-worker-computer" } as const; + + it("a remembered always-allow grant cannot answer a worker request", () => { + const granted = { ...bot, autoApprove: false, alwaysAllow: ["worker_task:abc"] }; + expect(autoVerdict(granted, "worker_task:abc", "run a task", scoped).approve).toBeNull(); + }); + + it("and neither can auto mode's unclassified-GUI allowance without it being on", () => { + expect(autoVerdict({ ...bot, autoApprove: false }, "click", "click at 10,10", scoped).approve).toBeNull(); + }); +}); diff --git a/server/worker-task-approval.ts b/server/worker-task-approval.ts new file mode 100644 index 00000000..ea5c4d1f --- /dev/null +++ b/server/worker-task-approval.ts @@ -0,0 +1,217 @@ +// Harness-native approval for one remote worker task. +// +// This is the only human gate on the whole worker path, and it is worth being +// explicit about why. A worker's CUA bridge mounts with +// `scope: "remote-worker-computer"`, which `drivers/claude.ts` treats as "not +// the user's own screen" and therefore pre-allows — so a worker tool call never +// reaches the provider's permission broker. Containment comes from the three +// fences instead: the base policy on the worker, the derived CUA capability, +// and this card over the task manifest's digest. +// +// Mechanically it rides the same options-card flow as peer-approval.ts: a card +// with a harness-owned `requestId`, intercepted by the respond endpoints before +// the provider adapter ever sees it. Two things differ, both deliberate: +// +// * There is no "Always allow". A remembered grant cannot describe a task +// whose whole identity is a digest that changes with every document, and +// `autoVerdict` already refuses to let a grant answer a scoped desktop +// request. The card offers Allow and Deny, and nothing else. +// * The card never names the SSH alias. #508 item 7 keeps the transport +// identity out of anything a bot, a device client, or an export can read. +import { newId } from "./contracts.ts"; +import { publicWorker, type ResolvedWorker } from "./computer-workers.ts"; +import type { ApprovalBus } from "./peer-approval.ts"; +import type { Message } from "./store.ts"; +import type { WorkerTaskManifest } from "./worker-task-manifest.ts"; + +/** Exactly what peer-approval.ts needs, because it is the same object: index.ts + * builds one `approvalBus` and hands it to both. Re-exported under this name so + * a reader of this file does not have to go looking for the shape. */ +export type WorkerApprovalBus = ApprovalBus; + +/** Long enough for a person to actually read a command list, short enough that + * an unattended task cannot hold a worker's lease all day. Matches the peer + * approval and Claude broker timeouts. */ +export const WORKER_TASK_APPROVAL_TIMEOUT_MS = 15 * 60_000; + +interface Pending { + resolve: (result: "allow" | "deny") => void; + timer: ReturnType; + workerId: string; + taskId: string; + threadId: string; + messageId: string; + bus: WorkerApprovalBus; +} + +/** requestId → pending approval. Memory only: a restart denies every in-flight + * task, which is the same posture `WorkerTaskRegistry` takes on its approvals. */ +const pendingTasks = new Map(); + +function humanBytes(total: number): string { + if (total < 1024) return `${total} B`; + if (total < 1024 * 1024) return `${Math.round(total / 1024)} KB`; + return `${(total / (1024 * 1024)).toFixed(1)} MB`; +} + +/** The whole of what a person is agreeing to, in the order they need it: + * which machine, what surface, what will actually execute, and how long the + * approval lives. Built from `publicWorker`, so the alias cannot leak into a + * card, a transcript, or an export. */ +export function describeWorkerTask(worker: ResolvedWorker, manifest: WorkerTaskManifest): string { + const shown = publicWorker(worker); + const os = shown.platform === "windows" ? "Windows" : "macOS"; + const bytes = manifest.files.reduce((sum, file) => sum + file.size, 0); + const lines = [ + `${shown.displayName} (${os}) · ${manifest.surface} · ${manifest.files.length} files, ${humanBytes(bytes)}`, + "", + ...manifest.commands.map((command) => `• ${[command.executable, ...command.argv].join(" ")}`), + ]; + if (manifest.origins.length > 0) { + lines.push("", `Browser origins: ${manifest.origins.join(", ")}`); + } + lines.push( + "", + `Expires ${new Date(manifest.expiresAt).toLocaleTimeString()} · idles out after ${ + Math.round(manifest.idleTimeoutMs / 60_000) + } min`, + ); + return lines.join("\n"); +} + +function pushApprovalCard( + bus: WorkerApprovalBus, + worker: ResolvedWorker, + manifest: WorkerTaskManifest, + digest: string, + requestId: string, + threadId: string, +): Message { + return bus.store.appendMessage(threadId, { + role: "bot", + kind: "options", + card: { + title: `Run a task on ${worker.displayName}`, + subtitle: describeWorkerTask(worker, manifest), + options: ["Allow", "Deny"], + requestId, + // The digest is the identity of what was approved. Naming it as the tool + // puts it in the transcript and in the decision log, so an audit can tie + // a click to the exact document that ran. + tool: `worker_task:${digest.slice(0, 12)}`, + // Deliberately no allowKey: see the header. + approvalScope: "remote-worker-computer", + }, + }); +} + +/** Ask the person whether this exact document may run on this exact worker. + * Resolves `"allow"` or `"deny"`; never resolves from a remembered grant. */ +export function requestWorkerTaskApproval( + bus: WorkerApprovalBus, + worker: ResolvedWorker, + manifest: WorkerTaskManifest, + digest: string, + threadId: string, +): Promise<"allow" | "deny"> { + return new Promise((resolve) => { + const requestId = newId(); + // The card exists before the entry, so a timeout or an answer can always + // find it to settle. + const card = pushApprovalCard(bus, worker, manifest, digest, requestId, threadId); + const timer = setTimeout(() => { + const pending = pendingTasks.get(requestId); + if (!pending) return; + pendingTasks.delete(requestId); + settleCard(pending, "deny", "system"); + resolve("deny"); + }, WORKER_TASK_APPROVAL_TIMEOUT_MS); + timer.unref?.(); // a waiting card must never hold the process open + pendingTasks.set(requestId, { + resolve, + timer, + workerId: worker.id, + taskId: manifest.taskId, + threadId, + messageId: card.id, + bus, + }); + }); +} + +/** Mark the card answered so the UI stops treating it as pending. A + * harness-native card emits no `request.resolved`, so it settles itself. */ +function settleCard(pending: Pending, behavior: string, source: "user" | "system"): void { + const existing = pending.bus.store + .messagesFor(pending.threadId) + .find((message) => message.id === pending.messageId); + if (!existing?.card || existing.card.answered) return; + pending.bus.store.patchMessage(pending.threadId, pending.messageId, { + card: { ...existing.card, answered: behavior, dismissed: source !== "user" }, + }); +} + +/** Called by the respond endpoints BEFORE forwarding to the provider adapter. + * True means the requestId was a worker task and has now been settled. */ +export function resolveWorkerTaskApproval(requestId: string, behavior: string | undefined): boolean { + const pending = pendingTasks.get(requestId); + if (!pending) return false; + pendingTasks.delete(requestId); + clearTimeout(pending.timer); + const allow = behavior === "allow"; + settleCard(pending, allow ? "allow" : "deny", "user"); + pending.resolve(allow ? "allow" : "deny"); + return true; +} + +function cancelWhere(match: (pending: Pending) => boolean): number { + let cancelled = 0; + for (const [requestId, pending] of pendingTasks) { + if (!match(pending)) continue; + pendingTasks.delete(requestId); + clearTimeout(pending.timer); + settleCard(pending, "deny", "system"); + pending.resolve("deny"); + cancelled += 1; + } + return cancelled; +} + +/** One worker going offline denies only its own pending tasks. #508 item 6: + * the other worker's desktop stays usable, and its approvals stay valid. */ +export function cancelWorkerTaskApprovalsForWorker(workerId: string): number { + return cancelWhere((pending) => pending.workerId === workerId); +} + +/** An interrupted turn must not leave its task waiting out the full timeout. */ +export function cancelWorkerTaskApprovalsForThread(threadId: string): number { + return cancelWhere((pending) => pending.threadId === threadId); +} + +export function cancelWorkerTaskApproval(taskId: string): number { + return cancelWhere((pending) => pending.taskId === taskId); +} + +/** Cards left on disk by a previous run can never be answered — the promise + * they belonged to died with the process. Settle them at boot so a crashed run + * does not leave a thread with a permanently blocked composer. */ +export function dismissStaleWorkerTaskCards(bus: WorkerApprovalBus): number { + let dismissed = 0; + for (const bot of bus.store.bots) { + const threadIds = new Set([bot.threadId, ...(bot.tasks ?? []).map((task) => task.threadId)]); + for (const threadId of threadIds) { + for (const message of bus.store.messagesFor(threadId)) { + const card = message.card; + if (!card?.requestId || card.answered || card.dismissed) continue; + if (!card.tool?.startsWith("worker_task:")) continue; + if (pendingTasks.has(card.requestId)) continue; + // Boot-time, before any client is connected, so this counts rather + // than broadcasting — the same as dismissStalePeerCards. + if (bus.store.patchMessage(threadId, message.id, { card: { ...card, answered: "deny", dismissed: true } })) { + dismissed += 1; + } + } + } + } + return dismissed; +} diff --git a/server/worker-task-client.ts b/server/worker-task-client.ts new file mode 100644 index 00000000..295795f8 --- /dev/null +++ b/server/worker-task-client.ts @@ -0,0 +1,81 @@ +// The proxy-side half of the worker task layer, mirroring control-client.ts. +// +// The MCP bridge runs as a separate per-turn process and has no view of the +// worker registry, the approval card, or the SSH transport — all three live in +// the harness. So the task tools it exposes are RPCs to the harness's loopback +// endpoint, and this file is the whole of that conversation. +// +// Failure posture: CLOSED, and the opposite of control-client.ts on purpose. +// Control is cooperation about who is holding the mouse; a task is authority to +// execute on a real machine. An unreachable harness cannot have approved +// anything, so it must read as a refusal, never as a pass. + +import { z } from "zod"; + +import type { JsonObject } from "./schema.ts"; + +export type WorkerTaskOp = "propose" | "status" | "run" | "results"; + +/** The harness answers with one of these two fields and nothing else. */ +const replySchema = z.object({ + text: z.string().max(1024 * 1024).optional(), + error: z.string().max(4096).optional(), +}).loose(); + +export interface WorkerTaskReply { + /** Rendered for the model as the tool result body. */ + text: string; + isError: boolean; +} + +export interface WorkerTaskClient { + call(op: WorkerTaskOp, payload: JsonObject): Promise; + readonly configured: boolean; +} + +/** Long enough for the whole approval to resolve: proposing a task shows a card + * and waits for a person, which is minutes, not seconds. */ +const CALL_TIMEOUT_MS = 16 * 60_000; + +const UNAVAILABLE: WorkerTaskReply = { + text: + "OpenMausBot could not be reached, so this worker task was NOT performed and nothing was approved. " + + "Do not retry in a loop — tell the person the control plane is unavailable.", + isError: true, +}; + +export function createWorkerTaskClient(options?: { + url?: string; + token?: string; + fetchImpl?: typeof fetch; +}): WorkerTaskClient { + const url = options?.url ?? process.env.OMB_TASK_URL ?? ""; + const token = options?.token ?? process.env.OMB_TASK_TOKEN ?? ""; + const fetchImpl = options?.fetchImpl ?? fetch; + const configured = Boolean(url && token); + const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" }; + + return { + configured, + async call(op: WorkerTaskOp, payload: JsonObject): Promise { + if (!configured) return UNAVAILABLE; + try { + const res = await fetchImpl(url, { + method: "POST", + headers, + body: JSON.stringify({ op, ...payload }), + signal: AbortSignal.timeout(CALL_TIMEOUT_MS), + }); + const body = replySchema.safeParse(await res.json().catch(() => null)); + if (!res.ok) { + const reason = body.success && body.data.error ? body.data.error : `request failed (${res.status})`; + return { text: `This worker task was NOT performed: ${reason}`, isError: true }; + } + if (body.success && body.data.text !== undefined) return { text: body.data.text, isError: false }; + return { text: "This worker task returned an unreadable result and was NOT performed.", isError: true }; + } catch { + return UNAVAILABLE; + } + }, + }; +} diff --git a/server/worker-task-frames.test.ts b/server/worker-task-frames.test.ts new file mode 100644 index 00000000..48284c30 --- /dev/null +++ b/server/worker-task-frames.test.ts @@ -0,0 +1,175 @@ +import { Buffer } from "node:buffer"; +import { describe, expect, it } from "vitest"; + +import * as companion from "../worker-companion/src/frames.ts"; +import { PLATFORM_PROFILES, taskCapabilityManifest, parseStagedManifest } from "../worker-companion/src/manifest.ts"; +import { HOST_TASK_PLATFORM, fakeTaskRoot, manifestFixture, parsedManifest } from "./testing/worker-task.ts"; +import { workerCuaCapabilityDigest, workerCuaCapabilityManifest } from "./worker-cua-capability.ts"; +import * as server from "./worker-task-frames.ts"; +import { workerTaskManifestDigest, workerTaskManifestJson } from "./worker-task-manifest.ts"; +import type { JsonValue } from "./schema.ts"; + +/** Collect a frame stream into whole frames, for the tests below. */ +function decode(module: typeof server | typeof companion, bytes: Buffer) { + const frames: { header: server.FrameHeader; payload: Buffer }[] = []; + let parts: Buffer[] = []; + const reader = new module.FrameReader({ + onHeader: () => { parts = []; }, + onPayload: (chunk) => { parts.push(chunk); }, + onFrameEnd: (header) => { + if (header.kind !== "end") frames.push({ header, payload: Buffer.concat(parts) }); + }, + }); + reader.push(bytes); + reader.end(); + return frames; +} + +// The two framing modules are duplicated because the companion ships to the +// worker as a standalone package. Duplication only stays safe while each end +// can read what the other writes, which is what these drive. +describe("frame parity between the control plane and the companion", () => { + const manifest = Buffer.from('{"hello":"world"}', "utf8"); + const payload = Buffer.from([0, 1, 2, 253, 254, 255]); + + it("the companion reads what the control plane writes", () => { + const stream = Buffer.concat([ + server.encodeFrame({ kind: "manifest", bytes: manifest.length }, manifest), + server.encodeFrame({ kind: "file", bytes: payload.length, path: "a/b.bin", sha256: "b".repeat(64) }, payload), + server.END_FRAME, + ]); + const frames = decode(companion, stream); + expect(frames.map((frame) => frame.header.kind)).toEqual(["manifest", "file"]); + expect(frames[1].payload.equals(payload)).toBe(true); + }); + + it("the control plane reads what the companion writes", () => { + const stream = Buffer.concat([ + companion.encodeFrame({ kind: "file", bytes: payload.length, path: "result.json", sha256: "c".repeat(64) }, payload), + companion.END_FRAME, + ]); + const frames = decode(server, stream); + expect(frames[0].header.path).toBe("result.json"); + expect(frames[0].payload.equals(payload)).toBe(true); + }); + + it("the two end frames are byte-identical", () => { + expect(server.END_FRAME.equals(companion.END_FRAME)).toBe(true); + }); +}); + +describe("frame reader", () => { + const body = Buffer.from("0123456789", "utf8"); + const stream = Buffer.concat([ + server.encodeFrame({ kind: "manifest", bytes: body.length }, body), + server.END_FRAME, + ]); + + it("reassembles a frame split across arbitrary chunk boundaries", () => { + for (let split = 1; split < stream.length; split += 1) { + const frames: Buffer[] = []; + let parts: Buffer[] = []; + const reader = new server.FrameReader({ + onHeader: () => { parts = []; }, + onPayload: (chunk) => { parts.push(chunk); }, + onFrameEnd: (header) => { if (header.kind !== "end") frames.push(Buffer.concat(parts)); }, + }); + reader.push(stream.subarray(0, split)); + reader.push(stream.subarray(split)); + reader.end(); + expect(frames[0].toString("utf8")).toBe("0123456789"); + } + }); + + it("refuses a stream that ends mid-frame", () => { + const reader = new server.FrameReader({ onHeader: () => {}, onPayload: () => {}, onFrameEnd: () => {} }); + reader.push(stream.subarray(0, stream.length - 4)); + expect(() => reader.end()).toThrow(/before its end frame/); + }); + + it("refuses bytes after the end frame", () => { + const reader = new server.FrameReader({ onHeader: () => {}, onPayload: () => {}, onFrameEnd: () => {} }); + expect(() => reader.push(Buffer.concat([stream, Buffer.from("extra")]))).toThrow(/past its end frame/); + }); + + it("refuses an oversized header length before allocating anything", () => { + const prefix = Buffer.alloc(4); + prefix.writeUInt32BE(server.MAX_FRAME_HEADER_BYTES + 1, 0); + const reader = new server.FrameReader({ onHeader: () => {}, onPayload: () => {}, onFrameEnd: () => {} }); + expect(() => reader.push(prefix)).toThrow(/header length is out of range/); + }); + + it("refuses a file frame with no digest", () => { + expect(() => server.encodeFrame({ kind: "file", bytes: 0, path: "a" })).toThrow(/path and a digest/); + }); +}); + +// The companion also duplicates the manifest rules and the capability builder. +// Same contract, same reason: a control plane that has been tampered with must +// not be able to hand the worker a boundary the worker would not derive itself. +describe("manifest and capability parity", () => { + const document: JsonValue = JSON.parse(workerTaskManifestJson(parsedManifest())); + const digest = workerTaskManifestDigest(parsedManifest()); + + it("both ends compute the same manifest digest", () => { + const staged = parseStagedManifest(document, digest); + expect(staged.taskId).toBe("task-1"); + }); + + it("the companion refuses a staged manifest that does not match the approved digest", () => { + expect(() => parseStagedManifest(document, "f".repeat(64))).toThrow(/does not match the approved digest/); + }); + + it("both ends derive a byte-identical capability", () => { + const manifest = parsedManifest(); + const root = fakeTaskRoot(HOST_TASK_PLATFORM, manifest.taskId); + const staged = parseStagedManifest(document, digest); + expect(taskCapabilityManifest(staged, root, TASK_ISSUED)) + .toBe(workerCuaCapabilityManifest(manifest, root, TASK_ISSUED)); + }); + + it("a browser task derives the same capability on both ends too", () => { + const overrides = { surface: "browser", origins: ["https://example.com"] }; + const manifest = parsedManifest(HOST_TASK_PLATFORM, overrides); + const browserDocument: JsonValue = JSON.parse(workerTaskManifestJson(manifest)); + const staged = parseStagedManifest(browserDocument, workerTaskManifestDigest(manifest)); + const root = fakeTaskRoot(HOST_TASK_PLATFORM, manifest.taskId); + expect(workerCuaCapabilityDigest(taskCapabilityManifest(staged, root, TASK_ISSUED))) + .toBe(workerCuaCapabilityDigest(workerCuaCapabilityManifest(manifest, root, TASK_ISSUED))); + }); + + it.each([ + ["macos", "/bin/sh"], + ["macos", "/usr/bin/osascript"], + ["macos", "/usr/bin/open"], + ["windows", "C:\\Windows\\System32\\cmd.exe"], + ["windows", "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"], + ] as const)("both ends reject %s executable %s", (platform, executable) => { + // The server's own refusal is covered in worker-task-manifest.test.ts; this + // asserts the companion's copy of the rule agrees, which is the half that + // would silently drift. + expect(PLATFORM_PROFILES[platform].blockedExecutable.test(executable)).toBe(true); + }); + + it("both ends still allow an ordinary build binary", () => { + expect(PLATFORM_PROFILES.macos.blockedExecutable.test("/opt/homebrew/bin/just")).toBe(false); + expect(PLATFORM_PROFILES.windows.blockedExecutable.test("C:\\tools\\build.exe")).toBe(false); + }); +}); + +const TASK_ISSUED = 1_800_000_000_000; + +// Manifest documents are staged as bytes and re-parsed on the worker, so the +// document the control plane sends has to survive that round trip unchanged. +describe("staged manifest round trip", () => { + it("survives serialisation with its digest intact", () => { + const manifest = parsedManifest(); + const bytes = Buffer.from(workerTaskManifestJson(manifest), "utf8"); + const reparsed: JsonValue = JSON.parse(bytes.toString("utf8")); + expect(parseStagedManifest(reparsed, workerTaskManifestDigest(manifest)).commands[0].id).toBe("build"); + }); + + it("a manifest fixture is a plain JSON document", () => { + expect(() => JSON.parse(JSON.stringify(manifestFixture()))).not.toThrow(); + }); +}); diff --git a/server/worker-task-frames.ts b/server/worker-task-frames.ts new file mode 100644 index 00000000..9e4b087b --- /dev/null +++ b/server/worker-task-frames.ts @@ -0,0 +1,147 @@ +// Length-prefixed framing for the staging and result streams. +// +// Staged task files are binary and can total 200 MB, so they ride a raw stream +// on the worker companion's stdin rather than the line-oriented JSON wire the +// companion's other operations use. One frame is a 4-byte big-endian header +// length, a JSON header, then exactly the payload bytes that header declares — +// no delimiter a payload could forge, and no base64 inflation against the size +// ceilings. +// +// worker-companion/src/frames.ts is the other end of this format. They are +// duplicated rather than imported because the companion ships to the worker as +// a standalone package with no view of this tree; server/worker-task-frames +// .test.ts drives one against the other so they cannot drift. +import { Buffer } from "node:buffer"; + +export const FRAME_HEADER_PREFIX_BYTES = 4; +export const MAX_FRAME_HEADER_BYTES = 4096; +export const MAX_FRAME_PAYLOAD_BYTES = 50 * 1024 * 1024; + +export type FrameKind = "manifest" | "file" | "end"; + +export interface FrameHeader { + kind: FrameKind; + /** Exact payload length following this header. `end` always carries 0. */ + bytes: number; + /** Present only on `file`: the manifest-relative path being staged. */ + path?: string; + /** Present only on `file`: the digest the payload must hash to. */ + sha256?: string; +} + +function assertHeader(header: FrameHeader): void { + if (!["manifest", "file", "end"].includes(header.kind)) throw new Error("unknown frame kind"); + if (!Number.isSafeInteger(header.bytes) || header.bytes < 0 || header.bytes > MAX_FRAME_PAYLOAD_BYTES) { + throw new Error("frame payload length is out of range"); + } + if (header.kind === "end" && header.bytes !== 0) throw new Error("end frame cannot carry a payload"); + if (header.kind === "file" && (!header.path || !header.sha256)) { + throw new Error("file frame needs a path and a digest"); + } +} + +export function encodeFrameHeader(header: FrameHeader): Buffer { + assertHeader(header); + const json = Buffer.from(JSON.stringify(header), "utf8"); + if (json.length > MAX_FRAME_HEADER_BYTES) throw new Error("frame header is too large"); + const prefix = Buffer.alloc(FRAME_HEADER_PREFIX_BYTES); + prefix.writeUInt32BE(json.length, 0); + return Buffer.concat([prefix, json]); +} + +export function encodeFrame(header: FrameHeader, payload: Buffer = Buffer.alloc(0)): Buffer { + if (payload.length !== header.bytes) throw new Error("frame payload length does not match its header"); + return Buffer.concat([encodeFrameHeader(header), payload]); +} + +export const END_FRAME = encodeFrame({ kind: "end", bytes: 0 }); + +export interface FrameHandlers { + /** A header has been read; its payload follows in zero or more chunks. */ + onHeader: (header: FrameHeader) => void; + /** A slice of the current frame's payload, in order. */ + onPayload: (chunk: Buffer) => void; + /** The current frame's payload is complete. */ + onFrameEnd: (header: FrameHeader) => void; +} + +/** Incremental reader. Payload chunks are handed straight through so a 50 MB + * file is written to disk as it arrives rather than held whole in memory. */ +export class FrameReader { + private pending: Buffer = Buffer.alloc(0); + private header: FrameHeader | null = null; + private remaining = 0; + private finished = false; + + private readonly handlers: FrameHandlers; + + // Assigned in the body, not as a constructor parameter property: the + // packaged server runs under Node's strip-only TypeScript mode, which + // rejects `constructor(private readonly x: T)`. tsc and vitest both + // transpile, so only booting the real server catches it. + constructor(handlers: FrameHandlers) { + this.handlers = handlers; + } + + /** True once an `end` frame has been read; further bytes are an error. */ + get done(): boolean { + return this.finished; + } + + push(chunk: Buffer): void { + if (this.finished) throw new Error("frame stream continued past its end frame"); + this.pending = this.pending.length === 0 ? chunk : Buffer.concat([this.pending, chunk]); + for (;;) { + if (this.header === null) { + if (this.pending.length < FRAME_HEADER_PREFIX_BYTES) return; + const length = this.pending.readUInt32BE(0); + if (length === 0 || length > MAX_FRAME_HEADER_BYTES) throw new Error("frame header length is out of range"); + if (this.pending.length < FRAME_HEADER_PREFIX_BYTES + length) return; + const json = this.pending.subarray(FRAME_HEADER_PREFIX_BYTES, FRAME_HEADER_PREFIX_BYTES + length).toString("utf8"); + this.pending = this.pending.subarray(FRAME_HEADER_PREFIX_BYTES + length); + this.header = parseFrameHeader(json); + this.remaining = this.header.bytes; + this.handlers.onHeader(this.header); + } + if (this.remaining > 0) { + if (this.pending.length === 0) return; + const take = Math.min(this.remaining, this.pending.length); + this.handlers.onPayload(this.pending.subarray(0, take)); + this.pending = this.pending.subarray(take); + this.remaining -= take; + if (this.remaining > 0) return; + } + const complete = this.header; + this.header = null; + this.handlers.onFrameEnd(complete); + if (complete.kind === "end") { + this.finished = true; + if (this.pending.length > 0) throw new Error("frame stream continued past its end frame"); + return; + } + } + } + + /** Called when the source stream closes. A stream that stopped mid-frame, or + * before its end frame, is truncated — never silently accept it. */ + end(): void { + if (!this.finished) throw new Error("frame stream ended before its end frame"); + } +} + +function parseFrameHeader(json: string): FrameHeader { + let value: unknown; + try { + value = JSON.parse(json); + } catch { + throw new Error("frame header is not JSON"); + } + if (value === null || !(value instanceof Object) || Array.isArray(value)) { + throw new Error("frame header must be an object"); + } + // SAFETY: shape is checked field by field in assertHeader below, which + // rejects anything this cast would otherwise let through. + const header = value as FrameHeader; + assertHeader(header); + return header; +} diff --git a/server/worker-task-service.test.ts b/server/worker-task-service.test.ts new file mode 100644 index 00000000..1a47db3f --- /dev/null +++ b/server/worker-task-service.test.ts @@ -0,0 +1,302 @@ +// The authority chain end to end, against a fake worker: propose → approve → +// stage → validate → activate, then run and read back. No SSH, no daemon — +// #508 acceptance item 8's fake-worker protocol, which is what CI can actually +// prove on all three platforms. +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { DATA_DIR } from "./config.ts"; +import type { ModelSelection } from "./contracts.ts"; +import type { JsonValue } from "./schema.ts"; +import { Store, type BotRecord } from "./store.ts"; +import { + fakeTaskRoot, + HOST_TASK_PLATFORM, + manifestFixture, + parsedManifest, + TASK_NOW, + workerFixture, +} from "./testing/worker-task.ts"; +import { workerCuaCapabilityDigest, workerCuaCapabilityManifest } from "./worker-cua-capability.ts"; +import { + cancelWorkerTaskApprovalsForWorker, + resolveWorkerTaskApproval, +} from "./worker-task-approval.ts"; +import { encodeFrame, END_FRAME } from "./worker-task-frames.ts"; +import { workerTaskManifestDigest, WorkerTaskRegistry } from "./worker-task-manifest.ts"; +import { WorkerTaskService } from "./worker-task-service.ts"; +import type { WorkerTaskStreamOptions } from "./worker-task-transport.ts"; + +const selection = (): ModelSelection => ({ instanceId: "claude", model: "fake-model" }); +const worker = workerFixture(); +const taskRoot = fakeTaskRoot(HOST_TASK_PLATFORM, "task-1"); + +// A manifest is bound to one conversation, and a test bot's thread id is +// generated, so these are rebuilt per test rather than at module scope. +let digest = ""; +let capability = ""; + +let store: Store; +let bot: BotRecord; +let registry: WorkerTaskRegistry; +let cwd = ""; +/** Every companion op the fake worker was asked to perform, in order. */ +let ops: string[] = []; + +/** A worker that behaves. Individual tests override one reply to misbehave. */ +function fakeWorker(overrides: Record = {}) { + const runner = (_args: string[], _timeoutMs?: number, stdin = "") => { + const request = JSON.parse(stdin || "{}"); + ops.push(String(request.op)); + const canned: Record = { + validate: { ok: true, version: 1, op: "validate", taskRoot, files: 0, commandIds: ["build"] }, + activate: { ok: true, version: 1, op: "activate", capabilitySha256: capability }, + reset: { ok: true, version: 1, op: "reset", capabilitySha256: "d".repeat(64) }, + run: { ok: true, version: 1, op: "run", commandId: "build", code: 0, stdout: "built", stderr: "" }, + ...overrides, + }; + return Promise.resolve({ stdout: `${JSON.stringify(canned[String(request.op)])}\n`, stderr: "" }); + }; + const streamRunner = async (args: string[], options: WorkerTaskStreamOptions) => { + ops.push(args.includes("fetch") ? "fetch" : "stage"); + if (options.write) { + // A sink that swallows the staged bytes: what is staged is the transport + // test's subject, not this one's. + const sink = new PassThrough(); + sink.resume(); + await options.write(sink); + sink.end(); + } + if (args.includes("fetch")) { + const body = Buffer.from('{"ok":true}', "utf8"); + const sha256 = createHash("sha256").update(body).digest("hex"); + return { + stdout: Buffer.concat([ + encodeFrame({ kind: "file", bytes: body.length, path: "result.json", sha256 }, body), + END_FRAME, + ]), + stderr: "", + }; + } + return { stdout: Buffer.from('{"ok":true,"version":1,"op":"stage","files":0}\n', "utf8"), stderr: "" }; + }; + return { runner, streamRunner }; +} + +function makeService(overrides: Record = {}): WorkerTaskService { + const fake = fakeWorker(overrides); + return new WorkerTaskService({ + bus: { store, broadcast: () => {} }, + registry, + workerFor: () => worker, + runner: fake.runner, + streamRunner: fake.streamRunner, + now: () => TASK_NOW, + }); +} + +/** Answer the approval card as soon as it appears. */ +async function answer(behavior: "allow" | "deny"): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const card = store.messagesFor(bot.threadId).find((message) => message.card?.tool?.startsWith("worker_task:")); + const requestId = card?.card?.requestId; + if (requestId && !card?.card?.answered && resolveWorkerTaskApproval(requestId, behavior)) return; + await new Promise((wait) => setTimeout(wait, 5)); + } + throw new Error("no approval card appeared"); +} + +const propose = (document?: JsonValue) => + ({ op: "propose", manifest: document ?? manifestFixture(HOST_TASK_PLATFORM, { threadId: bot.threadId }) }) as JsonValue; + +beforeEach(() => { + rmSync(DATA_DIR, { recursive: true, force: true }); + store = new Store(selection); + registry = new WorkerTaskRegistry(); + ops = []; + cwd = mkdtempSync(join(tmpdir(), "omb-task-")); + bot = store.createBot(); + store.patchBot(bot.id, { cwd, workerId: worker.id }); + bot = store.bot(bot.id)!; + + const manifest = parsedManifest(HOST_TASK_PLATFORM, { threadId: bot.threadId }); + digest = workerTaskManifestDigest(manifest); + capability = workerCuaCapabilityDigest(workerCuaCapabilityManifest(manifest, taskRoot, TASK_NOW)); +}); + +afterEach(() => { + cancelWorkerTaskApprovalsForWorker(worker.id); + rmSync(cwd, { recursive: true, force: true }); +}); + +describe("propose", () => { + it("stages, validates and activates only after a person allows", async () => { + const service = makeService(); + const pending = service.handle(bot, propose()); + await answer("allow"); + const outcome = await pending; + + expect(outcome.status).toBe(200); + expect(outcome.text).toContain("Approved and active"); + expect(outcome.text).toContain(capability.slice(0, 12)); + // Order matters: nothing reaches the worker before the person answers, and + // the capability is activated only after the worker has re-validated. + expect(ops).toEqual(["stage", "validate", "activate"]); + }); + + it("touches the worker not at all when the person denies", async () => { + const service = makeService(); + const pending = service.handle(bot, propose()); + await answer("deny"); + const outcome = await pending; + + expect(outcome.text).toContain("denied"); + expect(ops).toEqual([]); + }); + + it("refuses a manifest bound to another conversation", async () => { + const service = makeService(); + const elsewhere = manifestFixture(HOST_TASK_PLATFORM, { threadId: "another-thread" }); + const outcome = await service.handle(bot, propose(elsewhere)); + expect(outcome.status).toBe(409); + expect(outcome.error).toMatch(/different conversation/); + expect(ops).toEqual([]); + }); + + it("refuses a bot with no working folder rather than staging from the home directory", async () => { + store.patchBot(bot.id, { cwd: undefined }); + const service = makeService(); + const outcome = await service.handle(store.bot(bot.id)!, propose()); + expect(outcome.status).toBe(409); + expect(outcome.error).toMatch(/no working folder/); + }); + + it("refuses a bot with no worker assigned", async () => { + const service = new WorkerTaskService({ + bus: { store, broadcast: () => {} }, + registry, + workerFor: () => null, + now: () => TASK_NOW, + }); + const outcome = await service.handle(bot, propose()); + expect(outcome.status).toBe(409); + expect(outcome.error).toMatch(/not assigned/); + }); + + it("surfaces an invalid manifest as a refusal, not a crash", async () => { + const service = makeService(); + const outcome = await service.handle(bot, propose({ version: 1 } as JsonValue)); + expect(outcome.status).toBe(409); + expect(outcome.error).toMatch(/Invalid worker task manifest/); + }); + + it("refuses when the worker activates a capability the control plane did not derive", async () => { + const service = makeService({ + activate: { ok: true, version: 1, op: "activate", capabilitySha256: "e".repeat(64) }, + }); + const pending = service.handle(bot, propose()); + await answer("allow"); + const outcome = await pending; + expect(outcome.status).toBe(409); + expect(outcome.error).toMatch(/different capability/); + }); +}); + +describe("run and results", () => { + async function approved(overrides: Record = {}): Promise { + const service = makeService(overrides); + const pending = service.handle(bot, propose()); + await answer("allow"); + await pending; + ops = []; + return service; + } + + it("runs an approved command by id", async () => { + const service = await approved(); + const outcome = await service.handle(bot, { op: "run", commandId: "build" } as JsonValue); + expect(outcome.text).toContain("build exited 0"); + expect(outcome.text).toContain("built"); + expect(ops).toEqual(["run"]); + }); + + it("refuses a command id the approved manifest does not contain", async () => { + const service = await approved(); + const outcome = await service.handle(bot, { op: "run", commandId: "deploy" } as JsonValue); + expect(outcome.status).toBe(409); + expect(outcome.error).toMatch(/no command with that id/); + }); + + it("reads back the declared artefacts", async () => { + const service = await approved(); + const outcome = await service.handle(bot, { op: "results" } as JsonValue); + expect(outcome.text).toContain("result.json"); + expect(outcome.text).toContain('{"ok":true}'); + }); + + it("refuses to run once the worker's approvals are revoked", async () => { + const service = await approved(); + // What happens when a worker drops off: #508 item 6. + service.forgetWorker(worker.id); + const outcome = await service.handle(bot, { op: "run", commandId: "build" } as JsonValue); + expect(outcome.status).toBe(409); + expect(outcome.error).toMatch(/no longer approved/); + expect(ops).toEqual([]); + }); + + it("leaves another worker's approval alone when one worker is forgotten", async () => { + const service = await approved(); + service.forgetWorker("some-other-worker"); + const outcome = await service.handle(bot, { op: "run", commandId: "build" } as JsonValue); + expect(outcome.status).toBe(200); + }); + + it("refuses to run a task that was never proposed", async () => { + const service = makeService(); + const outcome = await service.handle(bot, { op: "run", commandId: "build" } as JsonValue); + expect(outcome.status).toBe(409); + expect(outcome.error).toMatch(/no worker task has been proposed/); + }); + + it("refuses an unknown operation", async () => { + const service = makeService(); + const outcome = await service.handle(bot, { op: "sudo" } as JsonValue); + expect(outcome.status).toBe(400); + }); +}); + +describe("status", () => { + it("reports nothing before anything is proposed", async () => { + const outcome = await makeService().handle(bot, { op: "status" } as JsonValue); + expect(outcome.text).toContain("No worker task"); + }); + + it("reports the live approval and never mints one", async () => { + const service = makeService(); + const pending = service.handle(bot, propose()); + await answer("allow"); + await pending; + ops = []; + + const outcome = await service.handle(bot, { op: "status" } as JsonValue); + expect(outcome.text).toContain(digest.slice(0, 12)); + expect(outcome.text).toContain("build"); + expect(ops).toEqual([]); + }); + + it("says so once the approval has been revoked", async () => { + const service = makeService(); + const pending = service.handle(bot, propose()); + await answer("allow"); + await pending; + service.forgetWorker(worker.id); + + const outcome = await service.handle(bot, { op: "status" } as JsonValue); + expect(outcome.text).toMatch(/no longer approved/); + }); +}); diff --git a/server/worker-task-service.ts b/server/worker-task-service.ts new file mode 100644 index 00000000..363a9823 --- /dev/null +++ b/server/worker-task-service.ts @@ -0,0 +1,282 @@ +// The authority chain behind the four worker task tools. +// +// The MCP bridge that exposes those tools runs as a separate per-turn process +// with no view of the registry, the approval card, or SSH. It calls the +// harness's loopback endpoint, and the endpoint calls this. Everything that +// decides anything lives here: +// +// propose parse and bind the manifest → register it → ask a person → +// stage → validate → activate. Only then is the worker unbounded. +// status what is approved for this conversation, and for how long +// run one command id out of the approved document +// results the artefacts that document declared, and nothing else +// +// A restart forgets every approval on purpose: `WorkerTaskRegistry` keeps them +// in memory, and so does the activation record below. A manifest survives as +// data; permission to execute it does not. +import { z } from "zod"; + +import type { ResolvedWorker } from "./computer-workers.ts"; +import { type JsonValue, parseJson, schemaIssue } from "./schema.ts"; +import type { BotRecord } from "./store.ts"; +import type { RemoteWorkerSshRunner } from "./remote-worker.ts"; +import { defaultRemoteWorkerRunner } from "./remote-worker.ts"; +import { + cancelWorkerTaskApproval, + requestWorkerTaskApproval, + type WorkerApprovalBus, +} from "./worker-task-approval.ts"; +import { + parseWorkerTaskManifest, + workerTaskManifestDigest, + type WorkerTaskManifest, + WorkerTaskRegistry, +} from "./worker-task-manifest.ts"; +import { + activateWorkerTask, + defaultWorkerTaskStreamRunner, + fetchWorkerResults, + resetWorkerTask, + runWorkerCommand, + stageWorkerTask, + validateWorkerTask, + type WorkerTaskStreamRunner, +} from "./worker-task-transport.ts"; + +/** Per artefact, so a large diff cannot flood a turn's context. */ +const MAX_ARTEFACT_CHARS = 64 * 1024; + +const requestSchema = z.discriminatedUnion("op", [ + z.object({ op: z.literal("propose"), manifest: z.json() }), + z.object({ op: z.literal("status") }), + z.object({ op: z.literal("run"), commandId: z.string().max(128) }), + z.object({ op: z.literal("results") }), +]); + +/** What activation produced, kept beside the registry's approval. Memory only, + * for the same reason: a restart must not leave a worker looking unlocked. */ +interface Activation { + taskRoot: string; + capabilitySha256: string; +} + +export interface WorkerTaskServiceDeps { + bus: WorkerApprovalBus; + registry: WorkerTaskRegistry; + /** The worker this bot is assigned to, or null when it has none. */ + workerFor: (bot: BotRecord) => ResolvedWorker | null; + runner?: RemoteWorkerSshRunner; + streamRunner?: WorkerTaskStreamRunner; + now?: () => number; +} + +/** One task whose approval is live right now, re-checked at the moment of use. */ +interface ApprovedTask { + manifest: WorkerTaskManifest; + digest: string; +} + +export interface WorkerTaskOutcome { + status: number; + text?: string; + error?: string; +} + +export class WorkerTaskService { + private readonly activations = new Map(); + private readonly deps: WorkerTaskServiceDeps; + + // Assigned in the body rather than declared as a constructor parameter + // property: the packaged server runs under Node's strip-only TypeScript mode, + // which rejects `constructor(private readonly x: T)` outright. `tsc` and + // vitest both transpile, so neither notices — only booting the real server + // does. + constructor(deps: WorkerTaskServiceDeps) { + this.deps = deps; + } + + private get runner(): RemoteWorkerSshRunner { + return this.deps.runner ?? defaultRemoteWorkerRunner; + } + + private get streamRunner(): WorkerTaskStreamRunner { + return this.deps.streamRunner ?? defaultWorkerTaskStreamRunner; + } + + private now(): number { + return this.deps.now ? this.deps.now() : Date.now(); + } + + /** Forget one worker's activations without touching the other's. Paired with + * `WorkerTaskRegistry.revokeWorker` for #508 item 6. */ + forgetWorker(workerId: string): void { + for (const record of this.deps.registry.forWorker(workerId)) { + this.activations.delete(record.manifest.taskId); + cancelWorkerTaskApproval(record.manifest.taskId); + } + this.deps.registry.revokeWorker(workerId); + } + + async handle(bot: BotRecord, body: JsonValue): Promise { + const parsed = requestSchema.safeParse(body); + if (!parsed.success) { + return { status: 400, error: schemaIssue(parsed.error, "unknown worker task operation") }; + } + const worker = this.deps.workerFor(bot); + if (!worker) return { status: 409, error: "this bot is not assigned to a configured worker" }; + + try { + switch (parsed.data.op) { + case "propose": + return await this.propose(bot, worker, parsed.data.manifest); + case "status": + return this.status(bot); + case "run": + return await this.run(bot, worker, parsed.data.commandId); + case "results": + return await this.results(bot, worker); + } + } catch (error) { + // A failed task is a normal outcome for the model to read and react to, + // not a transport fault: 200 with an explanatory body would hide it, and + // a 500 would read as "OpenMausBot broke". 409 says "this did not run". + return { status: 409, error: error instanceof Error ? error.message : String(error) }; + } + } + + private async propose(bot: BotRecord, worker: ResolvedWorker, raw: JsonValue): Promise { + if (!bot.cwd) { + return { + status: 409, + error: + "this bot has no working folder, so there is nothing to stage — " + + "set one in the bot's settings before proposing a worker task", + }; + } + // Re-serialized and re-parsed so `parseWorkerTaskManifest` sees a plain JSON + // value and never a live object carrying getters or a prototype. + const document: JsonValue = parseJson(JSON.stringify(raw ?? null)); + const manifest = parseWorkerTaskManifest(document, worker, this.now()); + if (manifest.threadId !== bot.threadId) { + throw new Error("the task manifest names a different conversation"); + } + const digest = workerTaskManifestDigest(manifest); + this.deps.registry.register(manifest); + + const verdict = await requestWorkerTaskApproval(this.deps.bus, worker, manifest, digest, bot.threadId); + if (verdict !== "allow") { + return { status: 200, text: "The person denied this task. Nothing was staged, activated or run." }; + } + if (!this.deps.registry.approve(manifest.taskId, digest, this.now())) { + throw new Error("the task expired before it was approved"); + } + + await stageWorkerTask(worker, bot.cwd, manifest, this.streamRunner); + const validated = await validateWorkerTask(worker, manifest, digest, this.runner); + const activated = await activateWorkerTask( + worker, + manifest, + digest, + validated.taskRoot, + this.runner, + this.now(), + ); + this.activations.set(manifest.taskId, { + taskRoot: validated.taskRoot, + capabilitySha256: activated.capabilitySha256, + }); + + return { + status: 200, + text: [ + `Approved and active on ${worker.displayName}.`, + `Task ${manifest.taskId} · manifest ${digest.slice(0, 12)} · capability ${activated.capabilitySha256.slice(0, 12)}`, + `Staged ${validated.files} files. Commands you may run: ${validated.commandIds.join(", ")}.`, + `The ${manifest.surface} capability is now live; it expires at ` + + `${new Date(manifest.expiresAt).toISOString()} or after ${ + Math.round(manifest.idleTimeoutMs / 60_000) + } idle minutes, whichever comes first.`, + ].join("\n"), + }; + } + + /** The current task and its live approval, or an explanation of why there is + * none. Never mints an approval as a side effect of being asked. */ + private status(bot: BotRecord): WorkerTaskOutcome { + const record = this.deps.registry.forThread(bot.threadId); + if (!record) return { status: 200, text: "No worker task has been proposed in this conversation." }; + const live = this.deps.registry.approved(record.manifest.taskId, record.digest, this.now()); + if (!live) { + return { + status: 200, + text: + `Task ${record.manifest.taskId} is no longer approved — it expired, idled out, or the worker went offline. ` + + "Propose it again to run anything.", + }; + } + const activation = this.activations.get(record.manifest.taskId); + const remaining = Math.max(0, Math.round((live.manifest.expiresAt - this.now()) / 60_000)); + return { + status: 200, + text: [ + `Task ${live.manifest.taskId} · manifest ${live.digest.slice(0, 12)} · ${live.manifest.surface}`, + `Capability ${activation?.capabilitySha256.slice(0, 12) ?? "not activated"} · about ${remaining} min left`, + `Commands: ${live.manifest.commands.map((command) => command.id).join(", ")}`, + ].join("\n"), + }; + } + + /** Every call re-checks the approval rather than trusting the one taken at + * propose time: the idle fence only means something if it is read again. */ + private approvedFor(bot: BotRecord): ApprovedTask { + const record = this.deps.registry.forThread(bot.threadId); + if (!record) throw new Error("no worker task has been proposed in this conversation"); + const live = this.deps.registry.approved(record.manifest.taskId, record.digest, this.now()); + if (!live) throw new Error("this task is no longer approved — propose it again"); + if (!this.activations.has(live.manifest.taskId)) throw new Error("this task was never activated on the worker"); + return { manifest: live.manifest, digest: live.digest }; + } + + private async run(bot: BotRecord, worker: ResolvedWorker, commandId: string): Promise { + const { manifest, digest } = this.approvedFor(bot); + const result = await runWorkerCommand(worker, manifest, digest, commandId, this.runner); + const body = [ + `${commandId} exited ${result.code ?? "without a status"}`, + result.stdout.trim() ? `stdout:\n${result.stdout.trim()}` : "stdout: (empty)", + result.stderr.trim() ? `stderr:\n${result.stderr.trim()}` : "", + ].filter(Boolean); + return { status: 200, text: body.join("\n\n") }; + } + + private async results(bot: BotRecord, worker: ResolvedWorker): Promise { + const { manifest, digest } = this.approvedFor(bot); + const artefacts = await fetchWorkerResults(worker, manifest, digest, this.streamRunner); + if (artefacts.length === 0) { + return { status: 200, text: "The task has not produced any of its declared result artefacts yet." }; + } + const sections = artefacts.map((artefact) => { + const text = artefact.content.toString("utf8"); + const shown = text.length > MAX_ARTEFACT_CHARS + ? `${text.slice(0, MAX_ARTEFACT_CHARS)}\n… truncated at ${MAX_ARTEFACT_CHARS} characters` + : text; + return `── ${artefact.path} (${artefact.content.length} bytes)\n${shown}`; + }); + return { status: 200, text: sections.join("\n\n") }; + } + + /** Wipe a task off its worker and put the worker back on its deny-all + * capability. Best effort by design: it runs on teardown paths where the + * worker may already be unreachable, and a failure there must not stop the + * local state from being dropped. */ + async release(worker: ResolvedWorker, taskId: string): Promise { + this.activations.delete(taskId); + this.deps.registry.revoke(taskId); + cancelWorkerTaskApproval(taskId); + try { + await resetWorkerTask(worker, taskId, this.runner); + } catch { + // Unreachable worker: the approval is already gone locally, and the + // worker's own idle timeout expires the capability on its side. + } + } +} diff --git a/server/worker-task-transport.test.ts b/server/worker-task-transport.test.ts new file mode 100644 index 00000000..92229688 --- /dev/null +++ b/server/worker-task-transport.test.ts @@ -0,0 +1,333 @@ +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough, type Writable } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { JsonValue } from "./schema.ts"; +import { + fakeTaskRoot, + HOST_TASK_PLATFORM, + parsedManifest, + TASK_NOW, + workerFixture, +} from "./testing/worker-task.ts"; +import { workerCuaCapabilityDigest, workerCuaCapabilityManifest } from "./worker-cua-capability.ts"; +import { encodeFrame, END_FRAME } from "./worker-task-frames.ts"; +import { workerTaskManifestDigest, type WorkerTaskManifest } from "./worker-task-manifest.ts"; +import { + activateWorkerTask, + fetchWorkerResults, + isPlausibleTaskRoot, + resetWorkerTask, + runWorkerCommand, + stageWorkerTask, + validateWorkerTask, + type WorkerTaskStreamOptions, +} from "./worker-task-transport.ts"; + +const worker = workerFixture(); +const taskRoot = fakeTaskRoot(HOST_TASK_PLATFORM, "task-1"); + +/** Captures the argv and stdin every call would have sent, and replies with + * whatever the test lines up. Nothing here reaches a network or a shell. */ +function fakeCompanion(replies: JsonValue[]) { + const calls: { args: string[]; stdin: string }[] = []; + let next = 0; + const runner = (args: string[], _timeoutMs?: number, stdin = "") => { + calls.push({ args, stdin }); + const reply = replies[Math.min(next, replies.length - 1)]; + next += 1; + return Promise.resolve({ stdout: `${JSON.stringify(reply)}\n`, stderr: "" }); + }; + return { runner, calls }; +} + +/** Collects the bytes a streaming call would have written to ssh's stdin. */ +function fakeStream(reply: JsonValue | Buffer) { + const captured: Buffer[] = []; + const calls: string[][] = []; + const runner = async (args: string[], options: WorkerTaskStreamOptions) => { + calls.push(args); + if (options.write) { + const sink = new PassThrough(); + sink.on("data", (chunk: Buffer) => captured.push(chunk)); + await options.write(sink as unknown as Writable); + sink.end(); + } + return { + stdout: Buffer.isBuffer(reply) ? reply : Buffer.from(`${JSON.stringify(reply)}\n`, "utf8"), + stderr: "", + }; + }; + return { runner, calls, bytes: () => Buffer.concat(captured) }; +} + +describe("isPlausibleTaskRoot", () => { + it("accepts the shape each platform's companion actually derives", () => { + expect(isPlausibleTaskRoot("macos", "task-1", fakeTaskRoot("macos", "task-1"))).toBe(true); + expect(isPlausibleTaskRoot("windows", "task-1", fakeTaskRoot("windows", "task-1"))).toBe(true); + }); + + it.each([ + ["a root for another task", "/Users/worker/Library/Application Support/OpenMausBot/tasks/other"], + ["a traversal", "/Users/worker/../../tasks/task-1"], + ["a relative path", "tasks/task-1"], + ["a Windows root on macOS", "C:\\tasks\\task-1"], + ])("refuses %s", (_label, value) => { + expect(isPlausibleTaskRoot("macos", "task-1", value)).toBe(false); + }); + + it("refuses a root that is not under a tasks directory", () => { + expect(isPlausibleTaskRoot("macos", "task-1", "/task-1")).toBe(false); + expect(isPlausibleTaskRoot("windows", "task-1", "C:\\task-1")).toBe(false); + }); +}); + +describe("staging", () => { + let root = ""; + let manifest: WorkerTaskManifest; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "omb-stage-")); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(join(root, "src", "main.txt"), "hello worker"); + const body = Buffer.from("hello worker"); + manifest = parsedManifest(HOST_TASK_PLATFORM, { + files: [{ + path: "src/main.txt", + size: body.length, + sha256: createHash("sha256").update(body).digest("hex"), + }], + }); + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + it("sends the manifest first, then each file, then an end frame", async () => { + const stream = fakeStream({ ok: true, version: 1, op: "stage", files: 1 }); + const result = await stageWorkerTask(worker, root, manifest, stream.runner); + expect(result.files).toBe(1); + expect(stream.calls[0].slice(-3)).toEqual(["openmausbot-worker-companion", "stage", "task-1"]); + + const bytes = stream.bytes(); + expect(bytes.subarray(bytes.length - END_FRAME.length).equals(END_FRAME)).toBe(true); + expect(bytes.includes(Buffer.from("hello worker"))).toBe(true); + // The manifest travels ahead of the files it describes, so the worker can + // never write a byte it has not already been told the digest of. + expect(bytes.indexOf(Buffer.from('"manifest"'))).toBeLessThan(bytes.indexOf(Buffer.from("hello worker"))); + }); + + it("refuses to stage when a file changed after approval", async () => { + writeFileSync(join(root, "src", "main.txt"), "tampered"); + const stream = fakeStream({ ok: true, version: 1, op: "stage", files: 1 }); + await expect(stageWorkerTask(worker, root, manifest, stream.runner)).rejects.toThrow(/size changed|hash changed/); + expect(stream.calls).toHaveLength(0); + }); + + it("refuses to stage a symlinked file", async () => { + rmSync(join(root, "src", "main.txt")); + symlinkSync(join(root, "elsewhere.txt"), join(root, "src", "main.txt")); + writeFileSync(join(root, "elsewhere.txt"), "hello worker"); + const stream = fakeStream({ ok: true, version: 1, op: "stage", files: 1 }); + await expect(stageWorkerTask(worker, root, manifest, stream.runner)).rejects.toThrow(/not a regular file/); + }); + + it("refuses a worker that confirms a different number of files", async () => { + const stream = fakeStream({ ok: true, version: 1, op: "stage", files: 4 }); + await expect(stageWorkerTask(worker, root, manifest, stream.runner)) + .rejects.toThrow(/different number of files/); + }); + + it("surfaces the companion's own refusal verbatim", async () => { + const stream = fakeStream({ ok: false, error: "unsafe staged path: ../escape" }); + await expect(stageWorkerTask(worker, root, manifest, stream.runner)) + .rejects.toThrow("unsafe staged path: ../escape"); + }); +}); + +describe("validate", () => { + const manifest = parsedManifest(); + const digest = workerTaskManifestDigest(manifest); + + it("names only the task id and the digest on the wire", async () => { + const fake = fakeCompanion([{ ok: true, version: 1, op: "validate", taskRoot, files: 0, commandIds: ["build"] }]); + const result = await validateWorkerTask(worker, manifest, digest, fake.runner); + expect(result.commandIds).toEqual(["build"]); + expect(JSON.parse(fake.calls[0].stdin)).toEqual({ op: "validate", taskId: "task-1", manifestSha256: digest }); + expect(fake.calls[0].stdin).not.toContain(worker.sshAlias); + }); + + it("refuses an implausible task root before anything is built from it", async () => { + const fake = fakeCompanion([ + { ok: true, version: 1, op: "validate", taskRoot: "/etc", files: 0, commandIds: [] }, + ]); + await expect(validateWorkerTask(worker, manifest, digest, fake.runner)) + .rejects.toThrow(/implausible task root/); + }); + + it("refuses a worker holding a different set of files", async () => { + const fake = fakeCompanion([ + { ok: true, version: 1, op: "validate", taskRoot, files: 3, commandIds: ["build"] }, + ]); + await expect(validateWorkerTask(worker, manifest, digest, fake.runner)) + .rejects.toThrow(/different set of task files/); + }); + + it("treats a non-JSON reply as a transport failure", async () => { + const runner = () => Promise.resolve({ stdout: "Welcome to Ubuntu 24.04\n", stderr: "" }); + await expect(validateWorkerTask(worker, manifest, digest, runner)).rejects.toThrow(/unreadable reply/); + }); + + it("reads the last line, so a login banner ahead of the reply is tolerated", async () => { + const reply = { ok: true, version: 1, op: "validate", taskRoot, files: 0, commandIds: ["build"] }; + const runner = () => Promise.resolve({ stdout: `motd line\n${JSON.stringify(reply)}\n`, stderr: "" }); + await expect(validateWorkerTask(worker, manifest, digest, runner)).resolves.toMatchObject({ files: 0 }); + }); +}); + +describe("activate", () => { + const manifest = parsedManifest(); + const digest = workerTaskManifestDigest(manifest); + const expected = workerCuaCapabilityDigest(workerCuaCapabilityManifest(manifest, taskRoot, TASK_NOW)); + + it("sends the issuing instant and the digest, never the capability itself", async () => { + const fake = fakeCompanion([{ ok: true, version: 1, op: "activate", capabilitySha256: expected }]); + const result = await activateWorkerTask(worker, manifest, digest, taskRoot, fake.runner, TASK_NOW); + expect(result.capabilitySha256).toBe(expected); + const sent = JSON.parse(fake.calls[0].stdin); + expect(sent).toEqual({ + op: "activate", + taskId: "task-1", + manifestSha256: digest, + issuedAt: TASK_NOW, + expectedCapabilitySha256: expected, + }); + // The capability document is derived at both ends and never travels. + expect(fake.calls[0].stdin).not.toContain("allow:"); + expect(fake.calls[0].stdin).not.toContain("expires_after"); + }); + + it("refuses when the worker activates a different capability", async () => { + const fake = fakeCompanion([{ ok: true, version: 1, op: "activate", capabilitySha256: "e".repeat(64) }]); + await expect(activateWorkerTask(worker, manifest, digest, taskRoot, fake.runner, TASK_NOW)) + .rejects.toThrow(/activated a different capability/); + }); + + it("will not derive a capability for an implausible root", async () => { + const fake = fakeCompanion([{ ok: true, version: 1, op: "activate", capabilitySha256: expected }]); + await expect(activateWorkerTask(worker, manifest, digest, "/etc", fake.runner, TASK_NOW)) + .rejects.toThrow(/implausible task root/); + expect(fake.calls).toHaveLength(0); + }); +}); + +describe("run", () => { + const manifest = parsedManifest(); + const digest = workerTaskManifestDigest(manifest); + + it("selects a command by id and never describes one", async () => { + const fake = fakeCompanion([ + { ok: true, version: 1, op: "run", commandId: "build", code: 0, stdout: "ok", stderr: "" }, + ]); + const result = await runWorkerCommand(worker, manifest, digest, "build", fake.runner); + expect(result.code).toBe(0); + const sent = JSON.parse(fake.calls[0].stdin); + expect(sent).toEqual({ op: "run", taskId: "task-1", manifestSha256: digest, commandId: "build" }); + expect(fake.calls[0].stdin).not.toContain(manifest.commands[0].executable); + }); + + it("refuses a command id the approved manifest does not contain", async () => { + const fake = fakeCompanion([{ ok: false, error: "unreachable" }]); + await expect(runWorkerCommand(worker, manifest, digest, "deploy", fake.runner)) + .rejects.toThrow(/no command with that id/); + expect(fake.calls).toHaveLength(0); + }); + + it("refuses a reply that names a different command", async () => { + const fake = fakeCompanion([ + { ok: true, version: 1, op: "run", commandId: "other", code: 0, stdout: "", stderr: "" }, + ]); + await expect(runWorkerCommand(worker, manifest, digest, "build", fake.runner)) + .rejects.toThrow(/ran a different command/); + }); + + it("passes a non-zero exit through as a result, not an error", async () => { + const fake = fakeCompanion([ + { ok: true, version: 1, op: "run", commandId: "build", code: 2, stdout: "", stderr: "boom" }, + ]); + await expect(runWorkerCommand(worker, manifest, digest, "build", fake.runner)) + .resolves.toMatchObject({ code: 2, stderr: "boom" }); + }); +}); + +describe("reset", () => { + it("names the pinned base policy so the worker proves what it parked on", async () => { + const fake = fakeCompanion([{ ok: true, version: 1, op: "reset", capabilitySha256: "d".repeat(64) }]); + await resetWorkerTask(worker, "task-1", fake.runner); + expect(JSON.parse(fake.calls[0].stdin)).toEqual({ + op: "reset", + taskId: "task-1", + expectedBasePolicySha256: worker.expectedBasePolicySha256, + }); + }); + + it("refuses a worker with no pinned base policy", async () => { + const unpinned = workerFixture(HOST_TASK_PLATFORM, { expectedBasePolicySha256: null, configured: false }); + const fake = fakeCompanion([{ ok: true, version: 1, op: "reset", capabilitySha256: "d".repeat(64) }]); + await expect(resetWorkerTask(unpinned, "task-1", fake.runner)).rejects.toThrow(/no pinned base policy/); + }); +}); + +describe("fetch results", () => { + const manifest = parsedManifest(); + const digest = workerTaskManifestDigest(manifest); + const body = Buffer.from('{"ok":true}', "utf8"); + const sha256 = createHash("sha256").update(body).digest("hex"); + + it("returns the declared artefacts", async () => { + const stream = fakeStream(Buffer.concat([ + encodeFrame({ kind: "file", bytes: body.length, path: "result.json", sha256 }, body), + END_FRAME, + ])); + const artefacts = await fetchWorkerResults(worker, manifest, digest, stream.runner); + expect(artefacts).toHaveLength(1); + expect(artefacts[0].content.toString("utf8")).toBe('{"ok":true}'); + expect(stream.calls[0].slice(-3)).toEqual(["fetch", "task-1", digest]); + }); + + it("refuses an artefact the manifest never declared", async () => { + const stream = fakeStream(Buffer.concat([ + encodeFrame({ kind: "file", bytes: body.length, path: "etc/passwd", sha256 }, body), + END_FRAME, + ])); + await expect(fetchWorkerResults(worker, manifest, digest, stream.runner)) + .rejects.toThrow(/never declared/); + }); + + it("refuses an artefact whose bytes do not match its digest", async () => { + const stream = fakeStream(Buffer.concat([ + encodeFrame({ kind: "file", bytes: body.length, path: "result.json", sha256: "f".repeat(64) }, body), + END_FRAME, + ])); + await expect(fetchWorkerResults(worker, manifest, digest, stream.runner)) + .rejects.toThrow(/hash does not match/); + }); + + it("refuses a result stream that tries to smuggle a manifest", async () => { + const stream = fakeStream(Buffer.concat([ + encodeFrame({ kind: "manifest", bytes: body.length }, body), + END_FRAME, + ])); + await expect(fetchWorkerResults(worker, manifest, digest, stream.runner)) + .rejects.toThrow(/cannot carry a manifest/); + }); + + it("refuses the same artefact twice", async () => { + const frame = encodeFrame({ kind: "file", bytes: body.length, path: "result.json", sha256 }, body); + const stream = fakeStream(Buffer.concat([frame, frame, END_FRAME])); + await expect(fetchWorkerResults(worker, manifest, digest, stream.runner)) + .rejects.toThrow(/twice/); + }); +}); diff --git a/server/worker-task-transport.ts b/server/worker-task-transport.ts new file mode 100644 index 00000000..15153853 --- /dev/null +++ b/server/worker-task-transport.ts @@ -0,0 +1,432 @@ +// The SSH half of the task layer: how an approved manifest reaches a worker +// and what comes back. +// +// Every remote invocation is fixed argv through the same base args and +// allow-listed environment the health probe uses (server/remote-worker.ts), so +// nothing here can widen the connection's boundary. Two of the five operations +// carry raw bytes and take a streaming runner; the other three are one JSON +// line in and one JSON line out, which is all the companion's wire accepts. +// +// Note what this file does NOT decide. It never chooses a task root — the +// worker derives its own and reports it back — and it never sends a capability +// document. It sends the instant the control plane derived one at, plus the +// digest that derivation produced, and the worker refuses anything it cannot +// reproduce from the manifest it already holds. +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { createReadStream } from "node:fs"; +import { resolve as resolvePath } from "node:path"; +import type { Writable } from "node:stream"; + +import { z } from "zod"; + +import type { ResolvedWorker, WorkerPlatform } from "./computer-workers.ts"; +import { + remoteWorkerSshBaseArgs, + remoteWorkerSshEnvironment, + type RemoteWorkerSshRunner, +} from "./remote-worker.ts"; +import { type JsonValue, parseJson, schemaIssue } from "./schema.ts"; +import { encodeFrameHeader, END_FRAME, FrameReader, type FrameHeader } from "./worker-task-frames.ts"; +import { + verifyWorkerTaskFiles, + workerTaskManifestJson, + WORKER_TASK_MAX_TOTAL_BYTES, + type WorkerTaskManifest, +} from "./worker-task-manifest.ts"; +import { workerCuaCapabilityDigest, workerCuaCapabilityManifest } from "./worker-cua-capability.ts"; + +const COMPANION = "openmausbot-worker-companion"; +const OP_TIMEOUT_MS = 60_000; +const STAGE_TIMEOUT_MS = 10 * 60_000; +const FETCH_TIMEOUT_MS = 5 * 60_000; +/** Head-room over a command's own deadline, so a companion that is enforcing + * the timeout properly always reports back before SSH gives up on it. */ +const RUN_GRACE_MS = 30_000; +const SHA256 = /^[a-f0-9]{64}$/i; + +// ── replies ────────────────────────────────────────────────────────────────── + +const digest = z.string().regex(SHA256).transform((value) => value.toLowerCase()); + +/** Discriminated on `op`, not on `ok`: four of the arms share `ok: true`, and + * a discriminator has to be unique per arm. */ +const successSchema = z.discriminatedUnion("op", [ + z.object({ + ok: z.literal(true), + version: z.number().int(), + op: z.literal("stage"), + files: z.number().int().min(0), + }), + z.object({ + ok: z.literal(true), + version: z.number().int(), + op: z.literal("validate"), + taskRoot: z.string().min(1).max(512), + files: z.number().int().min(0), + commandIds: z.array(z.string().max(128)).max(128), + }), + z.object({ + ok: z.literal(true), + version: z.number().int(), + op: z.literal("activate"), + capabilitySha256: digest, + }), + z.object({ + ok: z.literal(true), + version: z.number().int(), + op: z.literal("reset"), + capabilitySha256: digest, + }), + z.object({ + ok: z.literal(true), + version: z.number().int(), + op: z.literal("run"), + commandId: z.string().max(128), + code: z.number().int().nullable(), + stdout: z.string(), + stderr: z.string(), + }), +]); + +const failureSchema = z.object({ ok: z.literal(false), error: z.string().max(4096) }); + +const replySchema = z.union([failureSchema, successSchema]); + +/** What a caller sees: `parseReply` turns the failure arm into a thrown error, + * so every call site works on a reply that succeeded. */ +type CompanionSuccess = z.output; + +/** A companion reply is one JSON line. Anything else — a login banner, a shell + * error, a truncated stream — is a transport failure, not a task failure. */ +function parseReply(raw: string): CompanionSuccess { + const line = raw.split("\n").map((entry) => entry.trim()).filter(Boolean).at(-1) ?? ""; + if (!line) throw new Error("the worker companion returned nothing"); + let document: JsonValue; + try { + document = parseJson(line); + } catch { + // A shell error or an unexpected login banner reaches here as a raw + // SyntaxError, which reads to the model as if the task itself was + // malformed. Name what actually went wrong instead. + throw new Error("the worker companion returned an unreadable reply"); + } + const parsed = replySchema.safeParse(document); + if (!parsed.success) throw new Error(schemaIssue(parsed.error, "the worker companion returned an unreadable reply")); + if (!parsed.data.ok) throw new Error(parsed.data.error); + return parsed.data; +} + +// ── streaming runner ───────────────────────────────────────────────────────── + +export interface WorkerTaskStreamOptions { + timeoutMs: number; + /** Writes the request body to the child's stdin and resolves when done. */ + write?: (stdin: Writable) => Promise; +} + +export type WorkerTaskStreamRunner = ( + args: string[], + options: WorkerTaskStreamOptions, +) => Promise<{ stdout: Buffer; stderr: string }>; + +/** The binary sibling of `defaultRemoteWorkerRunner`. Staged files and result + * artefacts are bytes, and a string round trip would corrupt them. */ +export function defaultWorkerTaskStreamRunner( + args: string[], + options: WorkerTaskStreamOptions, +): Promise<{ stdout: Buffer; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn("ssh", args, { + shell: false, + env: remoteWorkerSshEnvironment(), + stdio: ["pipe", "pipe", "pipe"], + }); + const chunks: Buffer[] = []; + let stderr = ""; + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(); + }; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + finish(() => reject(new Error("worker task transport timed out"))); + }, options.timeoutMs); + timer.unref?.(); + + child.stdin.on("error", () => { + // A fast remote failure may close stdin mid-write; the close handler + // below stays the authoritative result. + }); + child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk)); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { stderr = (stderr + chunk).slice(-64 * 1024); }); + child.on("error", (error) => finish(() => reject(new Error(`worker SSH could not start: ${error.message}`)))); + child.on("close", (code) => finish(() => { + if (code === 0) resolve({ stdout: Buffer.concat(chunks), stderr }); + else reject(new Error(stderr.trim().slice(-500) || `worker SSH exited ${code ?? "without a status"}`)); + })); + + const body = options.write; + if (!body) { + child.stdin.end(); + return; + } + void body(child.stdin).then( + () => child.stdin.end(), + (error: Error) => finish(() => { + child.kill("SIGKILL"); + reject(error); + }), + ); + }); +} + +function companionArgs(worker: ResolvedWorker, argv: string[]): string[] { + return [...remoteWorkerSshBaseArgs(worker.sshAlias), COMPANION, ...argv]; +} + +/** Backpressure-aware write. A 200 MB stage would otherwise buffer the whole + * transfer in this process's memory. */ +async function write(stream: Writable, chunk: Buffer): Promise { + if (!stream.write(chunk)) await once(stream, "drain"); +} + +// ── stage ──────────────────────────────────────────────────────────────────── + +export interface StagedTask { + files: number; +} + +/** Send the approved manifest and the exact local files it names. + * + * The local stage is verified first: `verifyWorkerTaskFiles` re-hashes every + * file without following symlinks, so a path swapped between approval and + * transfer is caught here rather than becoming trusted bytes on the worker. */ +export async function stageWorkerTask( + worker: ResolvedWorker, + localRoot: string, + manifest: WorkerTaskManifest, + runner: WorkerTaskStreamRunner = defaultWorkerTaskStreamRunner, +): Promise { + verifyWorkerTaskFiles(localRoot, manifest); + const total = manifest.files.reduce((sum, file) => sum + file.size, 0); + if (total > WORKER_TASK_MAX_TOTAL_BYTES) throw new Error("Worker task staged files exceed 200 MB"); + + const document = Buffer.from(workerTaskManifestJson(manifest), "utf8"); + const result = await runner(companionArgs(worker, ["stage", manifest.taskId]), { + timeoutMs: STAGE_TIMEOUT_MS, + async write(stdin) { + await write(stdin, encodeFrameHeader({ kind: "manifest", bytes: document.length })); + await write(stdin, document); + for (const file of manifest.files) { + const header: FrameHeader = { + kind: "file", + bytes: file.size, + path: file.path, + sha256: file.sha256, + }; + await write(stdin, encodeFrameHeader(header)); + const source = createReadStream(resolvePath(localRoot, ...file.path.split("/"))); + for await (const chunk of source) { + await write(stdin, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + } + await write(stdin, END_FRAME); + }, + }); + + const reply = parseReply(result.stdout.toString("utf8")); + if (reply.op !== "stage") throw new Error("the worker companion did not confirm staging"); + if (reply.files !== manifest.files.length) throw new Error("the worker staged a different number of files"); + return { files: reply.files }; +} + +// ── the three JSON operations ──────────────────────────────────────────────── + +async function companionOp( + worker: ResolvedWorker, + request: Record, + runner: RemoteWorkerSshRunner, + timeoutMs: number, +): Promise { + const result = await runner(companionArgs(worker, ["stdio"]), timeoutMs, `${JSON.stringify(request)}\n`); + return parseReply(result.stdout); +} + +export interface ValidatedWorkerTask { + taskRoot: string; + files: number; + commandIds: string[]; +} + +/** A worker-derived task root is only a hint, but it becomes part of the + * capability document, so refuse any shape that is not the one this platform's + * companion can produce before building a capability around it. */ +export function isPlausibleTaskRoot(platform: WorkerPlatform, taskId: string, value: string): boolean { + if (/[\r\n]/.test(value) || value.includes("..")) return false; + return platform === "windows" + ? /^[A-Za-z]:\\/.test(value) && value.endsWith(`\\tasks\\${taskId}`) + : value.startsWith("/") && value.endsWith(`/tasks/${taskId}`); +} + +export async function validateWorkerTask( + worker: ResolvedWorker, + manifest: WorkerTaskManifest, + manifestSha256: string, + runner: RemoteWorkerSshRunner, +): Promise { + const reply = await companionOp( + worker, + { op: "validate", taskId: manifest.taskId, manifestSha256 }, + runner, + OP_TIMEOUT_MS, + ); + if (reply.op !== "validate") throw new Error("the worker companion did not validate the task"); + if (!isPlausibleTaskRoot(manifest.platform, manifest.taskId, reply.taskRoot)) { + throw new Error("the worker reported an implausible task root"); + } + if (reply.files !== manifest.files.length) throw new Error("the worker holds a different set of task files"); + return { taskRoot: reply.taskRoot, files: reply.files, commandIds: [...reply.commandIds] }; +} + +export interface ActivatedWorkerTask { + capabilitySha256: string; + issuedAt: number; +} + +/** Derive the capability here, then require the worker to reproduce it. + * Neither end can widen the boundary alone: the control plane cannot send a + * document, and the worker cannot activate one whose digest the control plane + * did not name. */ +export async function activateWorkerTask( + worker: ResolvedWorker, + manifest: WorkerTaskManifest, + manifestSha256: string, + taskRoot: string, + runner: RemoteWorkerSshRunner, + issuedAt = Date.now(), +): Promise { + if (!isPlausibleTaskRoot(manifest.platform, manifest.taskId, taskRoot)) { + throw new Error("refusing to derive a capability for an implausible task root"); + } + const expected = workerCuaCapabilityDigest(workerCuaCapabilityManifest(manifest, taskRoot, issuedAt)); + const reply = await companionOp( + worker, + { + op: "activate", + taskId: manifest.taskId, + manifestSha256, + issuedAt, + expectedCapabilitySha256: expected, + }, + runner, + OP_TIMEOUT_MS, + ); + if (reply.op !== "activate") throw new Error("the worker companion did not activate the task"); + if (reply.capabilitySha256 !== expected) throw new Error("the worker activated a different capability"); + return { capabilitySha256: expected, issuedAt }; +} + +export interface WorkerCommandResult { + commandId: string; + code: number | null; + stdout: string; + stderr: string; +} + +export async function runWorkerCommand( + worker: ResolvedWorker, + manifest: WorkerTaskManifest, + manifestSha256: string, + commandId: string, + runner: RemoteWorkerSshRunner, +): Promise { + const command = manifest.commands.find((entry) => entry.id === commandId); + if (!command) throw new Error("the approved task has no command with that id"); + const reply = await companionOp( + worker, + { op: "run", taskId: manifest.taskId, manifestSha256, commandId }, + runner, + command.timeoutMs + RUN_GRACE_MS, + ); + if (reply.op !== "run") throw new Error("the worker companion did not run the command"); + if (reply.commandId !== commandId) throw new Error("the worker ran a different command"); + return { commandId, code: reply.code, stdout: reply.stdout, stderr: reply.stderr }; +} + +/** Drop the task's files and put the worker back on its deny-all capability. + * The one operation that must still work when everything else has failed. */ +export async function resetWorkerTask( + worker: ResolvedWorker, + taskId: string, + runner: RemoteWorkerSshRunner, +): Promise { + if (!worker.expectedBasePolicySha256) throw new Error("worker has no pinned base policy"); + const reply = await companionOp( + worker, + { op: "reset", taskId, expectedBasePolicySha256: worker.expectedBasePolicySha256 }, + runner, + OP_TIMEOUT_MS, + ); + if (reply.op !== "reset") throw new Error("the worker companion did not reset the task"); + return reply.capabilitySha256; +} + +// ── fetch ──────────────────────────────────────────────────────────────────── + +export interface WorkerResultArtefact { + path: string; + sha256: string; + content: Buffer; +} + +/** Read back only the artefacts the approved manifest declares. A worker that + * offers anything else — a path not in `resultPaths`, a digest that does not + * match its own bytes — is refused whole rather than partially trusted. */ +export async function fetchWorkerResults( + worker: ResolvedWorker, + manifest: WorkerTaskManifest, + manifestSha256: string, + runner: WorkerTaskStreamRunner = defaultWorkerTaskStreamRunner, +): Promise { + const result = await runner(companionArgs(worker, ["fetch", manifest.taskId, manifestSha256]), { + timeoutMs: FETCH_TIMEOUT_MS, + }); + + const declared = new Set(manifest.resultPaths); + const artefacts: WorkerResultArtefact[] = []; + let parts: Buffer[] = []; + + const reader = new FrameReader({ + onHeader(header: FrameHeader) { + if (header.kind === "manifest") throw new Error("a result stream cannot carry a manifest"); + parts = []; + }, + onPayload(chunk: Buffer) { + parts.push(chunk); + }, + onFrameEnd(header: FrameHeader) { + if (header.kind !== "file") return; + const path = header.path ?? ""; + if (!declared.has(path)) throw new Error(`the worker returned an artefact the task never declared: ${path}`); + if (artefacts.some((artefact) => artefact.path === path)) { + throw new Error(`the worker returned ${path} twice`); + } + artefacts.push({ path, sha256: (header.sha256 ?? "").toLowerCase(), content: Buffer.concat(parts) }); + parts = []; + }, + }); + reader.push(result.stdout); + reader.end(); + + for (const artefact of artefacts) { + const actual = createHash("sha256").update(artefact.content).digest("hex"); + if (actual !== artefact.sha256) throw new Error(`result artefact hash does not match: ${artefact.path}`); + } + return artefacts; +} diff --git a/worker-companion/src/driver.ts b/worker-companion/src/driver.ts index d9ac9aa0..ea952a04 100644 --- a/worker-companion/src/driver.ts +++ b/worker-companion/src/driver.ts @@ -1,8 +1,10 @@ // Fixed-argv control of the local CUA Driver. // -// Every invocation here is a constant: no shell, no caller-supplied executable, -// argv, cwd or environment. The companion's whole security value is that the -// wire cannot name a program to run. +// Never a shell, and never an environment from the caller. The driver +// invocations here are constants; the one caller that supplies an executable, +// argv and cwd is task.ts, and every one of those values comes out of a staged +// manifest whose digest an operator approved. The companion's security value is +// that the wire itself cannot name a program to run. import { spawn } from "node:child_process"; import { capabilityDigest, parkedCapability, writeActiveCapability } from "./capability.ts"; @@ -15,19 +17,37 @@ const READY_TIMEOUT_MS = 15_000; export interface RunResult { stdout: string; stderr: string; code: number | null } +export interface RunOptions { + /** Working directory for the child. Only ever a directory the caller has + * already resolved inside an approved task root — never a path off the wire. */ + cwd?: string; +} + export function runFixed( executable: string, args: string[], timeoutMs: number, acceptNonZero = false, + options: RunOptions = {}, ): Promise { return new Promise((resolveResult, reject) => { - const child = spawn(executable, args, { - shell: false, - env: childEnvironment(), - windowsHide: true, - stdio: ["ignore", "pipe", "pipe"], - }); + // Two calls rather than one with a conditional spread: an absent cwd is an + // omission the reader can see, and the literal keeps its exact stdio tuple + // type so `child.stdout` and `child.stderr` stay non-null below. + const child = options.cwd === undefined + ? spawn(executable, args, { + shell: false, + env: childEnvironment(), + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }) + : spawn(executable, args, { + shell: false, + env: childEnvironment(), + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + cwd: options.cwd, + }); let stdout = ""; let stderr = ""; let settled = false; diff --git a/worker-companion/src/frames.ts b/worker-companion/src/frames.ts new file mode 100644 index 00000000..ee896227 --- /dev/null +++ b/worker-companion/src/frames.ts @@ -0,0 +1,145 @@ +// Length-prefixed framing for the staging and result streams. +// +// Staged task files are binary and can total 200 MB, so they ride a raw stream +// on the companion's stdin rather than the line-oriented JSON wire in wire.ts. +// One frame is a 4-byte big-endian header length, a JSON header, then exactly +// the payload bytes that header declares — no delimiter a payload could forge, +// and no base64 inflation against the size ceilings. +// +// server/worker-task-frames.ts is the other end of this format. They are +// duplicated rather than imported for the reason platform.ts gives, and +// test/frame-parity.test.ts drives one against the other so they cannot drift. +import { Buffer } from "node:buffer"; + +export const FRAME_HEADER_PREFIX_BYTES = 4; +export const MAX_FRAME_HEADER_BYTES = 4096; +export const MAX_FRAME_PAYLOAD_BYTES = 50 * 1024 * 1024; + +export type FrameKind = "manifest" | "file" | "end"; + +export interface FrameHeader { + kind: FrameKind; + /** Exact payload length following this header. `end` always carries 0. */ + bytes: number; + /** Present only on `file`: the manifest-relative path being staged. */ + path?: string; + /** Present only on `file`: the digest the payload must hash to. */ + sha256?: string; +} + +function assertHeader(header: FrameHeader): void { + if (!["manifest", "file", "end"].includes(header.kind)) throw new Error("unknown frame kind"); + if (!Number.isSafeInteger(header.bytes) || header.bytes < 0 || header.bytes > MAX_FRAME_PAYLOAD_BYTES) { + throw new Error("frame payload length is out of range"); + } + if (header.kind === "end" && header.bytes !== 0) throw new Error("end frame cannot carry a payload"); + if (header.kind === "file" && (!header.path || !header.sha256)) { + throw new Error("file frame needs a path and a digest"); + } +} + +export function encodeFrameHeader(header: FrameHeader): Buffer { + assertHeader(header); + const json = Buffer.from(JSON.stringify(header), "utf8"); + if (json.length > MAX_FRAME_HEADER_BYTES) throw new Error("frame header is too large"); + const prefix = Buffer.alloc(FRAME_HEADER_PREFIX_BYTES); + prefix.writeUInt32BE(json.length, 0); + return Buffer.concat([prefix, json]); +} + +export function encodeFrame(header: FrameHeader, payload: Buffer = Buffer.alloc(0)): Buffer { + if (payload.length !== header.bytes) throw new Error("frame payload length does not match its header"); + return Buffer.concat([encodeFrameHeader(header), payload]); +} + +export const END_FRAME = encodeFrame({ kind: "end", bytes: 0 }); + +export interface FrameHandlers { + /** A header has been read; its payload follows in zero or more chunks. */ + onHeader: (header: FrameHeader) => void; + /** A slice of the current frame's payload, in order. */ + onPayload: (chunk: Buffer) => void; + /** The current frame's payload is complete. */ + onFrameEnd: (header: FrameHeader) => void; +} + +/** Incremental reader. Payload chunks are handed straight through so a 50 MB + * file is written to disk as it arrives rather than held whole in memory. */ +export class FrameReader { + private pending: Buffer = Buffer.alloc(0); + private header: FrameHeader | null = null; + private remaining = 0; + private finished = false; + + private readonly handlers: FrameHandlers; + + // Assigned in the body, not as a constructor parameter property: the + // packaged server runs under Node's strip-only TypeScript mode, which + // rejects `constructor(private readonly x: T)`. tsc and vitest both + // transpile, so only booting the real server catches it. + constructor(handlers: FrameHandlers) { + this.handlers = handlers; + } + + /** True once an `end` frame has been read; further bytes are an error. */ + get done(): boolean { + return this.finished; + } + + push(chunk: Buffer): void { + if (this.finished) throw new Error("frame stream continued past its end frame"); + this.pending = this.pending.length === 0 ? chunk : Buffer.concat([this.pending, chunk]); + for (;;) { + if (this.header === null) { + if (this.pending.length < FRAME_HEADER_PREFIX_BYTES) return; + const length = this.pending.readUInt32BE(0); + if (length === 0 || length > MAX_FRAME_HEADER_BYTES) throw new Error("frame header length is out of range"); + if (this.pending.length < FRAME_HEADER_PREFIX_BYTES + length) return; + const json = this.pending.subarray(FRAME_HEADER_PREFIX_BYTES, FRAME_HEADER_PREFIX_BYTES + length).toString("utf8"); + this.pending = this.pending.subarray(FRAME_HEADER_PREFIX_BYTES + length); + this.header = parseFrameHeader(json); + this.remaining = this.header.bytes; + this.handlers.onHeader(this.header); + } + if (this.remaining > 0) { + if (this.pending.length === 0) return; + const take = Math.min(this.remaining, this.pending.length); + this.handlers.onPayload(this.pending.subarray(0, take)); + this.pending = this.pending.subarray(take); + this.remaining -= take; + if (this.remaining > 0) return; + } + const complete = this.header; + this.header = null; + this.handlers.onFrameEnd(complete); + if (complete.kind === "end") { + this.finished = true; + if (this.pending.length > 0) throw new Error("frame stream continued past its end frame"); + return; + } + } + } + + /** Called when the source stream closes. A stream that stopped mid-frame, or + * before its end frame, is truncated — never silently accept it. */ + end(): void { + if (!this.finished) throw new Error("frame stream ended before its end frame"); + } +} + +function parseFrameHeader(json: string): FrameHeader { + let value: unknown; + try { + value = JSON.parse(json); + } catch { + throw new Error("frame header is not JSON"); + } + if (value === null || !(value instanceof Object) || Array.isArray(value)) { + throw new Error("frame header must be an object"); + } + // SAFETY: shape is checked field by field in assertHeader below, which + // rejects anything this cast would otherwise let through. + const header = value as FrameHeader; + assertHeader(header); + return header; +} diff --git a/worker-companion/src/index.ts b/worker-companion/src/index.ts index d77ee72c..94f01447 100644 --- a/worker-companion/src/index.ts +++ b/worker-companion/src/index.ts @@ -4,32 +4,75 @@ // Runs as the already-authenticated, non-administrative interactive worker user // on macOS or Windows. It has no listener: OpenMausBot reaches it only over the // operator-owned SSH alias, either as one of the two out-of-band flags the -// health probe reads, or as the fixed `stdio` command. +// health probe reads, or as one of the fixed subcommands below. // -// The wire can name an operation and a digest. It can never name an executable, -// argv, environment variable, working directory, policy, or capability YAML. +// The `stdio` wire can name an operation, an id and a digest. It can never name +// an executable, argv, environment variable, working directory, policy, or +// capability YAML. `stage` and `fetch` are separate subcommands only because +// their payload is a raw byte stream rather than a JSON line; they still take +// no path from the caller, deriving the task root from an id-validated task id. import readline from "node:readline"; import { pauseWorker, resumeParkedWorker } from "./driver.ts"; import { formatPermissions, readPermissions } from "./permissions.ts"; -import { type CompanionRequest, type CompanionResponse, PROTOCOL_VERSION, parseRequest } from "./wire.ts"; +import { activateTask, fetchResults, resetTask, runTaskCommand, stageTask, validateTask } from "./task.ts"; +import { + asDigest, + type CompanionRequest, + type CompanionResponse, + PROTOCOL_VERSION, + parseRequest, + type StageResponse, +} from "./wire.ts"; const MAX_REQUEST_BYTES = 1024 * 1024; async function handle(request: CompanionRequest): Promise { - if (request.op === "pause") { - await pauseWorker(); - return { ok: true, version: PROTOCOL_VERSION, paused: true }; + switch (request.op) { + case "pause": { + await pauseWorker(); + return { ok: true, version: PROTOCOL_VERSION, paused: true }; + } + case "resume": { + const capabilitySha256 = await resumeParkedWorker(request.expectedBasePolicySha256); + return { ok: true, version: PROTOCOL_VERSION, paused: false, capabilitySha256 }; + } + case "reset": { + const capabilitySha256 = await resetTask(request.taskId, request.expectedBasePolicySha256); + return { ok: true, version: PROTOCOL_VERSION, op: "reset", capabilitySha256 }; + } + case "validate": { + const { manifest, root } = validateTask(request.taskId, request.manifestSha256); + return { + ok: true, + version: PROTOCOL_VERSION, + op: "validate", + taskRoot: root, + files: manifest.files.length, + commandIds: manifest.commands.map((command) => command.id), + }; + } + case "activate": { + const capabilitySha256 = await activateTask( + request.taskId, + request.manifestSha256, + request.issuedAt, + request.expectedCapabilitySha256, + ); + return { ok: true, version: PROTOCOL_VERSION, op: "activate", capabilitySha256 }; + } + case "run": { + const result = await runTaskCommand(request.taskId, request.manifestSha256, request.commandId); + return { ok: true, version: PROTOCOL_VERSION, op: "run", ...result }; + } } - const capabilitySha256 = await resumeParkedWorker(request.expectedBasePolicySha256); - return { ok: true, version: PROTOCOL_VERSION, paused: false, capabilitySha256 }; } -const reply = (response: CompanionResponse): void => { +const reply = (response: CompanionResponse | StageResponse): void => { process.stdout.write(`${JSON.stringify(response)}\n`); }; -const [, , subcommand] = process.argv; +const [, , subcommand, firstArgument = "", secondArgument = ""] = process.argv; if (process.argv.includes("--version")) { // The probe parses the trailing integer as the protocol version. @@ -64,7 +107,31 @@ if (process.argv.includes("--version")) { } })(); }); +} else if (subcommand === "stage") { + // The staged bytes arrive on stdin; the reply is one JSON line on stdout, so + // the caller reads the same shape it gets from `stdio`. + void (async () => { + try { + const result = await stageTask(firstArgument, process.stdin); + reply({ ok: true, version: PROTOCOL_VERSION, op: "stage", files: result.files }); + } catch (error) { + reply({ ok: false, error: error instanceof Error ? error.message : String(error) }); + process.exitCode = 1; + } + })(); +} else if (subcommand === "fetch") { + // stdout carries frames, not JSON, so a failure can only be reported on + // stderr and by the exit status. + try { + fetchResults(firstArgument, asDigest(secondArgument), process.stdout); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } } else { - process.stderr.write("usage: openmausbot-worker-companion --version | --permissions | stdio\n"); + process.stderr.write( + "usage: openmausbot-worker-companion --version | --permissions | stdio" + + " | stage | fetch \n", + ); process.exitCode = 2; } diff --git a/worker-companion/src/manifest.ts b/worker-companion/src/manifest.ts new file mode 100644 index 00000000..80713ef1 --- /dev/null +++ b/worker-companion/src/manifest.ts @@ -0,0 +1,283 @@ +// The worker's own reading of an approved task manifest. +// +// The control plane already validated and approved this document — its digest +// is what a person clicked Allow on. The companion still re-derives two things +// from it locally rather than being told them: +// +// 1. whether a command's executable is one this platform may ever run, and +// 2. the exact CUA capability the task is allowed to activate. +// +// Both duplicate rules that also live in server/worker-task-manifest.ts and +// server/worker-cua-capability.ts, for the same reason platform.ts duplicates +// the daemon paths: the companion ships to the worker as a standalone package +// with no view of the server tree. The duplication is the point as much as the +// cost — a control plane that has been tampered with cannot hand this worker a +// broader boundary than the worker itself would derive, and +// test/manifest-parity.test.ts fails if the two ends ever disagree about what +// to reject. +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +import type { JsonValue } from "./wire.ts"; + +export const TASK_MANIFEST_VERSION = 1; +export const TASK_IDLE_TIMEOUT_MS = 20 * 60_000; +export const TASK_MAX_FILE_BYTES = 50 * 1024 * 1024; +export const TASK_MAX_TOTAL_BYTES = 200 * 1024 * 1024; +export const TASK_MAX_COMMAND_MS = 30 * 60_000; + +const ID = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; +const SHA256 = /^[a-f0-9]{64}$/i; +const SAFE_RELATIVE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,511}$/; +const BLOCKED_FILE = + /(^|\/)(?:\.git(?:\/|$)|\.env(?:\.[^/]*)?$|credentials?(?:\.[^/]*)?$|secrets?(?:\.[^/]*)?$|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.[^/]*)?$|[^/]+\.(?:key|pem|p12|pfx|keystore)$)/i; + +export type TaskPlatform = "windows" | "macos"; + +/** The same three per-platform answers server/worker-task-manifest.ts keeps: + * which executables are never allowed, which one is the file manager, and how + * two executable paths compare for equality. */ +interface PlatformProfile { + readonly fileManager: string; + readonly blockedExecutable: RegExp; + readonly normalize: (value: string) => string; +} + +export const PLATFORM_PROFILES = { + windows: { + fileManager: "C:\\Windows\\explorer.exe", + blockedExecutable: + /(?:^|\\)(?:cmd|powershell|pwsh|wt|windowsterminal|reg|regedit|mmc|taskmgr|control|mshta|wscript|cscript)\.exe$/i, + normalize: (value: string) => value.replaceAll("/", "\\").toLowerCase(), + }, + macos: { + fileManager: "/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder", + blockedExecutable: + /(?:^|\/)(?:sh|bash|zsh|dash|ksh|csh|tcsh|fish|osascript|open|sudo|su|env|xargs|launchctl|python|python3|perl|ruby|node|deno|bun|Terminal|iTerm|iTerm2|Script Editor)$/, + normalize: (value: string) => value, + }, +} satisfies Record; + +const WINDOWS_ABSOLUTE = /^[A-Za-z]:\\/; +const POSIX_ABSOLUTE = /^\//; + +export function isAbsoluteFor(platform: TaskPlatform, value: string): boolean { + return platform === "windows" ? WINDOWS_ABSOLUTE.test(value) : POSIX_ABSOLUTE.test(value); +} + +const relativePath = z.string().refine((value) => { + if (!SAFE_RELATIVE.test(value) || value.includes("//") || value.endsWith("/")) return false; + const parts = value.split("/"); + return !parts.some((part) => part === "." || part === "..") && !BLOCKED_FILE.test(value); +}, { message: "must be a safe, non-secret relative task path" }); + +const executablePath = z.string().max(512).refine( + (value) => !/[\u0000-\u001f"|<>]/.test(value), + { message: "must not contain control characters or shell metacharacters" }, +); + +const manifestSchema = z.object({ + version: z.literal(TASK_MANIFEST_VERSION), + surface: z.enum(["browser", "desktop"]), + platform: z.enum(["windows", "macos"]), + workerId: z.string().regex(ID), + taskId: z.string().regex(ID), + threadId: z.string().regex(ID), + createdAt: z.number().int().positive(), + expiresAt: z.number().int().positive(), + idleTimeoutMs: z.literal(TASK_IDLE_TIMEOUT_MS), + target: z.object({ + sshAlias: z.string().regex(ID), + basePolicySha256: z.string().regex(SHA256), + browserExecutable: executablePath, + browserProfile: z.string().min(1).max(100), + ideExecutable: executablePath, + }).strict(), + files: z.array(z.object({ + path: relativePath, + size: z.number().int().min(0).max(TASK_MAX_FILE_BYTES), + sha256: z.string().regex(SHA256), + }).strict()).max(512), + commands: z.array(z.object({ + id: z.string().regex(ID), + executable: executablePath, + argv: z.array(z.string().max(4096)).max(128), + cwd: relativePath, + timeoutMs: z.number().int().min(1_000).max(TASK_MAX_COMMAND_MS), + }).strict()).min(1).max(128), + origins: z.array(z.string().max(2048)).max(128), + resultPaths: z.array(relativePath).min(2).max(256), +}).strict(); + +export type TaskManifest = z.output; +export type TaskCommand = TaskManifest["commands"][number]; + +function canonical(value: JsonValue): JsonValue { + if (Array.isArray(value)) return value.map(canonical); + if (value === null || !(value instanceof Object)) return value; + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, child]) => [key, canonical(child)]), + ); +} + +/** Byte-for-byte the control plane's `workerTaskManifestDigest`: canonical JSON + * of the parsed document, so key order in the staged file cannot change what a + * person approved. */ +export function taskManifestDigest(document: JsonValue): string { + return createHash("sha256").update(JSON.stringify(canonical(document))).digest("hex"); +} + +/** Parse a staged manifest and re-apply the executable rules locally. + * + * `expectedSha256` is the digest the operator approved. It is checked against + * the document as staged, so nothing that reached this worker after approval + * can change what runs. */ +export function parseStagedManifest(document: JsonValue, expectedSha256: string): TaskManifest { + if (taskManifestDigest(document).toLowerCase() !== expectedSha256.toLowerCase()) { + throw new Error("staged task manifest does not match the approved digest"); + } + const parsed = manifestSchema.safeParse(document); + if (!parsed.success) { + throw new Error(`invalid staged task manifest: ${parsed.error.issues[0]?.message ?? "unparseable"}`); + } + const manifest = parsed.data; + const profile = PLATFORM_PROFILES[manifest.platform]; + + if (manifest.files.reduce((sum, file) => sum + file.size, 0) > TASK_MAX_TOTAL_BYTES) { + throw new Error("staged task files exceed 200 MB"); + } + if (manifest.surface === "browser" && manifest.origins.length === 0) { + throw new Error("a browser task requires at least one exact origin"); + } + if (manifest.surface === "desktop" && manifest.origins.length > 0) { + throw new Error("a desktop task cannot declare browser origins"); + } + + const guiApps = new Set( + [manifest.target.browserExecutable, manifest.target.ideExecutable, profile.fileManager] + .map(profile.normalize), + ); + for (const command of manifest.commands) { + if (!isAbsoluteFor(manifest.platform, command.executable)) { + throw new Error(`task executable must be an absolute path: ${command.executable}`); + } + if (profile.blockedExecutable.test(command.executable)) { + throw new Error(`task executable is forbidden: ${command.executable}`); + } + const normalized = profile.normalize(command.executable); + if (manifest.platform === "windows" && !normalized.endsWith(".exe")) { + throw new Error(`task executable must be a .exe: ${command.executable}`); + } + if (guiApps.has(normalized)) { + throw new Error(`GUI executable must be driven through CUA, not the command runner: ${command.executable}`); + } + } + return manifest; +} + +// ── the derived CUA capability ─────────────────────────────────────────────── + +/** JSON double-quoted strings are valid YAML scalars, which avoids hand-rolling + * quoting rules for Windows paths, profile names, and origins. */ +const yamlString = (value: string): string => JSON.stringify(value); + +const BROWSER_TOOLS = [ + "start_session", + "end_session", + "list_windows", + "browser_prepare", + "get_browser_state", + "browser_navigate", + "browser_click", + "browser_type", +]; + +const DESKTOP_TOOLS = [ + "start_session", + "end_session", + "launch_app", + "list_windows", + "get_window_state", + "click", + "double_click", + "right_click", + "drag", + "scroll", + "type_text", + "press_key", + "hotkey", + "set_value", + "wait", + "bring_to_front", +]; + +const app = (executable: string): string[] => [ + ` - executable: ${yamlString(executable)}`, + " launch: true", + " windows: all", + " terminate: driver_launched", +]; + +/** Rebuild the exact capability the control plane derived. `issuedAt` is the + * instant the control plane used; the lifetimes in a CUA manifest are relative, + * so without it the two ends could never agree on a digest. The caller bounds + * how far that instant may be from this worker's own clock. */ +export function taskCapabilityManifest(manifest: TaskManifest, root: string, issuedAt: number): string { + if (!isAbsoluteFor(manifest.platform, root) || /[\u0000\r\n]/.test(root)) { + throw new Error(`task root must be an absolute ${manifest.platform} path`); + } + const expiresSeconds = Math.floor((manifest.expiresAt - issuedAt) / 1_000); + if (expiresSeconds < 1) throw new Error("task capability manifest is expired"); + const idleSeconds = Math.max(1, Math.min(expiresSeconds, Math.floor(manifest.idleTimeoutMs / 1_000))); + + const head = [ + "version: 3", + `expires_after: ${expiresSeconds}s`, + `idle_timeout: ${idleSeconds}s`, + "", + "allow:", + " tools:", + ]; + + if (manifest.surface === "browser") { + return [ + ...head, + ...BROWSER_TOOLS.map((tool) => ` - ${tool}`), + "", + "resources:", + " apps:", + ...app(manifest.target.browserExecutable), + " browser:", + " profiles:", + " - kind: existing_profile", + " origins:", + ...manifest.origins.map((origin) => ` - ${yamlString(origin)}`), + " desktop:", + " display: false", + "", + ].join("\n"); + } + + return [ + ...head, + ...DESKTOP_TOOLS.map((tool) => ` - ${tool}`), + "", + "resources:", + " apps:", + ...app(manifest.target.ideExecutable), + ...app(PLATFORM_PROFILES[manifest.platform].fileManager), + " files:", + " read:", + ` - dir: ${yamlString(root)}`, + " recursive: true", + " write:", + ` - dir: ${yamlString(root)}`, + " recursive: true", + " desktop:", + " display: false", + "", + ].join("\n"); +} diff --git a/worker-companion/src/platform.ts b/worker-companion/src/platform.ts index 0ae8b18d..b75a6ccb 100644 --- a/worker-companion/src/platform.ts +++ b/worker-companion/src/platform.ts @@ -39,6 +39,17 @@ export function activeCapabilityPath(platform: WorkerPlatform = workerPlatform() return join(supportDirectory(platform), "active-capabilities.yaml"); } +const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; + +/** Where one task's staged files live. The wire names a task id, never a + * path: the id is validated against the same grammar the control plane's + * manifest schema uses, and the root is derived here so no caller can point + * the companion at a directory of its choosing. */ +export function taskRoot(taskId: string, platform: WorkerPlatform = workerPlatform()): string { + if (!TASK_ID.test(taskId)) throw new Error("invalid task id"); + return join(supportDirectory(platform), "tasks", taskId); +} + /** Fixed allow-list. Never inherit the caller's environment wholesale: the SSH * session's environment is attacker-adjacent and the driver is the one process * on this box that can drive the whole desktop. */ diff --git a/worker-companion/src/task.ts b/worker-companion/src/task.ts new file mode 100644 index 00000000..24f4103a --- /dev/null +++ b/worker-companion/src/task.ts @@ -0,0 +1,390 @@ +// The four task operations, and the two streaming subcommands that feed them. +// +// The division of labour with the control plane is deliberate. The control +// plane decides *whether* a task may run — it parses the manifest, binds it to +// a configured worker, and puts a digest in front of a person. This file +// decides *what actually happens on this machine*, and it re-derives every one +// of those facts from the staged document rather than trusting the wire: +// +// stage bytes in, path rules applied per frame, digest checked per file +// validate the staged manifest must hash to the approved digest, and the +// files on disk must be exactly the ones it names +// activate the capability is rebuilt here and must match the digest the +// control plane says it derived — neither end can widen it alone +// run a command id, never a program: the argv comes from the manifest +// +// The wire (wire.ts) can therefore name only a task id, a digest, an instant +// and a command id. It still cannot name an executable, argv, path, policy or +// capability document, which is the property the companion exists to hold. +import { createHash } from "node:crypto"; +import { + closeSync, + existsSync, + lstatSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeSync, +} from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { Buffer } from "node:buffer"; + +import { capabilityDigest, parkedCapability, writeActiveCapability } from "./capability.ts"; +import { assertDriverVersion, runFixed } from "./driver.ts"; +import { + encodeFrame, + encodeFrameHeader, + END_FRAME, + FrameReader, + MAX_FRAME_PAYLOAD_BYTES, + type FrameHeader, +} from "./frames.ts"; +import { + parseStagedManifest, + TASK_MAX_TOTAL_BYTES, + taskCapabilityManifest, + type TaskManifest, +} from "./manifest.ts"; +import { cuaSocket, taskRoot, type WorkerPlatform, workerPlatform } from "./platform.ts"; +import { asDigest, type JsonValue, type Sha256Digest } from "./wire.ts"; + +export const MANIFEST_FILE = "manifest.json"; +const READY_TIMEOUT_MS = 15_000; +const MAX_CAPTURED_OUTPUT = 64 * 1024; +/** How far the control plane's issuing clock may sit from this worker's own. + * A capability's lifetime is relative, so a wildly skewed instant would mint a + * longer-lived boundary than anyone approved. */ +export const MAX_ISSUE_SKEW_MS = 5 * 60_000; + +const SAFE_RELATIVE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,511}$/; +const BLOCKED_FILE = + /(^|\/)(?:\.git(?:\/|$)|\.env(?:\.[^/]*)?$|credentials?(?:\.[^/]*)?$|secrets?(?:\.[^/]*)?$|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.[^/]*)?$|[^/]+\.(?:key|pem|p12|pfx|keystore)$)/i; + +/** The staging path rules, applied to a frame before the manifest that will + * later confirm it has even been read. Staging cannot depend on the manifest: + * the manifest arrives in the same stream. */ +export function isSafeStagedPath(value: string): boolean { + if (!SAFE_RELATIVE.test(value) || value.includes("//") || value.endsWith("/")) return false; + const parts = value.split("/"); + if (parts.some((part) => part === "." || part === "..")) return false; + return !BLOCKED_FILE.test(value); +} + +/** Resolve a manifest-relative path under the task root, refusing anything that + * leaves it. Checked against the real root so a symlinked ancestor cannot move + * the destination. */ +export function resolveInRoot(root: string, relativePath: string): string { + if (!isAbsolute(root)) throw new Error("task root must be absolute"); + const candidate = resolve(root, ...relativePath.split("/")); + const within = relative(root, candidate); + if (!within || within === ".." || within.startsWith(`..${sep}`) || isAbsolute(within)) { + throw new Error(`task path escapes the task root: ${relativePath}`); + } + return candidate; +} + +// ── stage ──────────────────────────────────────────────────────────────────── + +export interface StageResult { + manifestBytes: number; + files: number; +} + +/** Consume a staging stream into a fresh task root. + * + * Like every other operation here it takes an id and derives its own root; the + * optional platform exists so a test can drive both platforms' layouts on one + * machine, exactly as `parkedCapability` and `activeCapabilityPath` do. + * + * The root is removed first: a task id is reusable, and merging new files into + * an older stage would let a previous task's leftovers satisfy this one's + * validation. */ +export function stageTask( + taskId: string, + input: NodeJS.ReadableStream, + platform: WorkerPlatform = workerPlatform(), +): Promise { + const root = taskRoot(taskId, platform); + return new Promise((resolveResult, reject) => { + rmSync(root, { recursive: true, force: true }); + mkdirSync(root, { recursive: true, mode: 0o700 }); + + let manifestBytes = -1; + let files = 0; + let total = 0; + let fd: number | null = null; + let hash = createHash("sha256"); + let written = 0; + let settled = false; + + const closeFd = () => { + if (fd !== null) { + closeSync(fd); + fd = null; + } + }; + const fail = (error: Error) => { + if (settled) return; + settled = true; + closeFd(); + rmSync(root, { recursive: true, force: true }); + reject(error); + }; + + const reader = new FrameReader({ + onHeader(header: FrameHeader) { + if (header.kind === "end") return; + const path = header.kind === "manifest" ? MANIFEST_FILE : (header.path ?? ""); + if (header.kind === "manifest" && manifestBytes >= 0) throw new Error("duplicate manifest frame"); + if (header.kind === "file") { + if (!isSafeStagedPath(path)) throw new Error(`unsafe staged path: ${path}`); + if (path === MANIFEST_FILE) throw new Error("a staged file cannot shadow the manifest"); + } + total += header.bytes; + if (total > TASK_MAX_TOTAL_BYTES) throw new Error("staged task files exceed 200 MB"); + const target = resolveInRoot(root, path); + mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); + if (existsSync(target)) throw new Error(`duplicate staged path: ${path}`); + // wx: never follow an existing entry, so a symlink planted between the + // check above and this open cannot redirect the write. + fd = openSync(target, "wx", 0o600); + hash = createHash("sha256"); + written = 0; + }, + onPayload(chunk: Buffer) { + if (fd === null) throw new Error("payload arrived outside a frame"); + writeSync(fd, chunk); + hash.update(chunk); + written += chunk.length; + }, + onFrameEnd(header: FrameHeader) { + if (header.kind === "end") return; + closeFd(); + if (written !== header.bytes) throw new Error("staged file is shorter than its frame declared"); + if (header.kind === "manifest") { + manifestBytes = written; + return; + } + const digest = hash.digest("hex"); + if (digest !== (header.sha256 ?? "").toLowerCase()) { + throw new Error(`staged file hash does not match: ${header.path ?? "?"}`); + } + files += 1; + }, + }); + + input.on("data", (chunk: Buffer | string) => { + if (settled) return; + try { + reader.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + } + }); + input.on("error", (error: Error) => fail(error)); + input.on("end", () => { + if (settled) return; + try { + reader.end(); + if (manifestBytes < 0) throw new Error("staging stream carried no manifest"); + settled = true; + resolveResult({ manifestBytes, files }); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + } + }); + }); +} + +// ── validate ───────────────────────────────────────────────────────────────── + +function readStagedDocument(root: string): JsonValue { + const raw = readFileSync(join(root, MANIFEST_FILE), "utf8"); + // SAFETY: JSON.parse without a reviver can only produce JSON-compatible + // values, which is exactly what JsonValue describes. + return JSON.parse(raw) as JsonValue; +} + +/** Every regular file under the root, as manifest-relative paths. */ +function stagedPaths(root: string): string[] { + return readdirSync(root, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => relative(root, join(entry.parentPath, entry.name)).split(sep).join("/")); +} + +export interface ValidatedTask { + manifest: TaskManifest; + root: string; +} + +/** Re-establish, from disk alone, that this worker holds exactly the task the + * operator approved. Called before every activate and every run: an approval is + * not a fact about the past, it is a claim about what is on this machine now. */ +export function validateTask( + taskId: string, + manifestSha256: string, + platform: WorkerPlatform = workerPlatform(), +): ValidatedTask { + const root = taskRoot(taskId, platform); + if (!existsSync(join(root, MANIFEST_FILE))) throw new Error("no task is staged under that id"); + const manifest = parseStagedManifest(readStagedDocument(root), manifestSha256); + if (manifest.taskId !== taskId) throw new Error("staged manifest names a different task"); + + // Every declared input must still be exactly what was approved. Files the + // manifest does not name are NOT an error: the task writes its own build + // output and its result artefacts into this same root, so demanding an exact + // file set would make a task's own success look like tampering. Nothing is + // lost by allowing them — `run` only ever executes an absolute executable + // and argv out of the approved document, and `fetchResults` reads only the + // paths that document declares as results. + const present = new Set(stagedPaths(root)); + for (const file of manifest.files) { + if (!present.has(file.path)) throw new Error(`a file the approved manifest names is missing: ${file.path}`); + } + + for (const file of manifest.files) { + const target = resolveInRoot(root, file.path); + const stat = lstatSync(target); + if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`staged file is not a regular file: ${file.path}`); + if (stat.size !== file.size) throw new Error(`staged file size changed: ${file.path}`); + const digest = createHash("sha256").update(readFileSync(target)).digest("hex"); + if (digest !== file.sha256.toLowerCase()) throw new Error(`staged file hash changed: ${file.path}`); + } + return { manifest, root }; +} + +// ── activate ───────────────────────────────────────────────────────────────── + +/** Poll the daemon until it reports the exact boundary it was asked to hold. + * A driver that quietly loaded some other capability never satisfies this. */ +async function awaitActiveCapability( + capability: Sha256Digest, + basePolicy: string, + platform: WorkerPlatform, +): Promise { + const socket = cuaSocket(platform); + await runFixed("cua-driver", ["autostart", "kick"], READY_TIMEOUT_MS, true); + const deadline = Date.now() + READY_TIMEOUT_MS; + let diagnostic = ""; + for (;;) { + const status = await runFixed("cua-driver", ["status", "--socket", socket], 5_000, true); + diagnostic = `${status.stdout}\n${status.stderr}`.toLowerCase(); + if ( + status.code === 0 && + diagnostic.includes(capability) && + diagnostic.includes(basePolicy.toLowerCase()) && + diagnostic.includes("bounded") + ) return; + if (Date.now() >= deadline) break; + await new Promise((wait) => setTimeout(wait, 250)); + } + throw new Error(`approved CUA capability did not become active: ${diagnostic.trim().slice(-300) || "no status"}`); +} + +export async function activateTask( + taskId: string, + manifestSha256: string, + issuedAt: number, + expectedCapabilitySha256: Sha256Digest, + now = Date.now(), + platform: WorkerPlatform = workerPlatform(), +): Promise { + const { manifest, root } = validateTask(taskId, manifestSha256, platform); + if (Math.abs(now - issuedAt) > MAX_ISSUE_SKEW_MS) { + throw new Error("task capability was issued too far from this worker's clock"); + } + // Every deterministic check runs before the daemon is touched: a request + // that cannot succeed should not disturb a running driver, and it makes the + // refusals testable without one. + const content = taskCapabilityManifest(manifest, root, issuedAt); + const digest = asDigest(capabilityDigest(content)); + if (digest !== expectedCapabilitySha256.toLowerCase()) { + throw new Error("derived CUA capability does not match the approved digest"); + } + await assertDriverVersion(); + writeActiveCapability(content, platform); + await awaitActiveCapability(digest, manifest.target.basePolicySha256, platform); + return digest; +} + +// ── run ────────────────────────────────────────────────────────────────────── + +export interface CommandResult { + commandId: string; + code: number | null; + stdout: string; + stderr: string; +} + +export async function runTaskCommand( + taskId: string, + manifestSha256: string, + commandId: string, + platform: WorkerPlatform = workerPlatform(), +): Promise { + const { manifest, root } = validateTask(taskId, manifestSha256, platform); + const command = manifest.commands.find((entry) => entry.id === commandId); + if (!command) throw new Error("the approved manifest has no command with that id"); + + const cwd = resolveInRoot(root, command.cwd); + if (!statSync(cwd).isDirectory()) throw new Error(`command working directory is not a directory: ${command.cwd}`); + + const result = await runFixed(command.executable, command.argv, command.timeoutMs, true, { cwd }); + return { + commandId, + code: result.code, + stdout: result.stdout.slice(-MAX_CAPTURED_OUTPUT), + stderr: result.stderr.slice(-MAX_CAPTURED_OUTPUT), + }; +} + +// ── reset ──────────────────────────────────────────────────────────────────── + +/** Drop the task's files and put the worker back on the deny-all capability. + * Reset is the operation that must work even when everything else has failed, + * so it removes the root before it touches the daemon. */ +export async function resetTask( + taskId: string, + expectedBasePolicySha256: Sha256Digest, + platform: WorkerPlatform = workerPlatform(), +): Promise { + rmSync(taskRoot(taskId, platform), { recursive: true, force: true }); + await assertDriverVersion(); + const content = parkedCapability(platform); + writeActiveCapability(content, platform); + const digest = asDigest(capabilityDigest(content)); + await awaitActiveCapability(digest, expectedBasePolicySha256, platform); + return digest; +} + +// ── fetch ──────────────────────────────────────────────────────────────────── + +/** Stream the task's declared result artefacts back as frames. Only paths the + * approved manifest names are readable, and a result the task never produced is + * simply absent rather than an error. */ +export function fetchResults( + taskId: string, + manifestSha256: string, + output: NodeJS.WritableStream, + platform: WorkerPlatform = workerPlatform(), +): void { + const { manifest, root } = validateTask(taskId, manifestSha256, platform); + for (const path of manifest.resultPaths) { + const target = resolveInRoot(root, path); + if (!existsSync(target)) continue; + const stat = lstatSync(target); + if (stat.isSymbolicLink() || !stat.isFile()) continue; + if (stat.size > MAX_FRAME_PAYLOAD_BYTES) throw new Error(`result artefact is too large: ${path}`); + const payload = readFileSync(target); + const sha256 = createHash("sha256").update(payload).digest("hex"); + output.write(encodeFrame({ kind: "file", bytes: payload.length, path, sha256 }, payload)); + } + output.write(END_FRAME); +} + +/** Exported for the frame-parity test, which needs to build a header without + * writing one to a stream. */ +export { encodeFrameHeader }; diff --git a/worker-companion/src/wire.ts b/worker-companion/src/wire.ts index 690de5a6..df5db499 100644 --- a/worker-companion/src/wire.ts +++ b/worker-companion/src/wire.ts @@ -34,6 +34,8 @@ export type Sha256Digest = z.output; const versionSchema = z.literal(PROTOCOL_VERSION).optional(); +const idSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/, "invalid id"); + const requestSchema = z.discriminatedUnion("op", [ z.object({ version: versionSchema, op: z.literal("pause") }), z.object({ @@ -41,7 +43,42 @@ const requestSchema = z.discriminatedUnion("op", [ op: z.literal("resume"), expectedBasePolicySha256: digestSchema, }), - // reset / validate / activate / run arrive with the server-side task layer. + // The task operations. Note what these can and cannot say: a task id, a + // digest, an instant, a command id. Never an executable, argv, working + // directory, environment variable, path, policy or capability document — + // every one of those is read back out of the staged manifest the digest + // pins, so the wire can select an approved action but never describe a new + // one. + z.object({ + version: versionSchema, + op: z.literal("reset"), + taskId: idSchema, + expectedBasePolicySha256: digestSchema, + }), + z.object({ + version: versionSchema, + op: z.literal("validate"), + taskId: idSchema, + manifestSha256: digestSchema, + }), + z.object({ + version: versionSchema, + op: z.literal("activate"), + taskId: idSchema, + manifestSha256: digestSchema, + /** The instant the control plane derived the capability. A CUA manifest's + * lifetimes are relative, so both ends need the same one to agree on a + * digest; task.ts bounds how far it may sit from the worker's own clock. */ + issuedAt: z.number().int().positive(), + expectedCapabilitySha256: digestSchema, + }), + z.object({ + version: versionSchema, + op: z.literal("run"), + taskId: idSchema, + manifestSha256: digestSchema, + commandId: idSchema, + }), ]); export type CompanionRequest = z.output; @@ -54,6 +91,40 @@ export type CompanionResponse = readonly paused: false; readonly capabilitySha256: Sha256Digest; } + | { + readonly ok: true; + readonly version: number; + readonly op: "validate"; + /** The task root this worker derived for itself. The control plane needs + * the exact string to rebuild the same capability document, and cannot + * know the worker account's home directory any other way. It is a hint, + * not an authority: `activate` rebuilds the capability against this + * worker's own root, so a report that does not match simply fails. */ + readonly taskRoot: string; + readonly files: number; + readonly commandIds: readonly string[]; + } + | { + readonly ok: true; + readonly version: number; + readonly op: "activate" | "reset"; + readonly capabilitySha256: Sha256Digest; + } + | { + readonly ok: true; + readonly version: number; + readonly op: "run"; + readonly commandId: string; + readonly code: number | null; + readonly stdout: string; + readonly stderr: string; + } + | { readonly ok: false; readonly error: string }; + +/** The reply to a `stage` invocation, which is a subcommand rather than a wire + * operation because its payload is a raw byte stream. */ +export type StageResponse = + | { readonly ok: true; readonly version: number; readonly op: "stage"; readonly files: number } | { readonly ok: false; readonly error: string }; /** Brand a digest this process computed itself. */ diff --git a/worker-companion/test/companion.test.ts b/worker-companion/test/companion.test.ts index eedc93ff..6c31c0c0 100644 --- a/worker-companion/test/companion.test.ts +++ b/worker-companion/test/companion.test.ts @@ -160,11 +160,28 @@ describe("stdio request parsing", () => { it.each([ ["unknown op", '{"op":"exfiltrate"}'], ["missing op", "{}"], - ["task-layer op not in this release", '{"op":"run","taskId":"t","commandId":"c"}'], ])("rejects %s", (_label, line) => { expect(() => parseRequest(line)).toThrow("unsupported operation"); }); + // The task operations landed with the server-side task layer. What still has + // to hold is the shape of their vocabulary: an id, a digest, an instant, a + // command id — and nothing that could name a program or a path. + it.each([ + ["run with no digest", '{"op":"run","taskId":"t","commandId":"c"}'], + ["validate with a short digest", `{"op":"validate","taskId":"t","manifestSha256":"${"a".repeat(63)}"}`], + ["reset with no task id", `{"op":"reset","expectedBasePolicySha256":"${"a".repeat(64)}"}`], + ["activate with no issuing instant", `{"op":"activate","taskId":"t","manifestSha256":"${"a".repeat(64)}","expectedCapabilitySha256":"${"b".repeat(64)}"}`], + ["a task id that is a path", `{"op":"validate","taskId":"../etc","manifestSha256":"${"a".repeat(64)}"}`], + ])("rejects %s", (_label, line) => { + expect(() => parseRequest(line)).toThrow(); + }); + + it("accepts a well-formed run", () => { + const request = parseRequest(`{"op":"run","taskId":"task-1","manifestSha256":"${"a".repeat(64)}","commandId":"build"}`); + expect(request.op).toBe("run"); + }); + it.each([ ["absent", '{"op":"resume"}'], ["too short", `{"op":"resume","expectedBasePolicySha256":"${"a".repeat(63)}"}`], diff --git a/worker-companion/test/task.test.ts b/worker-companion/test/task.test.ts new file mode 100644 index 00000000..22fbd1a4 --- /dev/null +++ b/worker-companion/test/task.test.ts @@ -0,0 +1,264 @@ +// The companion half of the task layer, driven with real bytes against a real +// task root under a temporary home. Nothing here talks to a CUA daemon: the +// operations that need one are exercised up to the point where they would. +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { encodeFrame, END_FRAME, type FrameHeader } from "../src/frames.ts"; +import { taskManifestDigest } from "../src/manifest.ts"; +import { taskRoot, type WorkerPlatform } from "../src/platform.ts"; +import { + fetchResults, + isSafeStagedPath, + MANIFEST_FILE, + resolveInRoot, + runTaskCommand, + stageTask, + validateTask, +} from "../src/task.ts"; +import { asDigest } from "../src/wire.ts"; +import { HOST_TASK_PLATFORM, parsedManifest } from "../../server/testing/worker-task.ts"; +import { workerTaskManifestJson } from "../../server/worker-task-manifest.ts"; +import type { JsonValue } from "../../server/schema.ts"; + +/** The companion's own spelling of the platform this host can lay out paths + * for; the server fixture's `HOST_TASK_PLATFORM` is the same choice in the + * manifest's vocabulary. */ +const PLATFORM: WorkerPlatform = process.platform === "win32" ? "win32" : "darwin"; + +const TASK_ID = "task-1"; +const body = Buffer.from("hello worker", "utf8"); +const bodyDigest = createHash("sha256").update(body).digest("hex"); + +let home = ""; +let saved: Record = {}; + +function manifestDocument(overrides: Record = {}): JsonValue { + const manifest = parsedManifest(HOST_TASK_PLATFORM, { + files: [{ path: "src/main.txt", size: body.length, sha256: bodyDigest }], + ...overrides, + }); + return JSON.parse(workerTaskManifestJson(manifest)) as JsonValue; +} + +/** The exact byte stream `stageWorkerTask` would have written. */ +function stagingStream(document: JsonValue, files: { header: FrameHeader; payload: Buffer }[]): Buffer { + const json = Buffer.from(JSON.stringify(document), "utf8"); + return Buffer.concat([ + encodeFrame({ kind: "manifest", bytes: json.length }, json), + ...files.map((file) => encodeFrame(file.header, file.payload)), + END_FRAME, + ]); +} + +async function stage(bytes: Buffer, taskId = TASK_ID) { + const input = new PassThrough(); + const staged = stageTask(taskId, input, PLATFORM); + input.end(bytes); + return staged; +} + +const goodFile = { header: { kind: "file", bytes: body.length, path: "src/main.txt", sha256: bodyDigest } as FrameHeader, payload: body }; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "omb-worker-")); + // os.homedir() reads HOME on POSIX and USERPROFILE on Windows; the Windows + // layout reads LOCALAPPDATA directly. Set all three so either layout lands + // under the temporary directory whatever host this runs on. + saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, LOCALAPPDATA: process.env.LOCALAPPDATA }; + process.env.HOME = home; + process.env.USERPROFILE = home; + process.env.LOCALAPPDATA = home; +}); + +afterEach(() => { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + rmSync(home, { recursive: true, force: true }); +}); + +describe("staged path rules", () => { + it.each(["src/main.txt", "a/b/c.json", "Makefile"])("accepts %s", (path) => { + expect(isSafeStagedPath(path)).toBe(true); + }); + + it.each([ + "../escape.txt", + "a/../../b", + "/absolute", + "a//b", + "trailing/", + ".env", + "config/.env.local", + "deploy/id_rsa", + "certs/server.pem", + "app/credentials.json", + ])("refuses %s", (path) => { + expect(isSafeStagedPath(path)).toBe(false); + }); + + it("refuses a path that would escape the root even if it passed the grammar", () => { + expect(() => resolveInRoot("/tmp/root", "..")).toThrow(/escapes the task root/); + }); +}); + +describe("stage", () => { + it("writes the manifest and every declared file", async () => { + const result = await stage(stagingStream(manifestDocument(), [goodFile])); + expect(result.files).toBe(1); + const root = taskRoot(TASK_ID, PLATFORM); + expect(readFileSync(join(root, "src", "main.txt"), "utf8")).toBe("hello worker"); + expect(JSON.parse(readFileSync(join(root, MANIFEST_FILE), "utf8")).taskId).toBe(TASK_ID); + }); + + it("refuses a file whose bytes do not hash to what the frame declared", async () => { + const tampered = { header: { ...goodFile.header, sha256: "f".repeat(64) } as FrameHeader, payload: body }; + await expect(stage(stagingStream(manifestDocument(), [tampered]))).rejects.toThrow(/hash does not match/); + }); + + it("refuses a path that escapes the task root", async () => { + const escape = { + header: { kind: "file", bytes: body.length, path: "../escape.txt", sha256: bodyDigest } as FrameHeader, + payload: body, + }; + await expect(stage(stagingStream(manifestDocument(), [escape]))).rejects.toThrow(/unsafe staged path/); + }); + + it("refuses a staged file that would shadow the manifest", async () => { + const shadow = { + header: { kind: "file", bytes: body.length, path: MANIFEST_FILE, sha256: bodyDigest } as FrameHeader, + payload: body, + }; + await expect(stage(stagingStream(manifestDocument(), [shadow]))).rejects.toThrow(/shadow the manifest/); + }); + + it("refuses a stream with no manifest at all", async () => { + await expect(stage(Buffer.concat([encodeFrame(goodFile.header, goodFile.payload), END_FRAME]))) + .rejects.toThrow(/carried no manifest/); + }); + + it("leaves nothing behind when a stage fails part way", async () => { + const tampered = { header: { ...goodFile.header, sha256: "f".repeat(64) } as FrameHeader, payload: body }; + await expect(stage(stagingStream(manifestDocument(), [tampered]))).rejects.toThrow(); + expect(() => readFileSync(join(taskRoot(TASK_ID, PLATFORM), MANIFEST_FILE))).toThrow(); + }); + + it("replaces an earlier stage rather than merging into it", async () => { + await stage(stagingStream(manifestDocument(), [goodFile])); + const document = manifestDocument({ files: [] }); + await stage(stagingStream(document, [])); + expect(() => readFileSync(join(taskRoot(TASK_ID, PLATFORM), "src", "main.txt"))).toThrow(); + }); +}); + +describe("validate", () => { + it("accepts a stage that matches the approved digest exactly", async () => { + const document = manifestDocument(); + await stage(stagingStream(document, [goodFile])); + const validated = validateTask(TASK_ID, taskManifestDigest(document), PLATFORM); + expect(validated.manifest.commands[0].id).toBe("build"); + }); + + it("refuses a digest that is not the one approved", async () => { + await stage(stagingStream(manifestDocument(), [goodFile])); + expect(() => validateTask(TASK_ID, "f".repeat(64), PLATFORM)) + .toThrow(/does not match the approved digest/); + }); + + it("refuses a file changed after staging", async () => { + const document = manifestDocument(); + await stage(stagingStream(document, [goodFile])); + writeFileSync(join(taskRoot(TASK_ID, PLATFORM), "src", "main.txt"), "tampered after approval"); + expect(() => validateTask(TASK_ID, taskManifestDigest(document), PLATFORM)) + .toThrow(/size changed|hash changed/); + }); + + it("refuses a declared input that disappeared", async () => { + const document = manifestDocument(); + await stage(stagingStream(document, [goodFile])); + rmSync(join(taskRoot(TASK_ID, PLATFORM), "src", "main.txt")); + expect(() => validateTask(TASK_ID, taskManifestDigest(document), PLATFORM)) + .toThrow(/is missing/); + }); + + it("allows files the task wrote itself, which is what build output and results are", async () => { + // An exact-file-set rule would make a task's own success read as tampering: + // result.json and changes.patch are written into this same root by the very + // commands the manifest approved. + const document = manifestDocument(); + await stage(stagingStream(document, [goodFile])); + writeFileSync(join(taskRoot(TASK_ID, PLATFORM), "result.json"), '{"ok":true}'); + writeFileSync(join(taskRoot(TASK_ID, PLATFORM), "src", "main.o"), "object code"); + expect(validateTask(TASK_ID, taskManifestDigest(document), PLATFORM).manifest.files).toHaveLength(1); + }); + + it("refuses when nothing is staged under that id", () => { + expect(() => validateTask("never-staged", "a".repeat(64), PLATFORM)).toThrow(/no task is staged/); + }); +}); + +describe("run", () => { + it("runs the approved command by id, inside the task root", async () => { + const document = manifestDocument(); + await stage(stagingStream(document, [goodFile])); + const result = await runTaskCommand(TASK_ID, taskManifestDigest(document), "build", PLATFORM); + expect(result.commandId).toBe("build"); + expect(result.code).toBe(0); + }); + + it("refuses a command id the approved manifest does not name", async () => { + const document = manifestDocument(); + await stage(stagingStream(document, [goodFile])); + await expect(runTaskCommand(TASK_ID, taskManifestDigest(document), "deploy", PLATFORM)) + .rejects.toThrow(/no command with that id/); + }); + + it("re-validates before running, so a file changed after approval stops the command", async () => { + const document = manifestDocument(); + await stage(stagingStream(document, [goodFile])); + writeFileSync(join(taskRoot(TASK_ID, PLATFORM), "src", "main.txt"), "swapped"); + await expect(runTaskCommand(TASK_ID, taskManifestDigest(document), "build", PLATFORM)) + .rejects.toThrow(/size changed|hash changed/); + }); +}); + +describe("fetch results", () => { + it("returns only the artefacts the approved manifest declares", async () => { + const document = manifestDocument(); + await stage(stagingStream(document, [goodFile])); + const root = taskRoot(TASK_ID, PLATFORM); + writeFileSync(join(root, "result.json"), '{"ok":true}'); + writeFileSync(join(root, "changes.patch"), "diff --git a b\n"); + writeFileSync(join(root, "notes.txt"), "not declared as a result"); + + const chunks: Buffer[] = []; + const sink = new PassThrough(); + sink.on("data", (chunk: Buffer) => chunks.push(chunk)); + fetchResults(TASK_ID, asDigest(taskManifestDigest(document)), sink, PLATFORM); + sink.end(); + + const stream = Buffer.concat(chunks); + expect(stream.includes(Buffer.from("result.json"))).toBe(true); + expect(stream.includes(Buffer.from("changes.patch"))).toBe(true); + expect(stream.includes(Buffer.from("notes.txt"))).toBe(false); + expect(stream.subarray(stream.length - END_FRAME.length).equals(END_FRAME)).toBe(true); + }); + + it("emits only an end frame when the task produced nothing", async () => { + const document = manifestDocument(); + await stage(stagingStream(document, [goodFile])); + const chunks: Buffer[] = []; + const sink = new PassThrough(); + sink.on("data", (chunk: Buffer) => chunks.push(chunk)); + fetchResults(TASK_ID, asDigest(taskManifestDigest(document)), sink, PLATFORM); + sink.end(); + expect(Buffer.concat(chunks).equals(END_FRAME)).toBe(true); + }); +}); From 4ae715c2988326e548ecc5cc5531ae1dc6fe629e Mon Sep 17 00:00:00 2001 From: gus <42593099+lightcloud00@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:33:56 -0400 Subject: [PATCH 09/10] fix(workers): widen the two narrow copies of the scope and computer unions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge with upstream main surfaced two places that keep their own narrower copy of a union this stack widens, and CI caught both as assignment failures rather than as anything semantic: - `ReviewContext.approvalScope` was `"local-computer" | undefined`. The check it feeds is a bare `=== undefined`, so a remote worker's desktop is already excluded from auto-review on exactly the ground the user's own screen is — only the type needed to say so. - `LocalVmWorkspaceBot.computer` omitted "worker". A worker bot is never eligible for the Local VM workspace (the filters select "vm"), but the app's Bot type is one union, so a narrower copy makes every Bot[] fail to assign. Both are type-only; neither changes a runtime decision. --- server/auto-review.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/auto-review.ts b/server/auto-review.ts index 47c3ea1c..29daf083 100644 --- a/server/auto-review.ts +++ b/server/auto-review.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { parseJson } from "./schema.ts"; import type { AutoVerdictSource } from "./auto-approve.ts"; +import type { ApprovalScope } from "./contracts.ts"; export type AutoReviewMode = "off" | "shadow" | "enforce"; @@ -23,7 +24,7 @@ export interface ReviewContext { source: AutoVerdictSource | undefined; mode: AutoReviewMode; unattended: boolean; - approvalScope: "local-computer" | undefined; + approvalScope: ApprovalScope | undefined; } export function resolveAutoReviewMode(stored: string | undefined): AutoReviewMode { @@ -31,8 +32,10 @@ export function resolveAutoReviewMode(stored: string | undefined): AutoReviewMod } /** Review is a last resort for an ordinary attended permission card. - * Existing decisions, unattended turns, host-computer access, and questions - * remain exclusively human/rule controlled. */ + * Existing decisions, unattended turns, real-desktop access, and questions + * remain exclusively human/rule controlled. The scope check below is a bare + * `=== undefined`, so a remote worker's desktop is excluded on exactly the + * same ground the user's own screen is. */ export function shouldReview(context: ReviewContext): boolean { return ( context.mode !== "off" && From f7c8d85c000d3593d8355dce3e613b624e58b302 Mon Sep 17 00:00:00 2001 From: gus <42593099+lightcloud00@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:49:05 -0400 Subject: [PATCH 10/10] fix(workers): make the companion's task path runnable off a worker host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI-only failures, one per platform, both in the same test: - On Linux, `runFixed` called `childEnvironment()` with no argument, which falls through to `workerPlatform()` and throws `unsupported worker platform: linux`. The platform was threaded through every task operation except the last hop into the process boundary. It now reaches `childEnvironment` and `assertDriverVersion` too, so the whole chain can be driven from a host that is neither macOS nor Windows — which is what CI is. - On Windows, the fixture's argv was POSIX-shaped: `hostname.exe hello` tries to SET the machine name and exits 1 without admin rights. The two platforms cannot share one argv, so the fixture now supplies each its own. Neither is reachable on a real worker, which is always macOS or Windows and always runs a real command. Both are worth fixing anyway: the fake-worker protocol tests are #508's acceptance item 8, and they only mean something if they run on all three CI platforms. --- server/testing/worker-task.ts | 7 ++++++- worker-companion/src/driver.ts | 15 ++++++++++----- worker-companion/src/task.ts | 10 +++++----- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/server/testing/worker-task.ts b/server/testing/worker-task.ts index c0643063..b69a2450 100644 --- a/server/testing/worker-task.ts +++ b/server/testing/worker-task.ts @@ -25,6 +25,11 @@ export const HARMLESS_EXECUTABLE = process.platform === "win32" ? "C:\\Windows\\System32\\hostname.exe" : "/bin/echo"; +/** Argv that makes the executable above exit 0. `hostname` with an argument + * tries to SET the machine name and exits 1 without admin rights, so the two + * platforms cannot share one argv. */ +export const HARMLESS_ARGV = process.platform === "win32" ? [] : ["hello"]; + export function workerFixture( platform: WorkerPlatform = HOST_TASK_PLATFORM, overrides: Partial = {}, @@ -66,7 +71,7 @@ export function manifestFixture( commands: [{ id: "build", executable: HARMLESS_EXECUTABLE, - argv: ["hello"], + argv: HARMLESS_ARGV, cwd: "src", timeoutMs: 60_000, }], diff --git a/worker-companion/src/driver.ts b/worker-companion/src/driver.ts index ea952a04..18e76203 100644 --- a/worker-companion/src/driver.ts +++ b/worker-companion/src/driver.ts @@ -8,7 +8,7 @@ import { spawn } from "node:child_process"; import { capabilityDigest, parkedCapability, writeActiveCapability } from "./capability.ts"; -import { childEnvironment, cuaSocket } from "./platform.ts"; +import { childEnvironment, cuaSocket, type WorkerPlatform } from "./platform.ts"; import { asDigest, type Sha256Digest } from "./wire.ts"; export const EXPECTED_DRIVER_VERSION = "0.20.0"; @@ -21,6 +21,10 @@ export interface RunOptions { /** Working directory for the child. Only ever a directory the caller has * already resolved inside an approved task root — never a path off the wire. */ cwd?: string; + /** Which platform's environment allow-list to build. Omitted on a real + * worker, where the host's own platform is the answer; supplied by tests, + * which run on Linux too and would otherwise trip `workerPlatform()`. */ + platform?: WorkerPlatform; } export function runFixed( @@ -31,19 +35,20 @@ export function runFixed( options: RunOptions = {}, ): Promise { return new Promise((resolveResult, reject) => { + const environment = childEnvironment(options.platform); // Two calls rather than one with a conditional spread: an absent cwd is an // omission the reader can see, and the literal keeps its exact stdio tuple // type so `child.stdout` and `child.stderr` stay non-null below. const child = options.cwd === undefined ? spawn(executable, args, { shell: false, - env: childEnvironment(), + env: environment, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }) : spawn(executable, args, { shell: false, - env: childEnvironment(), + env: environment, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], cwd: options.cwd, @@ -77,8 +82,8 @@ export function runFixed( * tool semantics than the capability manifests were written against, and the * control plane refuses the worker anyway, so refuse it here with a message * that names the mismatch. */ -export async function assertDriverVersion(): Promise { - const result = await runFixed("cua-driver", ["--version"], 10_000); +export async function assertDriverVersion(platform?: WorkerPlatform): Promise { + const result = await runFixed("cua-driver", ["--version"], 10_000, false, { platform }); const match = `${result.stdout}\n${result.stderr}`.match(/\b(\d+\.\d+\.\d+)\b/); if (match?.[1] !== EXPECTED_DRIVER_VERSION) { throw new Error(`CUA Driver ${match?.[1] ?? "missing"} does not match required ${EXPECTED_DRIVER_VERSION}`); diff --git a/worker-companion/src/task.ts b/worker-companion/src/task.ts index 24f4103a..1158b536 100644 --- a/worker-companion/src/task.ts +++ b/worker-companion/src/task.ts @@ -266,11 +266,11 @@ async function awaitActiveCapability( platform: WorkerPlatform, ): Promise { const socket = cuaSocket(platform); - await runFixed("cua-driver", ["autostart", "kick"], READY_TIMEOUT_MS, true); + await runFixed("cua-driver", ["autostart", "kick"], READY_TIMEOUT_MS, true, { platform }); const deadline = Date.now() + READY_TIMEOUT_MS; let diagnostic = ""; for (;;) { - const status = await runFixed("cua-driver", ["status", "--socket", socket], 5_000, true); + const status = await runFixed("cua-driver", ["status", "--socket", socket], 5_000, true, { platform }); diagnostic = `${status.stdout}\n${status.stderr}`.toLowerCase(); if ( status.code === 0 && @@ -304,7 +304,7 @@ export async function activateTask( if (digest !== expectedCapabilitySha256.toLowerCase()) { throw new Error("derived CUA capability does not match the approved digest"); } - await assertDriverVersion(); + await assertDriverVersion(platform); writeActiveCapability(content, platform); await awaitActiveCapability(digest, manifest.target.basePolicySha256, platform); return digest; @@ -332,7 +332,7 @@ export async function runTaskCommand( const cwd = resolveInRoot(root, command.cwd); if (!statSync(cwd).isDirectory()) throw new Error(`command working directory is not a directory: ${command.cwd}`); - const result = await runFixed(command.executable, command.argv, command.timeoutMs, true, { cwd }); + const result = await runFixed(command.executable, command.argv, command.timeoutMs, true, { cwd, platform }); return { commandId, code: result.code, @@ -352,7 +352,7 @@ export async function resetTask( platform: WorkerPlatform = workerPlatform(), ): Promise { rmSync(taskRoot(taskId, platform), { recursive: true, force: true }); - await assertDriverVersion(); + await assertDriverVersion(platform); const content = parkedCapability(platform); writeActiveCapability(content, platform); const digest = asDigest(capabilityDigest(content));