diff --git a/docs/unattended-work-adapter.md b/docs/unattended-work-adapter.md new file mode 100644 index 000000000..762f7468c --- /dev/null +++ b/docs/unattended-work-adapter.md @@ -0,0 +1,19 @@ +# Dormant Hermes work-queue adapter + +OpenMausBot exposes a narrow local submission and status surface for the AOS unattended-work plane. It never executes a card. Hermes remains the sole executor, and the source-owned work plane owns request validation, idempotency, quarantine, leases, dispatch, and publishing gates. + +The adapter proxies only these fixed routes to `http://127.0.0.1:8817`: + +- `GET /health` +- `POST /v1/work` +- `GET /v1/work/` + +The normal OpenMausBot server exposes them locally as `/api/unattended-work/health`, `/api/unattended-work`, and `/api/unattended-work/`. `OMB_UNATTENDED_WORK_ENABLED=1` is required before submit or status calls can leave the OpenMausBot process. Any other value, including `true`, remains disabled. + +## Isolated adapter runtime + +`scripts/install-unattended-adapter.mjs` accepts one exact committed source SHA. It refuses a dirty or drifted worktree, rebuilds the standalone queue page and self-contained server bundle from that checkout, and packages those runtime artifacts with a `source.tar` archive produced by `git archive` for exact-source audit and reproducibility. The archive is provenance material, not an executable runtime input. The installer also creates a separate data home, assigns ports 8827 and 8828, and renders `com.gus.aos-unattended-openmausbot.plist` with `RunAtLoad=false` and `KeepAlive=false`. It does not bootstrap the LaunchAgent or replace `/Applications/OpenMausBot.app`. + +The rendered runtime sets `OMB_UNATTENDED_ADAPTER_ONLY=1`. In this mode OpenMausBot loads zero provider instances, starts no routine scheduler, opens no webhook listener, and returns 404 for every API except application health and unattended-work health, submit, and status. + +The receipt deliberately reports `source_ready=true`, `dormant_ready=true`, and `live_accepted=false`. Live activation remains a separate attended gate. It requires explicit surface selection plus fresh lane, credential, lease, provider, private Telegram readback, issue-to-draft-PR, and live-soak evidence. The adapter never gains merge, deployment, release, upload, provider-change, credential-value, external-send, force-push, destructive-cleanup, protected-branch, or out-of-worktree authority. diff --git a/package.json b/package.json index 29d66525e..87b575032 100644 --- a/package.json +++ b/package.json @@ -33,14 +33,16 @@ "dev:desktop": "electron .", "build": "tsc -b && tsc -p tsconfig.server.json && vite build", "typecheck": "tsc -b && tsc -p tsconfig.server.json", - "test": "node scripts/test-floor.mjs && pnpm broker:test && pnpm test:updater && pnpm test:desktop-viewer && pnpm test:packaged-server", + "test": "node scripts/test-floor.mjs && pnpm test:unattended-adapter-install && pnpm broker:test && pnpm test:updater && pnpm test:desktop-viewer && pnpm test:packaged-server", "test:updater": "node --test electron/updater-coordinator.node-test.mjs", "test:desktop-viewer": "node --test electron/desktop-viewer.node-test.mjs", + "test:unattended-adapter-install": "node --test scripts/install-unattended-adapter.node-test.mjs", "bench:observation": "node --experimental-strip-types scripts/bench-observation.ts", "test:watch": "vitest", "test:cua": "pnpm build:cua && node scripts/smoke-cua.mjs", "test:cua-container": "node scripts/smoke-cua-container.mjs", "check:electron": "node scripts/check-electron.mjs", + "install:unattended-adapter": "node scripts/install-unattended-adapter.mjs", "preview": "vite preview", "build:server": "tsc -p tsconfig.server.build.json && node scripts/bundle-server.mjs", "build:companion": "tsc -p tsconfig.companion.build.json", diff --git a/public/unattended-work.html b/public/unattended-work.html new file mode 100644 index 000000000..83666dcb8 --- /dev/null +++ b/public/unattended-work.html @@ -0,0 +1,176 @@ + + + + + + + Hermes Work Queue + + + +
+
+
+
OpenMausBot ingress
+

Hermes Work Queue

+

One narrow surface for guarded submission and status. Hermes is the only executor.

+
+
Checking local plane…
+
+ +
+
+

Submit guarded work

+

Exact source, isolated worktree, task branch, owning issue, allowed paths, and acceptance commands are required. Invalid work remains in triage.

+
+
+ + + + + + + + + +
+
+
+
No merge, deploy, release, upload, provider change, credential-value access, external message, force push, destructive cleanup, protected-branch write, or out-of-worktree write authority.
+
+ +
+

Request status

+

Read one deterministic work ID from the same local plane.

+
+ + +
+
No request selected.
+
+
+
+ + + + diff --git a/scripts/install-unattended-adapter.mjs b/scripts/install-unattended-adapter.mjs new file mode 100644 index 000000000..2ff2a7eaa --- /dev/null +++ b/scripts/install-unattended-adapter.mjs @@ -0,0 +1,318 @@ +#!/usr/bin/env node + +/** Install an exact, immutable, dormant OpenMausBot submission/status build. */ + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + chmodSync, + cpSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT = fileURLToPath(import.meta.url); +const ROOT = resolve(dirname(SCRIPT), ".."); +const LABEL = "com.gus.aos-unattended-openmausbot"; + +function fail(message) { + throw new Error(message); +} + +function argument(name, fallback = undefined) { + const index = process.argv.indexOf(name); + if (index === -1) return fallback; + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) fail(`${name} requires a value`); + return value; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: ROOT, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + ...options, + }); + if (result.status !== 0) fail(`${basename(command)} failed: ${(result.stderr || result.stdout || "unknown error").trim()}`); + return result.stdout.trim(); +} + +function requireAbsoluteSafePath(value, label) { + if (!isAbsolute(value) || resolve(value) !== value || value === "/" || value === homedir()) { + fail(`${label} must be a normalized absolute child path`); + } +} + +function validatePort(value, label) { + const port = Number(value); + if (!Number.isInteger(port) || port < 1024 || port > 65535) fail(`${label} is invalid`); + return port; +} + +function xml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +export function treeHash(root, excluded = new Set()) { + const hash = createHash("sha256"); + const visit = (directory) => { + for (const name of readdirSync(directory).sort()) { + const path = join(directory, name); + const artifactPath = relative(root, path); + if (excluded.has(artifactPath)) continue; + const stat = lstatSync(path); + if (stat.isSymbolicLink()) fail(`runtime artifact contains symlink: ${artifactPath}`); + if (stat.isDirectory()) visit(path); + else if (stat.isFile()) { + hash.update(artifactPath); + hash.update("\0"); + hash.update(readFileSync(path)); + hash.update("\0"); + } + } + }; + visit(root); + return hash.digest("hex"); +} + +function requireOwnedTarget(path, label, kind) { + const stat = lstatSync(path); + if (stat.isSymbolicLink()) fail(`${label} must not be a symlink`); + if (kind === "directory" ? !stat.isDirectory() : !stat.isFile()) { + fail(`${label} must be a ${kind}`); + } + const uid = process.getuid?.(); + if (uid !== undefined && stat.uid !== uid) fail(`${label} must be owned by the current user`); + return stat; +} + +function removeWriteBits(root) { + const visit = (path) => { + const stat = lstatSync(path); + if (stat.isDirectory()) { + for (const name of readdirSync(path)) visit(join(path, name)); + } + chmodSync(path, stat.mode & ~0o222); + }; + visit(root); +} + +function launchAgent({ node, server, staticDir, dataRoot, appPort, webhookPort, planePort }) { + const entries = { + HOME: dataRoot, + USERPROFILE: dataRoot, + OMB_PORT: String(appPort), + OMB_WEBHOOK_PORT: String(webhookPort), + OMB_STATIC_DIR: staticDir, + OMB_UNATTENDED_ADAPTER_ONLY: "1", + OMB_UNATTENDED_WORK_ENABLED: "0", + OMB_UNATTENDED_WORK_URL: `http://127.0.0.1:${planePort}`, + }; + const environment = Object.entries(entries) + .map(([key, value]) => ` ${xml(key)}\n ${xml(value)}`) + .join("\n"); + return ` + + + + Label${LABEL} + ProgramArguments + ${xml(node)}${xml(server)} + EnvironmentVariables + +${environment} + + RunAtLoad + KeepAlive + ProcessTypeBackground + + +`; +} + +function atomicWrite(path, body, mode = 0o600) { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`); + writeFileSync(temporary, body, { mode, flag: "wx" }); + renameSync(temporary, path); +} + +function serviceIsLoaded() { + if (process.platform !== "darwin") return false; + const uid = process.getuid?.(); + if (uid === undefined) return false; + return spawnSync("launchctl", ["print", `gui/${uid}/${LABEL}`], { + stdio: "ignore", + }).status === 0; +} + +export function ensureLaunchAgent(path, body) { + if (existsSync(path)) { + requireOwnedTarget(path, "LaunchAgent artifact", "regular file"); + chmodSync(path, 0o600); + if (readFileSync(path, "utf8") !== body) fail("existing LaunchAgent artifact does not match the exact generation"); + return; + } + atomicWrite(path, body, 0o600); +} + +export function main() { + const expectedSha = argument("--expected-sha"); + if (!expectedSha || !/^[0-9a-f]{40}$/.test(expectedSha)) fail("--expected-sha must be a full lowercase Git SHA"); + const runtimeRoot = argument("--runtime-root", join(homedir(), ".local", "share", "aos-unattended-work", "openmausbot")); + const dataRoot = argument("--data-root", join(homedir(), ".local", "state", "aos-unattended-work", "openmausbot")); + const launchAgentPath = argument("--launch-agent", join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`)); + for (const [value, label] of [[runtimeRoot, "runtime root"], [dataRoot, "data root"], [launchAgentPath, "launch agent"]]) { + requireAbsoluteSafePath(value, label); + } + const appPort = validatePort(argument("--app-port", "8827"), "app port"); + const webhookPort = validatePort(argument("--webhook-port", "8828"), "webhook port"); + const planePort = validatePort(argument("--plane-port", "8817"), "plane port"); + if (new Set([appPort, webhookPort, planePort, 8799, 8800]).size !== 5) fail("isolated ports must be distinct from each other and the attended defaults"); + if (serviceIsLoaded()) fail("unattended OpenMausBot service is already loaded"); + + if (realpathSync(ROOT) !== ROOT) fail("source worktree must not be reached through a symlink"); + const actualSha = run("git", ["rev-parse", "HEAD"]); + if (actualSha !== expectedSha) fail(`source SHA changed: expected ${expectedSha}, found ${actualSha}`); + if (run("git", ["status", "--porcelain=v1", "--untracked-files=all"])) fail("source worktree is not clean"); + + // Build after validating the exact clean checkout. This binds every staged + // executable artifact to expectedSha instead of trusting whatever an older + // build happened to leave in ignored dist directories. + run("pnpm", ["build"]); + run("pnpm", ["build:server"]); + if (run("git", ["rev-parse", "HEAD"]) !== expectedSha) fail("source SHA changed during build"); + if (run("git", ["status", "--porcelain=v1", "--untracked-files=all"])) { + fail("source worktree changed during build"); + } + for (const artifact of [join(ROOT, "dist", "unattended-work.html"), join(ROOT, "dist-server", "index.js")]) { + if (!existsSync(artifact)) fail(`required build artifact is missing: ${artifact}`); + requireOwnedTarget(artifact, "required build artifact", "regular file"); + } + + mkdirSync(runtimeRoot, { recursive: true, mode: 0o700 }); + mkdirSync(dataRoot, { recursive: true, mode: 0o700 }); + if (realpathSync(runtimeRoot) !== runtimeRoot || realpathSync(dataRoot) !== dataRoot) { + fail("runtime and data roots must not be symlinks"); + } + requireOwnedTarget(runtimeRoot, "runtime root", "directory"); + requireOwnedTarget(dataRoot, "data root", "directory"); + chmodSync(runtimeRoot, 0o700); + chmodSync(dataRoot, 0o700); + const generation = join(runtimeRoot, expectedSha); + const receiptPath = join(generation, "receipt.json"); + if (existsSync(generation)) { + requireOwnedTarget(generation, "runtime generation", "directory"); + requireOwnedTarget(receiptPath, "runtime receipt", "regular file"); + const receipt = JSON.parse(readFileSync(receiptPath, "utf8")); + if ( + receipt.source_sha !== expectedSha || + receipt.schema !== "openmausbot.unattended-work-runtime.v1" || + receipt.app_port !== appPort || + receipt.webhook_port !== webhookPort || + receipt.plane_port !== planePort || + receipt.data_root !== dataRoot || + receipt.launch_agent_path !== launchAgentPath || + !receipt.node_path + ) { + fail("existing generation receipt does not match"); + } + if (treeHash(generation, new Set(["receipt.json"])) !== receipt.artifact_sha256) { + fail("existing generation artifact hash does not match"); + } + const plist = launchAgent({ + node: receipt.node_path, + server: join(generation, "server", "index.js"), + staticDir: join(generation, "static"), + dataRoot, + appPort, + webhookPort, + planePort, + }); + ensureLaunchAgent(launchAgentPath, plist); + process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`); + return receipt; + } + + const staging = mkdtempSync(join(runtimeRoot, ".staging-")); + try { + const staticDir = join(staging, "static"); + const serverDir = join(staging, "server"); + mkdirSync(staticDir, { mode: 0o700 }); + cpSync(join(ROOT, "dist", "unattended-work.html"), join(staticDir, "index.html"), { errorOnExist: true }); + cpSync(join(ROOT, "dist-server"), serverDir, { recursive: true, errorOnExist: true }); + run("git", ["archive", "--format=tar", `--output=${join(staging, "source.tar")}`, expectedSha]); + const artifactSha = treeHash(staging); + const receipt = { + schema: "openmausbot.unattended-work-runtime.v1", + source_sha: expectedSha, + artifact_sha256: artifactSha, + source_ready: true, + dormant_ready: true, + live_accepted: false, + executor: "hermes", + app_port: appPort, + webhook_port: webhookPort, + plane_port: planePort, + adapter_only: true, + openmausbot_ingress_enabled: false, + dispatcher_enabled: false, + telegram_delivery_enabled: false, + provider_calls_enabled: false, + launch_agent_label: LABEL, + launch_agent_run_at_load: false, + launch_agent_bootstrapped: false, + installed_app_replaced: false, + attended_data_modified: false, + credential_values_included: false, + node_path: process.execPath, + data_root: dataRoot, + launch_agent_path: launchAgentPath, + }; + writeFileSync(join(staging, "receipt.json"), `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o444 }); + removeWriteBits(staging); + renameSync(staging, generation); + + const plist = launchAgent({ + node: process.execPath, + server: join(generation, "server", "index.js"), + staticDir: join(generation, "static"), + dataRoot, + appPort, + webhookPort, + planePort, + }); + ensureLaunchAgent(launchAgentPath, plist); + process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`); + return receipt; + } catch (error) { + if (existsSync(staging)) rmSync(staging, { recursive: true, force: true }); + throw error; + } +} + +if (process.argv[1] && resolve(process.argv[1]) === SCRIPT) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/install-unattended-adapter.node-test.mjs b/scripts/install-unattended-adapter.node-test.mjs new file mode 100644 index 000000000..a1eba6e3d --- /dev/null +++ b/scripts/install-unattended-adapter.node-test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { ensureLaunchAgent, treeHash } from "./install-unattended-adapter.mjs"; + +function scratch(t) { + const root = mkdtempSync(join(tmpdir(), "omb-unattended-installer-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + return root; +} + +test("generation hashes exclude only receipt metadata and detect artifact drift", (t) => { + const root = scratch(t); + mkdirSync(join(root, "server")); + writeFileSync(join(root, "server", "index.js"), "first"); + const expected = treeHash(root); + + writeFileSync(join(root, "receipt.json"), JSON.stringify({ artifact_sha256: expected })); + assert.equal(treeHash(root, new Set(["receipt.json"])), expected); + + writeFileSync(join(root, "server", "index.js"), "second"); + assert.notEqual(treeHash(root, new Set(["receipt.json"])), expected); +}); + +test("existing LaunchAgent targets must be regular and are hardened before reuse", (t) => { + const root = scratch(t); + const target = join(root, "agent.plist"); + writeFileSync(target, "exact", { mode: 0o666 }); + + ensureLaunchAgent(target, "exact"); + assert.equal(readFileSync(target, "utf8"), "exact"); + if (process.platform !== "win32") assert.equal(statSync(target).mode & 0o777, 0o600); + assert.throws(() => ensureLaunchAgent(target, "different"), /does not match/); + + const directoryTarget = join(root, "directory.plist"); + mkdirSync(directoryTarget); + assert.throws(() => ensureLaunchAgent(directoryTarget, "exact"), /regular file/); + + if (process.platform !== "win32") { + const symlinkTarget = join(root, "symlink.plist"); + symlinkSync(target, symlinkTarget); + assert.throws(() => ensureLaunchAgent(symlinkTarget, "exact"), /must not be a symlink/); + } +}); diff --git a/server/index.test.ts b/server/index.test.ts index 3819f5b63..47de5a058 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -269,6 +269,32 @@ describe("harness HTTP API", () => { expect(body.static).toBe(true); }); + it("keeps the unattended work adapter dormant unless the isolated runtime opts in", async () => { + const health = await api("GET", "/api/unattended-work/health"); + expect(health).toMatchObject({ + status: 200, + body: { + status: "disabled", + plane: null, + adapter: { + enabled: false, + executor: "hermes", + runs_repo_tools: false, + uses_full_task_profile: false, + }, + }, + }); + + const submit = await api("POST", "/api/unattended-work", { + schema: "aos.work-request.v1", + ingress: "openmausbot", + }); + expect(submit).toMatchObject({ + status: 403, + body: { error: "OpenMausBot work ingress is disabled" }, + }); + }); + it("serves packaged UI assets and preserves API 404s", async () => { const root = await fetch(`${BASE}/`); expect(root.status).toBe(200); diff --git a/server/index.ts b/server/index.ts index 15f278122..443c34a22 100644 --- a/server/index.ts +++ b/server/index.ts @@ -107,10 +107,12 @@ import { WebhookManager } from "./webhooks.ts"; import { SPAWNED_PROXIES } from "./proxy-paths.ts"; import { loadBundledSkills, renderSkillInstructions, selectBundledSkills } from "./skill-library.ts"; import { shouldMountLocalComputer } from "./local-routing.ts"; +import { unattendedWorkAdapterFromEnv, unattendedWorkRequestIdFromPath } from "./unattended-work-adapter.ts"; const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799); const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1); const STATIC_DIR = process.env.OMB_STATIC_DIR || null; +const UNATTENDED_ADAPTER_ONLY = process.env.OMB_UNATTENDED_ADAPTER_ONLY === "1"; const MIME: Record = { ".html": "text/html", ".js": "text/javascript", @@ -125,8 +127,9 @@ const MIME: Record = { ensureDirs(); const cfg = loadConfig(); const registry = new ProviderRegistry(BUILT_IN_DRIVERS); -await registry.load(instanceConfigs(cfg)); +await registry.load(UNATTENDED_ADAPTER_ONLY ? {} : instanceConfigs(cfg)); const bundledSkills = loadBundledSkills(); +const unattendedWork = unattendedWorkAdapterFromEnv(); const bus = new EventBus(); bus.attach(registry.instances()); @@ -1721,7 +1724,7 @@ routines = new RoutineManager({ notify(buildNotification("routine-failed", bot, run.threadId ?? bot.threadId, detail)); }, }); -routines.start(); +if (!UNATTENDED_ADAPTER_ONLY) routines.start(); // Webhook definitions are independent from calendar schedules, but every // delivery joins the same RoutineManager queue. That keeps unattended work @@ -1740,8 +1743,10 @@ const webhooks = new WebhookManager({ let webhookIngress: WebhookIngress | null = null; let webhookIngressError: string | null = null; try { - webhookIngress = await listenWebhookIngress(webhooks, { port: WEBHOOK_PORT }); - console.log(`openmausbot webhook receiver on ${webhookIngress.baseUrl}`); + if (!UNATTENDED_ADAPTER_ONLY) { + webhookIngress = await listenWebhookIngress(webhooks, { port: WEBHOOK_PORT }); + console.log(`openmausbot webhook receiver on ${webhookIngress.baseUrl}`); + } } catch (error) { webhookIngressError = error instanceof Error ? error.message : String(error); console.error(`openmausbot webhook receiver unavailable: ${webhookIngressError}`); @@ -2386,6 +2391,15 @@ const server = createServer(async (req, res) => { if (origin && !isAllowedOrigin(origin)) { return json(res, 403, { error: "forbidden: cross-origin request" }); } + const unattendedStatusRequestId = method === "GET" ? unattendedWorkRequestIdFromPath(path) : null; + if (UNATTENDED_ADAPTER_ONLY && path.startsWith("/api/")) { + const allowed = + (method === "GET" && path === "/api/health") || + (method === "GET" && path === "/api/unattended-work/health") || + (method === "POST" && path === "/api/unattended-work") || + unattendedStatusRequestId !== null; + if (!allowed) return json(res, 404, { error: "route unavailable in unattended adapter mode" }); + } // ── internal peer-agent comms (localhost + shared token only) ────── // The agents-proxy (spawned inside a bot's agent process) calls these to // discover peers and hand a message to one. Not part of the public API. @@ -3871,11 +3885,33 @@ const server = createServer(async (req, res) => { }); } + // The unattended-work surface is intentionally narrower than every bot + // route above: it only proxies health, submit, and status to one fixed + // 127.0.0.1 service. The adapter is disabled unless the isolated runtime + // explicitly opts in; it never starts a turn or touches a provider. + if (method === "GET" && path === "/api/unattended-work/health") { + return json(res, 200, await unattendedWork.health()); + } + if (method === "POST" && path === "/api/unattended-work") { + if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) { + return json(res, 415, { error: "content-type must be application/json" }); + } + return json(res, 200, await unattendedWork.submit(await readBody(req))); + } + if (method === "GET" && unattendedStatusRequestId !== null) { + return json(res, 200, await unattendedWork.status(unattendedStatusRequestId)); + } + // identity handshake for the packaged app's port fallback: the forked // child proves it is OURS by echoing its pid (a stray dev server has // the same API shape but a different pid) if (method === "GET" && path === "/api/health") { - return json(res, 200, { app: "openmausbot", pid: process.pid, static: Boolean(STATIC_DIR) }); + return json(res, 200, { + app: "openmausbot", + pid: process.pid, + static: Boolean(STATIC_DIR), + mode: UNATTENDED_ADAPTER_ONLY ? "unattended-adapter" : "full", + }); } // ── inspector: a thread's runtime events + native protocol tee ── diff --git a/server/unattended-work-adapter-mode.test.ts b/server/unattended-work-adapter-mode.test.ts new file mode 100644 index 000000000..afcf2ed3e --- /dev/null +++ b/server/unattended-work-adapter-mode.test.ts @@ -0,0 +1,101 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { waitForExit } from "./testing/cleanup.ts"; + +const SERVER_DIR = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(SERVER_DIR, ".."); +const PORT = 31_000 + Math.floor(Math.random() * 5_000); +const BASE = `http://127.0.0.1:${PORT}`; +let child: ChildProcess; +let home: string; +let staticDir: string; +let output = ""; + +beforeAll(async () => { + home = mkdtempSync(join(tmpdir(), "omb-unattended-adapter-home-")); + staticDir = join(home, "static"); + mkdirSync(staticDir, { recursive: true }); + writeFileSync(join(staticDir, "index.html"), "Hermes Work Queue"); + const env: NodeJS.ProcessEnv = { + HOME: home, + USERPROFILE: home, + OMB_PORT: String(PORT), + OMB_WEBHOOK_PORT: String(PORT + 1), + OMB_STATIC_DIR: staticDir, + OMB_UNATTENDED_ADAPTER_ONLY: "1", + OMB_UNATTENDED_WORK_ENABLED: "0", + OMB_UNATTENDED_WORK_URL: "http://127.0.0.1:8817", + }; + if (process.env.PATH) env.PATH = process.env.PATH; + if (process.env.SystemRoot) env.SystemRoot = process.env.SystemRoot; + child = spawn(process.execPath, [join(SERVER_DIR, "index.ts")], { + cwd: ROOT, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout?.on("data", (chunk) => { output += String(chunk); }); + child.stderr?.on("data", (chunk) => { output += String(chunk); }); + + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`adapter server exited ${child.exitCode}: ${output}`); + try { + if ((await fetch(`${BASE}/api/health`)).ok) return; + } catch { + // Boot is still in progress. + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`adapter server did not start: ${output}`); +}); + +afterAll(async () => { + if (child?.exitCode === null) child.kill("SIGTERM"); + if (child) await waitForExit(child, 5_000).catch(() => child.kill("SIGKILL")); + if (home) rmSync(home, { recursive: true, force: true }); +}); + +describe("unattended adapter-only server mode", () => { + it("serves only the standalone UI and narrow work-plane API", async () => { + const appHealth = await (await fetch(`${BASE}/api/health`)).json(); + expect(appHealth).toMatchObject({ app: "openmausbot", mode: "unattended-adapter", static: true }); + + const adapterHealth = await (await fetch(`${BASE}/api/unattended-work/health`)).json(); + expect(adapterHealth).toMatchObject({ + status: "disabled", + adapter: { enabled: false, executor: "hermes", runs_repo_tools: false }, + }); + + for (const path of ["/api/bots", "/api/instances", "/api/routines", "/api/internal/agents"]) { + const response = await fetch(`${BASE}${path}`); + expect(response.status, path).toBe(404); + await expect(response.json()).resolves.toMatchObject({ error: "route unavailable in unattended adapter mode" }); + } + + const html = await (await fetch(`${BASE}/`)).text(); + expect(html).toContain("Hermes Work Queue"); + }); + + it("starts no webhook listener and keeps submission disabled", async () => { + await expect(fetch(`http://127.0.0.1:${PORT + 1}/health`)).rejects.toThrow(); + const response = await fetch(`${BASE}/api/unattended-work`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ schema: "aos.work-request.v1", ingress: "openmausbot" }), + }); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ error: "OpenMausBot work ingress is disabled" }); + + const encodedStatus = await fetch(`${BASE}/api/unattended-work/${encodeURIComponent("work:123")}`); + expect(encodedStatus.status).toBe(403); + await expect(encodedStatus.json()).resolves.toMatchObject({ error: "OpenMausBot work ingress is disabled" }); + + const encodedSlash = await fetch(`${BASE}/api/unattended-work/work%2F123`); + expect(encodedSlash.status).toBe(404); + }); +}); diff --git a/server/unattended-work-adapter.test.ts b/server/unattended-work-adapter.test.ts new file mode 100644 index 000000000..aa8d14d9c --- /dev/null +++ b/server/unattended-work-adapter.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + UnattendedWorkAdapter, + UnattendedWorkAdapterError, + unattendedWorkAdapterFromEnv, + unattendedWorkRequestIdFromPath, +} from "./unattended-work-adapter.ts"; + +interface TestResponseBody { + schema?: string; + request?: { id: string }; + pass?: boolean; + error?: string; + live_accepted?: boolean; +} + +const response = (body: TestResponseBody, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +describe("UnattendedWorkAdapter", () => { + it("is disabled by default and never calls the loopback service", async () => { + const fetchImpl = vi.fn(); + const adapter = new UnattendedWorkAdapter({ fetchImpl }); + + expect(await adapter.health()).toMatchObject({ + status: "disabled", + adapter: { + enabled: false, + executor: "hermes", + runs_repo_tools: false, + uses_full_task_profile: false, + }, + }); + await expect(adapter.submit({})).rejects.toMatchObject({ status: 403 }); + await expect(adapter.status("work-12345678")).rejects.toMatchObject({ status: 403 }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("rejects non-loopback, credentialed, and implicit-port targets", () => { + for (const baseUrl of [ + "https://127.0.0.1:8817", + "http://localhost:8817", + "http://127.0.0.1", + "http://user:pass@127.0.0.1:8817", + "http://192.0.2.10:8817", + ]) { + expect(() => new UnattendedWorkAdapter({ baseUrl })).toThrow(UnattendedWorkAdapterError); + } + }); + + it("submits one forced openmausbot envelope to the fixed endpoint", async () => { + const fetchImpl = vi.fn().mockResolvedValue(response({ + schema: "aos.unattended-work-submit.v1", + request: { id: "work-12345678" }, + pass: true, + live_accepted: false, + })); + const adapter = new UnattendedWorkAdapter({ enabled: true, fetchImpl }); + + await expect(adapter.submit({ + schema: "aos.work-request.v1", + repository: "owner/repo", + issue: 1, + idempotency_key: "work:owner/repo:1", + card: {}, + })).resolves.toMatchObject({ pass: true }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [target, init] = fetchImpl.mock.calls[0]; + expect(String(target)).toBe("http://127.0.0.1:8817/v1/work"); + expect(init).toMatchObject({ method: "POST", redirect: "error" }); + expect(JSON.parse(String(init?.body))).toMatchObject({ ingress: "openmausbot" }); + }); + + it("rejects submit receipts unless the work plane proves dormant acceptance", async () => { + for (const body of [{ pass: true }, { pass: true, live_accepted: true }]) { + const adapter = new UnattendedWorkAdapter({ + enabled: true, + fetchImpl: vi.fn().mockResolvedValue(response(body)), + }); + await expect(adapter.submit({ ingress: "openmausbot" })).rejects.toMatchObject({ + status: 502, + message: "unattended-work returned a non-dormant receipt", + }); + } + }); + + it("decodes one status path segment exactly once", async () => { + expect(unattendedWorkRequestIdFromPath("/api/unattended-work/work%3A123")).toBe("work:123"); + expect(unattendedWorkRequestIdFromPath("/api/unattended-work/work%253A123")).toBeNull(); + expect(unattendedWorkRequestIdFromPath("/api/unattended-work/work%2F123")).toBeNull(); + expect(unattendedWorkRequestIdFromPath("/api/unattended-work/%E0%A4%A")).toBeNull(); + + const fetchImpl = vi.fn().mockResolvedValue(response({ pass: true })); + const adapter = new UnattendedWorkAdapter({ enabled: true, fetchImpl }); + await adapter.status("work:123"); + expect(String(fetchImpl.mock.calls[0][0])).toBe("http://127.0.0.1:8817/v1/work/work%3A123"); + }); + + it("rejects ingress confusion and invalid status identifiers locally", async () => { + const fetchImpl = vi.fn(); + const adapter = new UnattendedWorkAdapter({ enabled: true, fetchImpl }); + + await expect(adapter.submit(JSON.parse('{"ingress":"telegram"}'))).rejects.toMatchObject({ status: 400 }); + await expect(adapter.status("../secrets")).rejects.toMatchObject({ status: 400 }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("uses only an explicit opt-in environment value", () => { + expect(unattendedWorkAdapterFromEnv({ OMB_UNATTENDED_WORK_ENABLED: "true" }).enabled).toBe(false); + expect(unattendedWorkAdapterFromEnv({ OMB_UNATTENDED_WORK_ENABLED: "1" }).enabled).toBe(true); + }); + + it("bounds a loopback response before parsing it", async () => { + const fetchImpl = vi.fn().mockResolvedValue( + new Response("x".repeat(256 * 1024 + 1)), + ); + const adapter = new UnattendedWorkAdapter({ enabled: true, fetchImpl }); + + await expect(adapter.health()).rejects.toThrow("response is too large"); + }); +}); diff --git a/server/unattended-work-adapter.ts b/server/unattended-work-adapter.ts new file mode 100644 index 000000000..1ae9a3d0e --- /dev/null +++ b/server/unattended-work-adapter.ts @@ -0,0 +1,228 @@ +/** + * Narrow loopback client for the dormant AOS unattended-work plane. + * + * This adapter has exactly three operations: health, submit, and status. It + * cannot select a bot, start a turn, invoke a provider, run a repository tool, + * or opt into the full-task-scoped profile. The source-owned work plane remains + * responsible for validation, idempotency, card creation, and every execution + * guard. + */ + +import { z } from "zod"; + +const DEFAULT_BASE_URL = "http://127.0.0.1:8817"; +const MAX_RESPONSE_BYTES = 256 * 1024; +const REQUEST_ID = /^[A-Za-z0-9._:-]{1,160}$/; +const PlaneResponseSchema = z.object({ error: z.string().optional() }).catchall(z.json()); +const WorkRequestPayloadSchema = z.object({ ingress: z.literal("openmausbot").optional() }).catchall(z.json()); +type PlaneResponse = z.infer; +type WorkRequestPayload = z.input; + +export function unattendedWorkRequestIdFromPath(path: string): string | null { + const match = path.match(/^\/api\/unattended-work\/([^/]{1,480})$/); + if (!match) return null; + let decoded: string; + try { + decoded = decodeURIComponent(match[1]); + } catch { + return null; + } + return REQUEST_ID.test(decoded) ? decoded : null; +} + +export class UnattendedWorkAdapterError extends Error { + readonly status: number; + + constructor(message: string, status = 502) { + super(message); + this.name = "UnattendedWorkAdapterError"; + this.status = status; + } +} + +export interface UnattendedWorkAdapterOptions { + enabled?: boolean; + baseUrl?: string; + timeoutMs?: number; + fetchImpl?: typeof fetch; +} + +export interface UnattendedWorkAdapterSnapshot { + schema: "openmausbot.unattended-work-adapter.v1"; + enabled: boolean; + source_ready: true; + executor: "hermes"; + capabilities: readonly ["submit", "status"]; + runs_repo_tools: false; + uses_full_task_profile: false; +} + +export interface UnattendedWorkAdapterHealth { + adapter: UnattendedWorkAdapterSnapshot; + plane: PlaneResponse | null; + status: "disabled" | "connected"; +} + +function validatedBaseUrl(raw: string): URL { + let value: URL; + try { + value = new URL(raw); + } catch { + throw new UnattendedWorkAdapterError("unattended-work URL is invalid", 500); + } + if ( + value.protocol !== "http:" || + value.hostname !== "127.0.0.1" || + !value.port || + (value.pathname !== "/" && value.pathname !== "") || + value.username || + value.password || + value.search || + value.hash + ) { + throw new UnattendedWorkAdapterError( + "unattended-work URL must be an explicit 127.0.0.1 HTTP port", + 500, + ); + } + return value; +} + +function adapterSnapshot(enabled: boolean): UnattendedWorkAdapterSnapshot { + return { + schema: "openmausbot.unattended-work-adapter.v1", + enabled, + source_ready: true, + executor: "hermes", + capabilities: ["submit", "status"], + runs_repo_tools: false, + uses_full_task_profile: false, + }; +} + +async function boundedResponseBytes(response: Response): Promise { + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) { + throw new UnattendedWorkAdapterError("unattended-work response is too large"); + } + if (!response.body) return new Uint8Array(); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAX_RESPONSE_BYTES) { + await reader.cancel(); + throw new UnattendedWorkAdapterError("unattended-work response is too large"); + } + chunks.push(next.value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +export class UnattendedWorkAdapter { + readonly enabled: boolean; + private readonly baseUrl: URL; + private readonly timeoutMs: number; + private readonly fetchImpl: typeof fetch; + + constructor(options: UnattendedWorkAdapterOptions = {}) { + this.enabled = options.enabled ?? false; + this.baseUrl = validatedBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL); + this.timeoutMs = options.timeoutMs ?? 5_000; + if (!Number.isInteger(this.timeoutMs) || this.timeoutMs < 100 || this.timeoutMs > 30_000) { + throw new UnattendedWorkAdapterError("unattended-work timeout is invalid", 500); + } + this.fetchImpl = options.fetchImpl ?? fetch; + } + + snapshot(): UnattendedWorkAdapterSnapshot { + return adapterSnapshot(this.enabled); + } + + async health(): Promise { + const adapter = this.snapshot(); + if (!this.enabled) return { adapter, plane: null, status: "disabled" }; + const plane = await this.request("/health"); + return { adapter, plane, status: "connected" }; + } + + async submit(payload: WorkRequestPayload): Promise { + this.requireEnabled(); + const parsed = WorkRequestPayloadSchema.safeParse(payload); + if (!parsed.success) { + throw new UnattendedWorkAdapterError("work request must be a JSON object with openmausbot ingress", 400); + } + const receipt = await this.request("/v1/work", { + method: "POST", + body: JSON.stringify({ ...parsed.data, ingress: "openmausbot" }), + }); + if (receipt.live_accepted !== false) { + throw new UnattendedWorkAdapterError("unattended-work returned a non-dormant receipt", 502); + } + return receipt; + } + + async status(requestId: string): Promise { + this.requireEnabled(); + if (!REQUEST_ID.test(requestId)) { + throw new UnattendedWorkAdapterError("work request id is invalid", 400); + } + return this.request(`/v1/work/${encodeURIComponent(requestId)}`); + } + + private requireEnabled(): void { + if (!this.enabled) { + throw new UnattendedWorkAdapterError("OpenMausBot work ingress is disabled", 403); + } + } + + private async request(path: string, init: RequestInit = {}): Promise { + const target = new URL(path, this.baseUrl); + let response: Response; + try { + response = await this.fetchImpl(target, { + ...init, + headers: init.body ? { "content-type": "application/json" } : undefined, + redirect: "error", + signal: AbortSignal.timeout(this.timeoutMs), + }); + } catch (error) { + const message = error instanceof Error && error.name === "TimeoutError" + ? "unattended-work request timed out" + : "unattended-work service is unavailable"; + throw new UnattendedWorkAdapterError(message, 503); + } + const bytes = await boundedResponseBytes(response); + let body: PlaneResponse; + try { + body = PlaneResponseSchema.parse(JSON.parse(new TextDecoder().decode(bytes))); + } catch { + throw new UnattendedWorkAdapterError("unattended-work returned invalid JSON"); + } + if (!response.ok) { + const detail = body.error ?? `unattended-work returned HTTP ${response.status}`; + throw new UnattendedWorkAdapterError(detail, response.status); + } + return body; + } +} + +export function unattendedWorkAdapterFromEnv( + env: NodeJS.ProcessEnv = process.env, + fetchImpl?: typeof fetch, +): UnattendedWorkAdapter { + return new UnattendedWorkAdapter({ + enabled: env.OMB_UNATTENDED_WORK_ENABLED === "1", + baseUrl: env.OMB_UNATTENDED_WORK_URL || DEFAULT_BASE_URL, + fetchImpl, + }); +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 0da6d54f8..ad23f07c9 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -3,7 +3,7 @@ // is the stuff shared by every bot: who you are, your keys, and the // machine your bots can borrow. import { useEffect, useRef, useState } from "react"; -import { Coins, KeyRound, Monitor, Smartphone, Terminal, User, X } from "lucide-react"; +import { ClipboardList, Coins, KeyRound, Monitor, Smartphone, Terminal, User, X } from "lucide-react"; import { useStore, type AppSettingsSection } from "@/state/store"; import { analyticsEnabled, setAnalyticsEnabled } from "@/lib/analytics"; import { ApiKeyRow, VpsConnection } from "./ApiKeys"; @@ -16,6 +16,7 @@ import { UsageSection } from "./UsageSection"; import { SkinPicker } from "./SkinPicker"; import { RoomTurnTimeoutSettings } from "./RoomTurnTimeoutSettings"; import { cn } from "@/lib/cn"; +import { UnattendedWorkPanel } from "./UnattendedWorkPanel"; const SECTIONS: Array<{ id: AppSettingsSection; label: string; icon: typeof User }> = [ { id: "general", label: "General", icon: User }, @@ -23,6 +24,7 @@ const SECTIONS: Array<{ id: AppSettingsSection; label: string; icon: typeof User { id: "engines", label: "Engines", icon: Terminal }, { id: "companion", label: "Companion", icon: Smartphone }, { id: "computer", label: "Local VM", icon: Monitor }, + { id: "work", label: "Work queue", icon: ClipboardList }, { id: "usage", label: "Usage", icon: Coins }, ]; @@ -281,6 +283,8 @@ export function SettingsModal() { {section === "computer" && } + {section === "work" && } + {section === "usage" && } diff --git a/src/components/UnattendedWorkPanel.tsx b/src/components/UnattendedWorkPanel.tsx new file mode 100644 index 000000000..f5a1d99ee --- /dev/null +++ b/src/components/UnattendedWorkPanel.tsx @@ -0,0 +1,154 @@ +import { useCallback, useEffect, useState } from "react"; +import { Activity, Send } from "lucide-react"; + +import { buildOpenMausWorkRequest, type WorkRequestFields } from "@/lib/unattended-work"; +import { api } from "@/state/store"; +import { Card } from "./SettingsPrimitives"; + +const INITIAL_FIELDS: WorkRequestFields = { + repository: "", + issue: "", + repoPath: "", + baselineSha: "", + taskBranch: "codex/", + allowedPaths: "", + acceptanceTests: "", + tokenBudget: "12000", + maxRuntimeSeconds: "3600", +}; + +interface AdapterHealth { + adapter?: { enabled?: boolean; executor?: string; runs_repo_tools?: boolean; uses_full_task_profile?: boolean }; + plane?: { dormant_ready?: boolean; live_accepted?: boolean } | null; + status?: string; +} + +interface WorkResult { + error?: string; + pass?: boolean; + request?: { id?: string }; + schema?: string; + status?: string; +} + +const inputClass = + "w-full rounded-lg border border-hairline/40 bg-inset px-3 py-2 text-[13px] text-ink placeholder:text-ink-secondary focus:border-hairline focus:outline-none"; + +export function UnattendedWorkPanel() { + const [fields, setFields] = useState(INITIAL_FIELDS); + const [health, setHealth] = useState(null); + const [healthError, setHealthError] = useState(""); + const [working, setWorking] = useState(false); + const [result, setResult] = useState(null); + const [requestId, setRequestId] = useState(""); + + const refreshHealth = useCallback(async () => { + try { + setHealth(await api("/api/unattended-work/health")); + setHealthError(""); + } catch (error) { + setHealth(null); + setHealthError(error instanceof Error ? error.message : String(error)); + } + }, []); + + useEffect(() => { + void refreshHealth(); + }, [refreshHealth]); + + const set = (name: keyof WorkRequestFields, value: string) => + setFields((current) => ({ ...current, [name]: value })); + const enabled = health?.adapter?.enabled === true; + + const submit = async () => { + setWorking(true); + try { + const response: WorkResult = await api("/api/unattended-work", { + method: "POST", + body: JSON.stringify(buildOpenMausWorkRequest(fields)), + }); + setResult(response); + const id = response.request?.id; + if (id) setRequestId(id); + } catch (error) { + setResult({ error: error instanceof Error ? error.message : String(error) }); + } finally { + setWorking(false); + } + }; + + const checkStatus = async () => { + setWorking(true); + try { + const response: WorkResult = await api(`/api/unattended-work/${encodeURIComponent(requestId.trim())}`); + setResult(response); + } catch (error) { + setResult({ error: error instanceof Error ? error.message : String(error) }); + } finally { + setWorking(false); + } + }; + + return ( +
+ +
+ + {healthError ? "Unavailable" : enabled ? "Ingress enabled" : "Dormant / disabled"} + + Executor: Hermes + +
+ {healthError &&
{healthError}
} +
+ + +
+ set("repository", e.target.value)} placeholder="owner/repository" /> + set("issue", e.target.value)} placeholder="Issue number" inputMode="numeric" /> + set("repoPath", e.target.value)} placeholder="/absolute/path/to/isolated-worktree" /> + set("baselineSha", e.target.value)} placeholder="40-character baseline SHA" /> + set("taskBranch", e.target.value)} placeholder="codex/task-branch" /> +