Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/lazurio-fork-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
28 changes: 22 additions & 6 deletions LAZURIO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down
14 changes: 10 additions & 4 deletions docs/lazurio-github-intake.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
17 changes: 13 additions & 4 deletions docs/operations/lazurio-fork-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (`<Organization> / <machine>`).
- 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
Expand All @@ -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
Expand All @@ -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

Expand Down
33 changes: 33 additions & 0 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<bot id> \
-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,
Expand Down
2 changes: 2 additions & 0 deletions scripts/control-omb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
143 changes: 143 additions & 0 deletions server/headless-full-access.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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"');
});
});
Loading