diff --git a/.github/workflows/lazurio-fork-ci.yml b/.github/workflows/lazurio-fork-ci.yml index 3a2745ccdd..18f60c770a 100644 --- a/.github/workflows/lazurio-fork-ci.yml +++ b/.github/workflows/lazurio-fork-ci.yml @@ -66,6 +66,9 @@ jobs: # the webhook queue in-process. Its wiring also changes # server/index.ts and scripts/control-omb.ts, listed above. server/webhooks.ts + # OMB_HEADLESS_FULL_ACCESS, the operator's opt-in to Full access + # over the API, changes only files listed above: server/index.ts, + # scripts/control-omb.ts and docs/self-hosting.md. ) unexpected="$(git diff --name-only --diff-filter=MDRT "$UPSTREAM_SHA..HEAD" | grep -vxF -f <(printf '%s\n' "${allowed_upstream_changes[@]}") || true)" diff --git a/LAZURIO.md b/LAZURIO.md index 0322abaf5d..f171e79c0e 100644 --- a/LAZURIO.md +++ b/LAZURIO.md @@ -47,6 +47,12 @@ turn stays byte-identical to upstream. or an explicit `/lazurio publish` instruction, through the webhook queue ([`docs/lazurio-github-intake.md`](docs/lazurio-github-intake.md)). Off unless `OMB_GITHUB_INTAKE=1`. +- `OMB_HEADLESS_FULL_ACCESS`: the operator's opt-in at server start that + lets the owner on loopback set a bot to Full access through the ordinary + bot settings API, which upstream allows only in the desktop app. Only + Full is unlocked, never Custom + ([`docs/self-hosting.md`](docs/self-hosting.md)). Off unless + `OMB_HEADLESS_FULL_ACCESS=1`. - **Steward team**: [`lazurio/teams/steward.openmaus.json`](lazurio/teams/steward.openmaus.json), a leader (Henry, Chief of Staff) and three workers who review pull requests on their exact head and publish only on an explicit instruction. @@ -56,12 +62,22 @@ turn stays byte-identical to upstream. `OMB_GITHUB_INTAKE_BOT` at the leader. Another persona (Pablo) imports the same file and renames the leader; nothing in the instructions names the persona. Imported bots start on Ask, which would leave unattended work - waiting for an approval nobody gives. Steward teams run on **Auto** - (Organization Admin decision, issue #3): right after the import the - operator sets all four bots to Auto in the app. The provider's own review - then approves routine actions and anything else still asks. Full and - Custom stay desktop-only, as upstream; the overlay adds no way to raise a - bot above Auto. + waiting for an approval nobody gives. Steward teams run on **Full access, + without a sandbox** (Organization Admin decision of 2026-10-01, issue #3; + it supersedes the earlier decision for Auto): the Environment, one Machine + with one operator, is the boundary. Right after the import the operator + sets each bot's engine, Codex or Claude (both are supported, as the + Environment's own signed-in CLIs), and then sets all four bots to Full, in + that order: changing the engine of a Full bot needs Ask first. Full needs + the operator to start the service with `OMB_HEADLESS_FULL_ACCESS=1`; the + app's level selector offers Full only in the desktop app, so the operator + sets it through the local API + ([`docs/self-hosting.md`](docs/self-hosting.md)). Machines never sets the + opt-in. Full is upstream's Full: Codex runs with `never` approvals in the + `danger-full-access` sandbox, Claude with `bypassPermissions`, and the work + the leader (a Chief of Staff with Full) delegates runs Full too. Questions + and missing credentials still reach a person; GitHub rights and branch + rules stay the limit. How to release, rebuild on a new upstream tag, what the overlay contains and how Machines consume a release: diff --git a/docs/lazurio-github-intake.md b/docs/lazurio-github-intake.md index 048a4933a6..db002624dc 100644 --- a/docs/lazurio-github-intake.md +++ b/docs/lazurio-github-intake.md @@ -181,7 +181,13 @@ changes of state, deliveries, own pull requests and ignored instructions. bot can only do what the signed-in account may do on GitHub. Give the account write only where it should review and publish; branch rules stay the enforcement, the team's standing instructions the process. -- **Approval levels are unchanged.** Unattended bots run with their own - approval level. Steward teams run on Auto (issue #3): the operator sets it - after the import, the provider's review approves routine actions and - anything else waits for the operator. Headless Full access does not exist. +- **Approval levels.** Unattended bots run with their own approval level; + the intake changes none. Steward teams run on Full access without a + sandbox (Organization Admin decision of 2026-10-01, issue #3, superseding + Auto): the Environment, one Machine with one operator, is the boundary. + The operator starts the service with `OMB_HEADLESS_FULL_ACCESS=1` and sets + each bot to Full after the import + ([self-hosting](self-hosting.md#full-access-without-the-desktop-app)); + Machines never sets it. Every intake delivery opens a new conversation, + which starts Full, and the leader's delegated work runs Full too. + Questions and missing credentials still wait for the operator. diff --git a/docs/operations/lazurio-fork-release.md b/docs/operations/lazurio-fork-release.md index 74289057f4..2b8c080c18 100644 --- a/docs/operations/lazurio-fork-release.md +++ b/docs/operations/lazurio-fork-release.md @@ -89,6 +89,10 @@ release offers it. - `PATH` including `~/.local/bin`, so the server finds the signed-in `codex` and `claude` CLIs of the same user, as T3 does. - optionally `OMB_ENVIRONMENT_LABEL` (` / `). + - never `OMB_HEADLESS_FULL_ACCESS`. Full access is the operator's own + decision for their Environment: the operator adds it to the unit + (for example `systemctl --user edit openmausbot.service`) and restarts + it; see Overlay. - optionally the GitHub intake (`OMB_GITHUB_INTAKE=1`, `OMB_GITHUB_INTAKE_BOT` and its scope settings), with `gh` signed in as the same user; see @@ -114,6 +118,7 @@ release offers it. | `release: Lazurio distribution` | This runbook, `LAZURIO.md`, `scripts/lazurio-release-archive.sh`, the contract test and the workflows `lazurio-fork-ci.yml`, `lazurio-archive.yml` and `lazurio-release.yml`. | | `server: GitHub intake, model-free pull request triggers for a bot` | Lazurio Environments hand GitHub review and publication work to a bot team without a model polling (decision 0169). Off unless `OMB_GITHUB_INTAKE=1`. New files plus `WebhookManager.deliver()` and its wiring; see [`docs/lazurio-github-intake.md`](../lazurio-github-intake.md). Upstream-friendly apart from the Lazurio publish marker. | | `lazurio: Steward team for the GitHub intake` | The team Lazurio Organizations run behind the intake: leader Henry (Chief of Staff) and three workers with exact-head review and explicit-publication rules, as a portable team file (`lazurio/teams/steward.openmaus.json`). Lazurio-only; never proposed upstream. | +| `server: OMB_HEADLESS_FULL_ACCESS, Full access over the API as an operator opt-in` | Steward teams run without a sandbox; the Environment is the boundary (issue #3). Off unless `OMB_HEADLESS_FULL_ACCESS=1`: the owner on loopback may then set a bot to Full through the bot PATCH, nothing else. New `server/headless-full-access.ts` plus one guard and two log lines in `server/index.ts`; see [`docs/self-hosting.md`](../self-hosting.md#full-access-without-the-desktop-app). Upstream-friendly; could be proposed upstream. | `OMB_DEFAULT_BOT_CWD` applies wherever a bot is created: New bot, the first bot on an empty server, a Chief's reviewed team setup and imports. A @@ -131,10 +136,14 @@ change to any other upstream file. New Lazurio files need no entry. Versions are stamped only at build time by `scripts/lazurio-release-archive.sh` and never committed. -Deliberately not in the overlay (DEV-6632 M2 and later): approval levels -(`full` and `custom` stay desktop-only, as upstream; Steward teams run on -`auto`, set by the operator after the import, issue #3), the Environment preset -that installs and configures the Steward team, and any UI change. +Deliberately not in the overlay (DEV-6632 M2 and later): the Environment +preset that installs and configures the Steward team, and any UI change. +Approval levels stay upstream's, with one opt-in: Steward teams run on +`full` (Organization Admin decision of 2026-10-01, issue #3, superseding the +earlier `auto`), which a headless server allows only when the operator +starts it with `OMB_HEADLESS_FULL_ACCESS=1`. `custom` stays desktop-only, and +the app's level selector still offers Full only in the desktop app, so the +operator sets Full through the local API ([self-hosting](../self-hosting.md#full-access-without-the-desktop-app)). ## When to release diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 6a0f6cbcd4..d786a1d268 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -299,6 +299,39 @@ itself, and existing bots keep their folder. The folder is checked like a bot's own: a relative path, a file or a missing folder stops the server at start with the reason. Unset or empty keeps the private folders. +### Full access without the desktop app + +Full access and Custom are normally chosen only in the packaged desktop app +([approval levels](approval-levels.md)): a headless server trusts every +loopback request as its owner, so any process of the server's user, +including a bot's own shell, could otherwise raise a bot to Full. On a +server that is its operator's own machine, where bots are meant to run +without a sandbox, the operator can allow Full over the API at start: + +| Variable | Meaning | +| --- | --- | +| `OMB_HEADLESS_FULL_ACCESS` | `1` lets the owner on loopback set a bot to Full access through the bot settings API. Unset, empty, `0` or `false`: off, as upstream. Any other value stops the server at start with the reason. Ignored in the desktop app. | + +```sh +OMB_HEADLESS_FULL_ACCESS=1 openmausbot serve +# then, on the server, for each bot: +curl -sS -X PATCH http://127.0.0.1:8799/api/bots/ \ + -H 'content-type: application/json' -d '{"approvalMode":"full"}' +``` + +The server logs one line at start when the opt-in is on, and one line for +each bot raised to Full. Only entering Full from Ask, Auto-accept edits or +Approve for me is unlocked, for the bot's default; the app's level selector +still offers Full only in the desktop app. Custom, leaving Custom, a single +thread's level, paired devices and a server with `OMB_LOOPBACK_TRUST=service` +stay as upstream. Full is the same Full the desktop sets: Codex runs with +`never` approvals in its `danger-full-access` sandbox, Claude with +`bypassPermissions`, and a Chief of Staff with Full passes it on to the work +it delegates. New conversations start Full, including the ones that webhook +deliveries and routine runs open; a conversation that already has its own +level keeps it. Back to Ask needs no opt-in. Removing the opt-in later stops +new raises but does not lower bots that are already Full; set them to Ask. + ## Installing the engines without a terminal Engines whose installer is an npm package (Claude Code, Codex, OpenCode, diff --git a/scripts/control-omb.ts b/scripts/control-omb.ts index a192efa858..4cea38faf4 100644 --- a/scripts/control-omb.ts +++ b/scripts/control-omb.ts @@ -384,6 +384,8 @@ export function verificationServerEnvironment(parentEnv: NodeJS.ProcessEnv, data for (const [key, value] of Object.entries(parentEnv)) { if (key.startsWith("OMB_GITHUB_INTAKE") && value) childEnv[key] = value; } + // The operator's opt-in to Full access over the API (server/headless-full-access.ts). + if (parentEnv.OMB_HEADLESS_FULL_ACCESS) childEnv.OMB_HEADLESS_FULL_ACCESS = parentEnv.OMB_HEADLESS_FULL_ACCESS; // Voice-note e2e fault injection: arms the one-shot audio-append failure // prelude inside the fixture server (see fail-audio-append-once.mjs). if (parentEnv.OMB_TEST_FAIL_AUDIO_APPEND_ONCE) { diff --git a/server/headless-full-access.e2e.test.ts b/server/headless-full-access.e2e.test.ts new file mode 100644 index 0000000000..a0f282e7ef --- /dev/null +++ b/server/headless-full-access.e2e.test.ts @@ -0,0 +1,143 @@ +// OMB_HEADLESS_FULL_ACCESS through the real isolated server. Without it the +// HTTP API refuses Full exactly as upstream does. With it the loopback owner +// sets a Codex bot and a Claude bot to Full through the same PATCH as any +// other setting, Custom stays refused, and the next turn of each engine runs +// with the provider's own permissive mode: Codex with `never` approvals in +// the `danger-full-access` sandbox, Claude with `bypassPermissions` and no +// approval broker. An unknown value stops the server at start. +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { launchVerificationServer, type VerificationServer } from "../scripts/control-omb.ts"; + +const client = (fixture: () => VerificationServer) => async (method: string, path: string, body?: unknown) => { + const response = await fetch(`${fixture().info.url}${path}`, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: AbortSignal.timeout(10_000), + }); + return { status: response.status, body: await response.json() as any }; +}; + +describe("a server started without OMB_HEADLESS_FULL_ACCESS", () => { + let fixture: VerificationServer; + const api = client(() => fixture); + + beforeAll(async () => { + fixture = await launchVerificationServer({ ...process.env, OMB_HEADLESS_FULL_ACCESS: undefined }); + }); + afterAll(async () => { await fixture?.close(); }); + + it("refuses Full over the API, as upstream does", async () => { + const bot = (await api("POST", "/api/bots", { name: "Upstream" })).body.bot; + const refused = await api("PATCH", `/api/bots/${bot.id}`, { approvalMode: "full" }); + expect(refused.status).toBe(403); + expect(refused.body.error).toBe("This approval-level change can only be made from the packaged desktop app"); + const stored = (await api("GET", "/api/bots?messages=0")).body.bots.find((candidate: any) => candidate.id === bot.id); + expect(stored.approvalMode ?? "ask").toBe("ask"); + expect(readFileSync(fixture.info.logPath, "utf8")).not.toContain("OMB_HEADLESS_FULL_ACCESS"); + }); +}); + +describe("a server started with OMB_HEADLESS_FULL_ACCESS=1", () => { + const dir = mkdtempSync(join(tmpdir(), "omb-headless-full-e2e-")); + const codexDump = join(dir, "codex-dump.json"); + let fixture: VerificationServer; + const api = client(() => fixture); + + beforeAll(async () => { + // One-shot Claude runs (titles) write elsewhere, so the turn dump is the bot's turn. + fixture = await launchVerificationServer({ + ...process.env, + OMB_HEADLESS_FULL_ACCESS: "1", + FAKE_CLAUDE_TEXT_DUMP: join(dir, "claude-text-dump.json"), + }, undefined, undefined, undefined, undefined, undefined, ["codex"]); + // Record the fake Codex app-server's calls, as server/delta-context.e2e.test.ts does. + const wrapper = join(dir, "codex.mjs"); + writeFileSync(wrapper, [ + "#!/usr/bin/env node", + `process.env.FAKE_CODEX_DUMP = ${JSON.stringify(codexDump)};`, + `await import(${JSON.stringify(pathToFileURL(join(process.cwd(), "server", "testing", "fake-codex-app-server.ts")).href)});`, + ].join("\n"), { mode: 0o700 }); + expect((await api("PATCH", "/api/instances/codex", { cli: wrapper })).status).toBe(200); + }); + afterAll(async () => { + await fixture?.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + const createBot = async (name: string, instanceId: "codex" | "claude") => { + const instance = (await api("GET", "/api/instances")).body.instances.find((item: any) => item.instanceId === instanceId); + const created = await api("POST", "/api/bots", { name, modelSelection: { instanceId, model: instance.models.default } }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + return created.body.bot; + }; + + it("says so once at start", () => { + const log = readFileSync(fixture.info.logPath, "utf8"); + expect(log.split("[approvals] OMB_HEADLESS_FULL_ACCESS is on").length - 1).toBe(1); + }); + + it("runs a Codex bot set to Full without a sandbox or approvals", async () => { + const bot = await createBot("Codex steward", "codex"); + const full = await api("PATCH", `/api/bots/${bot.id}`, { approvalMode: "full" }); + expect(full.status, JSON.stringify(full.body)).toBe(200); + expect(full.body.bot).toMatchObject({ approvalMode: "full", autoApprove: false }); + + // Custom, and leaving Full for it, stay desktop-only. + const custom = await api("PATCH", `/api/bots/${bot.id}`, { approvalMode: "custom" }); + expect(custom.status).toBe(403); + expect(custom.body.error).toMatch(/packaged desktop app/); + + expect((await api("POST", `/api/bots/${bot.id}/messages`, { text: "Review the pull request." })).status).toBe(202); + let calls: Array<{ method: string; params: any }> = []; + await expect.poll(() => { + calls = existsSync(codexDump) ? JSON.parse(readFileSync(codexDump, "utf8")).calls : []; + return calls.some((call) => call.method === "turn/start"); + }, { timeout: 20_000, interval: 200 }).toBe(true); + expect(calls.find((call) => call.method === "thread/start")?.params) + .toMatchObject({ approvalPolicy: "never", sandbox: "danger-full-access" }); + expect(calls.find((call) => call.method === "turn/start")?.params) + .toMatchObject({ approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }); + expect(readFileSync(fixture.info.logPath, "utf8")) + .toContain(`bot ${bot.id}: approval level raised to Full`); + }); + + it("runs a Claude bot set to Full with bypassPermissions and no approval broker", async () => { + const bot = await createBot("Claude steward", "claude"); + const full = await api("PATCH", `/api/bots/${bot.id}`, { approvalMode: "full" }); + expect(full.status, JSON.stringify(full.body)).toBe(200); + expect(full.body.bot).toMatchObject({ approvalMode: "full", autoApprove: false }); + + expect((await api("POST", `/api/bots/${bot.id}/messages`, { text: "Review the pull request." })).status).toBe(202); + await expect.poll(() => existsSync(fixture.fixtureDumpPath), { timeout: 20_000, interval: 200 }).toBe(true); + const argv: string[] = JSON.parse(readFileSync(fixture.fixtureDumpPath, "utf8")).argv; + expect(argv[argv.indexOf("--permission-mode") + 1]).toBe("bypassPermissions"); + expect(argv).not.toContain("--permission-prompt-tool"); + + // Back to Ask needs no opt-in, as upstream; Custom is never unlocked. + await expect.poll(async () => (await api("GET", "/api/bots?messages=0")).body.bots + .find((candidate: any) => candidate.id === bot.id).busy, { timeout: 20_000, interval: 200 }).toBe(false); + const ask = await api("PATCH", `/api/bots/${bot.id}`, { approvalMode: "ask" }); + expect(ask.status).toBe(200); + expect(ask.body.bot).toMatchObject({ approvalMode: "ask" }); + expect((await api("PATCH", `/api/bots/${bot.id}`, { approvalMode: "custom" })).status).toBe(400); + }); +}); + +describe("a server started with an unknown OMB_HEADLESS_FULL_ACCESS", () => { + it("does not start, and says why", async () => { + const launch = launchVerificationServer({ ...process.env, OMB_HEADLESS_FULL_ACCESS: "yes" }); + const error = await launch.then( + async (fixture) => { await fixture.close(); throw new Error("the server started"); }, + (failure: Error) => failure, + ); + expect(error.message).toMatch(/exited before it was ready/); + const logPath = /see (\S+)$/.exec(error.message)?.[1]; + expect(logPath && readFileSync(logPath, "utf8")).toContain('OMB_HEADLESS_FULL_ACCESS must be 1 to allow Full access over the API, got "yes"'); + }); +}); diff --git a/server/headless-full-access.test.ts b/server/headless-full-access.test.ts new file mode 100644 index 0000000000..e349af1bf6 --- /dev/null +++ b/server/headless-full-access.test.ts @@ -0,0 +1,69 @@ +// OMB_HEADLESS_FULL_ACCESS: an operator-only opt-in at server start that lets +// the loopback owner set a bot to Full access through the HTTP API. Unset, +// the server refuses that exactly as upstream does; set, only entering Full +// is unlocked, never Custom and never leaving Custom. +import { describe, expect, it } from "vitest"; + +import { APPROVAL_MODES } from "../shared/approval-mode.ts"; +import { headlessFullAccessFromEnv, headlessFullAccessPermits } from "./headless-full-access.ts"; +import type { RequestAuth } from "./request-auth.ts"; + +const owner: RequestAuth = { kind: "loopback", scopes: ["admin", "client"] }; + +describe("headlessFullAccessFromEnv", () => { + it("is off when unset, empty, 0 or false", () => { + for (const value of [undefined, "", " ", "0", "false", "FALSE"]) { + expect(headlessFullAccessFromEnv({ OMB_HEADLESS_FULL_ACCESS: value }), String(value)).toBe(false); + } + }); + + it("is on for 1 or true", () => { + for (const value of ["1", "true", " TRUE "]) { + expect(headlessFullAccessFromEnv({ OMB_HEADLESS_FULL_ACCESS: value }), value).toBe(true); + } + }); + + it("refuses any other value by name, so the server does not start on it", () => { + for (const value of ["yes", "on", "2", "full"]) { + expect(() => headlessFullAccessFromEnv({ OMB_HEADLESS_FULL_ACCESS: value }), value) + .toThrow(/^OMB_HEADLESS_FULL_ACCESS must be 1 /); + } + }); + + it("stays off in the desktop app, which has its own private confirmation", () => { + expect(headlessFullAccessFromEnv({ OMB_HEADLESS_FULL_ACCESS: "1", OMB_DESKTOP_PARENT: "1" })).toBe(false); + }); +}); + +describe("headlessFullAccessPermits", () => { + const permits = (input: Partial[0]>) => + headlessFullAccessPermits({ enabled: true, auth: owner, current: "ask", requested: "full", ...input }); + + it("lets the loopback owner raise a bot to Full from Ask, Edits or Auto", () => { + for (const current of ["ask", "edits", "auto"] as const) expect(permits({ current }), current).toBe(true); + }); + + it("unlocks nothing without the opt-in", () => { + for (const current of APPROVAL_MODES) { + for (const requested of APPROVAL_MODES) { + expect(permits({ enabled: false, current, requested }), `${current} -> ${requested}`).toBe(false); + } + } + }); + + it("never unlocks Custom, nor leaving it", () => { + for (const current of APPROVAL_MODES) { + expect(permits({ current, requested: "custom" }), `${current} -> custom`).toBe(false); + } + for (const requested of APPROVAL_MODES) { + expect(permits({ current: "custom", requested }), `custom -> ${requested}`).toBe(false); + } + }); + + it("is only for the owner on loopback, not a paired session or a service-trust loopback", () => { + expect(permits({ auth: { kind: "loopback", scopes: ["client"], trust: "service" } })).toBe(false); + expect(permits({ + auth: { kind: "session", via: "bearer", scopes: ["admin", "client"], session: {} as never }, + })).toBe(false); + }); +}); diff --git a/server/headless-full-access.ts b/server/headless-full-access.ts new file mode 100644 index 0000000000..edde854ba5 --- /dev/null +++ b/server/headless-full-access.ts @@ -0,0 +1,43 @@ +// Lazurio MausBot: Full access over the API, as an operator opt-in. +// +// Upstream lets Full (and Custom) be chosen only from the packaged desktop +// app, over a private process channel: a headless server trusts every +// loopback request as its owner, so any local process could otherwise raise +// a bot to Full. On a Lazurio Environment the Machine, with its one +// operator, is the boundary, and bot teams run without a sandbox there +// (Lazurio decision, issue #3). OMB_HEADLESS_FULL_ACCESS=1 at server start +// lets the loopback owner make exactly that change through the ordinary bot +// PATCH: entering Full from Ask, Edits or Auto. Custom, and leaving Custom, +// stay desktop-only. Full then means what it means on the desktop; nothing +// here changes how a provider runs a Full turn. +// +// Contract: server/headless-full-access.test.ts and its e2e test. Off unless +// set; upstream behaviour is otherwise unchanged. +import type { ApprovalMode } from "../shared/approval-mode.ts"; +import type { RequestAuth } from "./request-auth.ts"; + +/** Whether the operator started the server with the opt-in. Unset, empty, + * `0` or `false`: off. `1` or `true`: on. Anything else stops the server at + * start. The desktop app keeps its own confirmation, so the opt-in is off + * there, like upstream's shared-workspace Full access policy. */ +export function headlessFullAccessFromEnv(env: NodeJS.ProcessEnv = process.env): boolean { + const flag = (env.OMB_HEADLESS_FULL_ACCESS ?? "").trim().toLowerCase(); + if (!flag || flag === "0" || flag === "false") return false; + if (flag !== "1" && flag !== "true") { + throw new Error(`OMB_HEADLESS_FULL_ACCESS must be 1 to allow Full access over the API, got "${env.OMB_HEADLESS_FULL_ACCESS}"`); + } + return env.OMB_DESKTOP_PARENT !== "1"; +} + +/** Whether a bot PATCH that upstream reserves for the desktop may go through: + * the opt-in is on, the caller is the owner on loopback, and the change + * enters Full from anything but Custom. */ +export function headlessFullAccessPermits(input: { + enabled: boolean; + auth: RequestAuth; + current: ApprovalMode; + requested: ApprovalMode; +}): boolean { + return input.enabled && input.auth.kind === "loopback" && input.auth.trust === undefined && + input.requested === "full" && input.current !== "custom"; +} diff --git a/server/index.ts b/server/index.ts index ebf22b8118..2d9ade51cc 100644 --- a/server/index.ts +++ b/server/index.ts @@ -567,6 +567,7 @@ import { createBotMemoryRoutes } from "./routes/bot-memory.ts"; import { createDeciderRoutes } from "./routes/decider.ts"; import { createGithubIntakeRoutes } from "./routes/github-intake.ts"; import { GithubIntake, githubIntakeConfigFromEnv, spawnGh } from "./github-intake.ts"; +import { headlessFullAccessFromEnv, headlessFullAccessPermits } from "./headless-full-access.ts"; const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799); const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1); @@ -578,6 +579,9 @@ const DEFAULT_BOT_CWD = defaultBotCwdFromEnv(); // The Lazurio GitHub intake (server/github-intake.ts): off unless // OMB_GITHUB_INTAKE=1; an unusable setting stops the server at start. const GITHUB_INTAKE = githubIntakeConfigFromEnv(); +// The operator's opt-in to Full access over the API (server/headless-full-access.ts). +const HEADLESS_FULL_ACCESS = headlessFullAccessFromEnv(); +if (HEADLESS_FULL_ACCESS) console.log("[approvals] OMB_HEADLESS_FULL_ACCESS is on: the local owner may set bots to Full access (no sandbox, no approval prompts) over the API"); const MIME: Record = { ".html": "text/html", ".js": "text/javascript", @@ -19074,7 +19078,10 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => { ((requestedApprovalMode === "full" || requestedApprovalMode === "custom") && currentApprovalMode !== requestedApprovalMode) || (currentApprovalMode === "custom" && requestedApprovalMode !== "custom"); - if (requiresPrivateApprovalTransition) { + // OMB_HEADLESS_FULL_ACCESS lets the loopback owner enter Full here; nothing else. + const headlessFull = requiresPrivateApprovalTransition && headlessFullAccessPermits({ + enabled: HEADLESS_FULL_ACCESS, auth, current: currentApprovalMode, requested: requestedApprovalMode }); + if (requiresPrivateApprovalTransition && !headlessFull) { return json(res, 403, { error: "This approval-level change can only be made from the packaged desktop app", }); @@ -19286,6 +19293,7 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => { bot = store.patchBot(m[1], patch); } if (!bot) return json(res, 404, { error: "no such bot" }); + if (headlessFull) console.warn(`bot ${bot.id}: approval level raised to Full by ${requestSource(req)} through the local API`); if (body.memoryUpkeep === false) memoryUpkeep.dropBot(bot.id); // A defined Works on is the newest explicit choice: this bot's // auto-recorded pins that now point elsewhere give way immediately, so