openmausbot 0.1.66 → 0.1.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/assets/index-A3CBTxwl.css +1 -0
  2. package/dist/assets/{index-kW5L1QeO.js → index-BwpNqgCm.js} +1 -1
  3. package/dist/assets/index-CDuyoVPJ.js +278 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/computer-proxy.js +14 -3
  6. package/dist-server/container-mcp.js +42 -19
  7. package/dist-server/drivers/agents-proxy.js +31 -0
  8. package/dist-server/index.js +2416 -993
  9. package/dist-server/local-computer-proxy.js +433 -0
  10. package/dist-server/local-computer.js +48 -5
  11. package/dist-server/mcp-server.js +38 -17
  12. package/dist-server/openmausbot.js +1346 -726
  13. package/dist-server/pair-cli.js +1346 -726
  14. package/dist-server/proxy-paths.js +1 -0
  15. package/dist-server/server/account-signin.js +103 -0
  16. package/dist-server/server/caddy.js +203 -0
  17. package/dist-server/server/cli.js +163 -22
  18. package/dist-server/server/computer-proxy.js +11 -2
  19. package/dist-server/server/config.js +15 -0
  20. package/dist-server/server/control-client.js +7 -1
  21. package/dist-server/server/drivers/agents-proxy.js +34 -0
  22. package/dist-server/server/drivers/claude-login-auth.js +334 -0
  23. package/dist-server/server/drivers/claude.js +8 -0
  24. package/dist-server/server/environment.js +1 -0
  25. package/dist-server/server/harness/registry.js +7 -1
  26. package/dist-server/server/index.js +778 -310
  27. package/dist-server/server/local-computer-proxy.js +28 -0
  28. package/dist-server/server/local-computer.js +17 -0
  29. package/dist-server/server/mcp-bridge.js +27 -11
  30. package/dist-server/server/peer-approval.js +19 -6
  31. package/dist-server/server/proxy-paths.js +1 -0
  32. package/dist-server/server/sessions.js +42 -1
  33. package/dist-server/server/steer-queue.js +10 -8
  34. package/dist-server/server/store.js +234 -34
  35. package/dist-server/server/team-backup.js +1 -0
  36. package/dist-server/server/tunnel.js +27 -1
  37. package/dist-server/server/turn-resources.js +50 -0
  38. package/dist-server/server/workspace.js +53 -3
  39. package/dist-server/vps-container-mcp.js +42 -19
  40. package/package.json +1 -1
  41. package/dist/assets/index-BftlaHAF.css +0 -1
  42. package/dist/assets/index-DuR3H-Hf.js +0 -278
@@ -10,6 +10,7 @@ function resolveProxy(relative) {
10
10
  var SPAWNED_PROXIES = {
11
11
  browser: resolveProxy("browser-proxy"),
12
12
  computer: resolveProxy("computer-proxy"),
13
+ localComputer: resolveProxy("local-computer-proxy"),
13
14
  permission: resolveProxy("permission-proxy"),
14
15
  containerMcp: resolveProxy("container-mcp"),
15
16
  vpsContainerMcp: resolveProxy("vps-container-mcp"),
@@ -0,0 +1,103 @@
1
+ // Sign in with your email on a hosted server. The emailed code comes from the
2
+ // OpenMausBot control plane (the account service the desktop companion and
3
+ // `openmausbot login` already use), and this server decides who is welcome
4
+ // with an allow-list its owner controls. The result is an ordinary local
5
+ // session, the same thing a pairing code produces, so every gate applies.
6
+ //
7
+ // Why through the control plane rather than a mail provider per server: a
8
+ // self-hoster then needs no email credentials at all; the code arrives from
9
+ // accounts.openmausbot.com. The exchange happens server-side, so a browser
10
+ // only ever talks to this server, and a server with an empty allow-list does
11
+ // not expose the routes.
12
+ import { resolveCompanionControlPlaneURL } from "../electron/companion-account-service.mjs";
13
+ import { ControlPlaneError, createControlPlaneClient, normalizeAccountEmail } from "../electron/control-plane-client.mjs";
14
+ export const ADMIN_EMAILS_ENV = "OMB_SIGNIN_EMAILS";
15
+ export const MEMBER_EMAILS_ENV = "OMB_SIGNIN_MEMBER_EMAILS";
16
+ /** Commas, spaces or newlines between entries; case does not matter. */
17
+ export function parseAllowList(value) {
18
+ return (value ?? "")
19
+ .split(/[,\s]+/)
20
+ .map((entry) => entry.trim().toLowerCase())
21
+ .filter(Boolean);
22
+ }
23
+ export function allowedScopes(rawEmail, list) {
24
+ const email = rawEmail.trim().toLowerCase();
25
+ if (!email.includes("@"))
26
+ return null;
27
+ const matches = (entry) => (entry.startsWith("@") ? email.endsWith(entry) && email.length > entry.length : entry === email);
28
+ if (list.admins.some(matches))
29
+ return ["admin", "client"];
30
+ if (list.members.some(matches))
31
+ return ["client"];
32
+ return null;
33
+ }
34
+ export function signInEnabled(list) {
35
+ return list.admins.length + list.members.length > 0;
36
+ }
37
+ const NOT_WELCOME = { ok: false, status: 403, error: "this email is not on this server's sign-in list; ask the server's owner to add it" };
38
+ export function createEmailSignIn(options) {
39
+ const env = options.env ?? process.env;
40
+ const allow = () => (typeof options.allow === "function" ? options.allow() : options.allow);
41
+ let client = options.client ?? null;
42
+ const controlPlane = () => {
43
+ if (client)
44
+ return client;
45
+ const url = resolveCompanionControlPlaneURL({ isPackaged: true, environment: env });
46
+ if (!url)
47
+ throw new Error("OMB_CONTROL_PLANE_URL is set but is not an https address");
48
+ client = createControlPlaneClient({ baseURL: url, fetchImpl: options.fetchImpl });
49
+ return client;
50
+ };
51
+ return {
52
+ enabled: () => signInEnabled(allow()),
53
+ async start(rawEmail) {
54
+ const email = normalizeAccountEmail(rawEmail);
55
+ if (!email)
56
+ return { ok: false, status: 400, error: "enter a valid email address" };
57
+ if (!allowedScopes(email, allow()))
58
+ return NOT_WELCOME;
59
+ try {
60
+ await controlPlane().requestOTP(email);
61
+ }
62
+ catch (error) {
63
+ return unavailable(error);
64
+ }
65
+ return { ok: true };
66
+ },
67
+ async verify(rawEmail, code) {
68
+ const email = normalizeAccountEmail(rawEmail);
69
+ if (!email || !allowedScopes(email, allow()))
70
+ return NOT_WELCOME;
71
+ let verified;
72
+ try {
73
+ verified = await controlPlane().verifyOTP(email, code);
74
+ }
75
+ catch (error) {
76
+ // The client refuses a malformed code before any request (status 0);
77
+ // the control plane's own "invalid_otp" is a wrong or expired code.
78
+ if (error instanceof ControlPlaneError && error.code === "invalid_otp" && !error.status) {
79
+ return { ok: false, status: 400, error: "enter the 8-digit code from the email" };
80
+ }
81
+ if (error instanceof ControlPlaneError && error.status === 429) {
82
+ return { ok: false, status: 429, error: "too many attempts; wait a minute and request a new code" };
83
+ }
84
+ if (error instanceof ControlPlaneError && (error.status === 400 || error.status === 401 || error.status === 403)) {
85
+ return { ok: false, status: 401, error: "that code is wrong or has expired; request a new one" };
86
+ }
87
+ return unavailable(error);
88
+ }
89
+ // The account session on the control plane has done its job.
90
+ void controlPlane()
91
+ .signOut(verified.accountToken)
92
+ .catch(() => undefined);
93
+ const scopes = allowedScopes(verified.user.email, allow());
94
+ if (!scopes)
95
+ return NOT_WELCOME;
96
+ return { ok: true, email: verified.user.email, userId: verified.user.id, scopes };
97
+ },
98
+ };
99
+ }
100
+ function unavailable(error) {
101
+ const detail = error instanceof ControlPlaneError ? `${error.code}${error.status ? ` (${error.status})` : ""}` : error instanceof Error ? error.message : String(error);
102
+ return { ok: false, status: 502, error: `the sign-in service could not be reached (${detail}); try again in a moment` };
103
+ }
@@ -0,0 +1,203 @@
1
+ // `openmausbot serve --domain maus.example.com`: HTTPS on your own domain
2
+ // with nothing to configure. The server downloads a pinned Caddy once into
3
+ // the data dir (the same way it fetches cloudflared and the browser engine),
4
+ // writes the Caddyfile the Docker stack ships, and runs Caddy as its child:
5
+ // Caddy gets and renews the certificate from Let's Encrypt and forwards to
6
+ // the loopback server with the real Host and the forwarded headers the
7
+ // server's remote-session rules expect. The loopback bind never changes.
8
+ //
9
+ // What the operator still owns: a DNS record for the domain pointing at this
10
+ // machine, and ports 80 and 443 reachable. Binding those ports needs a
11
+ // privilege on Linux; `serve` says exactly which command grants it to the
12
+ // downloaded binary when Caddy reports the refusal.
13
+ import { spawn, spawnSync } from "node:child_process";
14
+ import { createHash, randomBytes } from "node:crypto";
15
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { delimiter, join, resolve } from "node:path";
18
+ export const CADDY_VERSION = "2.11.4";
19
+ /** Release assets by platform-arch, with the sha512 from the release's checksums file. */
20
+ export const CADDY_ASSETS = Object.freeze({
21
+ "linux-x64": { name: `caddy_${CADDY_VERSION}_linux_amd64.tar.gz`, sha512: "8220d1f013b6f27510247b2360c9e0ca9f018feebd82515f07635318b34ff9777ccc8fd0b6e6f2486ce3a33fe389fbb7db12d05baa474f4587509fb4f5ebf1c9" },
22
+ "linux-arm64": { name: `caddy_${CADDY_VERSION}_linux_arm64.tar.gz`, sha512: "d5a7c423853c24a799765e0e8210d5c7c22a8f56ed37a3cae2fb9f58be138853c02b4efd6b59d576e6d8c7c0d30b9c1592deeaa6a536ff69bcca23b8c1ea709c" },
23
+ "darwin-x64": { name: `caddy_${CADDY_VERSION}_mac_amd64.tar.gz`, sha512: "e04eb10f9ce7e2e079bc9bff1bd5d3a3164888d1edbb1a49e5d15be4eab691b57e89ed36bb29c65ba43f1ba8d9279e0967b1003991c13fe4cb78384c3caf25de" },
24
+ "darwin-arm64": { name: `caddy_${CADDY_VERSION}_mac_arm64.tar.gz`, sha512: "3190ae0df98b59ab4b6021556fa35adc3c526a4f3e138776b0eaec8a037cc26121cbbb1ad53453f565551b47d37d5ba4755e2c2c3652256737fe2ce9e53c8ec0" },
25
+ });
26
+ const DOWNLOAD_TIMEOUT_MS = 180_000;
27
+ export function caddyTarget(platform = process.platform, arch = process.arch) {
28
+ return `${platform}-${arch}`;
29
+ }
30
+ export function pinnedCaddyPath(dataDir, platform = process.platform, arch = process.arch) {
31
+ return join(dataDir, "caddy", `${CADDY_VERSION}-${caddyTarget(platform, arch)}`, platform === "win32" ? "caddy.exe" : "caddy");
32
+ }
33
+ /** OMB_CADDY_PATH, then the pinned download, then a `caddy` on PATH. */
34
+ export function resolveCaddyBinary(options) {
35
+ const env = options.env ?? process.env;
36
+ const exists = options.exists ?? existsSync;
37
+ const platform = options.platform ?? process.platform;
38
+ const override = env.OMB_CADDY_PATH?.trim();
39
+ if (override)
40
+ return resolve(override) === override && exists(override) ? override : null;
41
+ const pinned = pinnedCaddyPath(options.dataDir, platform, options.arch);
42
+ if (exists(pinned))
43
+ return pinned;
44
+ const executable = platform === "win32" ? "caddy.exe" : "caddy";
45
+ for (const part of (env.PATH ?? "").split(delimiter)) {
46
+ const dir = part.trim();
47
+ if (!dir)
48
+ continue;
49
+ const candidate = join(dir, executable);
50
+ if (exists(candidate))
51
+ return candidate;
52
+ }
53
+ return null;
54
+ }
55
+ /** Download the pinned release once, verify its digest, extract the binary. */
56
+ export async function ensureCaddy(options) {
57
+ const platform = options.platform ?? process.platform;
58
+ const found = resolveCaddyBinary({ dataDir: options.dataDir, env: options.env, platform, arch: options.arch });
59
+ if (found)
60
+ return found;
61
+ if (platform === "win32")
62
+ throw new Error("--domain is not available on Windows yet; put your own HTTPS proxy in front (docs/self-hosting.md)");
63
+ const target = caddyTarget(platform, options.arch);
64
+ const asset = options.asset ?? CADDY_ASSETS[target];
65
+ if (!asset)
66
+ throw new Error(`Caddy publishes no build for ${target}`);
67
+ const destination = pinnedCaddyPath(options.dataDir, platform, options.arch);
68
+ const directory = join(destination, "..");
69
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
70
+ const url = asset.url ?? `https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/${asset.name}`;
71
+ options.log?.(`downloading Caddy ${CADDY_VERSION} once (about 15 MB, digest pinned) into ${directory}`);
72
+ const controller = new AbortController();
73
+ const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
74
+ timer.unref?.();
75
+ let body;
76
+ try {
77
+ const response = await (options.fetchImpl ?? fetch)(url, { redirect: "follow", signal: controller.signal });
78
+ if (!response.ok)
79
+ throw new Error(`the Caddy download failed (HTTP ${response.status})`);
80
+ body = Buffer.from(await response.arrayBuffer());
81
+ }
82
+ finally {
83
+ clearTimeout(timer);
84
+ }
85
+ const digest = createHash("sha512").update(body).digest("hex");
86
+ if (digest !== asset.sha512)
87
+ throw new Error("the Caddy download failed its SHA-512 check; nothing was installed");
88
+ const scratch = mkdtempSync(join(tmpdir(), "openmaus-caddy-"));
89
+ try {
90
+ const archive = join(scratch, asset.name);
91
+ writeFileSync(archive, body, { mode: 0o600 });
92
+ const extracted = spawnSync("tar", ["-xzf", archive, "-C", scratch, "caddy"], { stdio: "ignore" });
93
+ if (extracted.status !== 0)
94
+ throw new Error("the Caddy archive could not be extracted (is `tar` installed?)");
95
+ const staging = `${destination}.${randomBytes(6).toString("hex")}.part`;
96
+ renameSync(join(scratch, "caddy"), staging);
97
+ chmodSync(staging, 0o755);
98
+ renameSync(staging, destination);
99
+ }
100
+ finally {
101
+ rmSync(scratch, { recursive: true, force: true });
102
+ }
103
+ return destination;
104
+ }
105
+ /** The Docker stack's Caddyfile with this server's ports; certificates and
106
+ * state live under the data dir so they survive restarts and upgrades. */
107
+ export function caddyfileFor(input) {
108
+ return [
109
+ "# Written by openmausbot serve --domain. Edit the command, not this file.",
110
+ "{",
111
+ "\tadmin off",
112
+ "\tlog {",
113
+ "\t\tlevel WARN",
114
+ "\t}",
115
+ "}",
116
+ "",
117
+ `${input.domain} {`,
118
+ "\tencode zstd gzip",
119
+ "",
120
+ "\t# Webhook ingress has its own per-hook secrets and is meant for third parties.",
121
+ "\thandle /hooks/* {",
122
+ `\t\treverse_proxy 127.0.0.1:${input.webhookPort}`,
123
+ "\t}",
124
+ "",
125
+ "\thandle {",
126
+ `\t\treverse_proxy 127.0.0.1:${input.appPort} {`,
127
+ "\t\t\t# SSE: stream events as they arrive",
128
+ "\t\t\tflush_interval -1",
129
+ "\t\t}",
130
+ "\t}",
131
+ "}",
132
+ "",
133
+ ].join("\n");
134
+ }
135
+ /** Whether a Caddy exit was the ports refusing to bind (a privilege problem, not a config one). */
136
+ export function portPermissionRefused(output) {
137
+ return /permission denied|bind: operation not permitted|EACCES/i.test(output) && /:80|:443|listen/i.test(output);
138
+ }
139
+ export function portPermissionHint(binary) {
140
+ return process.platform === "linux"
141
+ ? `Caddy may not bind ports 80 and 443 as this user. Grant it once with:\n sudo setcap 'cap_net_bind_service=+ep' ${binary}\nthen run the same command again. (A service unit can grant the same capability.)`
142
+ : "Caddy may not bind ports 80 and 443 as this user. Run the server as an administrator, or put your own HTTPS proxy in front.";
143
+ }
144
+ /** Run Caddy as a child with the Caddyfile for this domain. Resolves once
145
+ * Caddy has stayed up for a moment; rejects with its output if it exits first. */
146
+ export async function startCaddy(options) {
147
+ const home = join(options.dataDir, "caddy");
148
+ mkdirSync(join(home, "data"), { recursive: true, mode: 0o700 });
149
+ mkdirSync(join(home, "config"), { recursive: true, mode: 0o700 });
150
+ const configFile = join(home, "Caddyfile");
151
+ writeFileSync(configFile, caddyfileFor(options), { mode: 0o600 });
152
+ const child = spawn(options.binary, ["run", "--config", configFile, "--adapter", "caddyfile"], {
153
+ env: { ...(options.env ?? process.env), XDG_DATA_HOME: join(home, "data"), XDG_CONFIG_HOME: join(home, "config") },
154
+ stdio: ["ignore", "pipe", "pipe"],
155
+ });
156
+ let output = "";
157
+ const receive = (chunk) => {
158
+ const text = chunk.toString("utf8");
159
+ if (output.length < 16_384)
160
+ output += text;
161
+ for (const line of text.split(/\r?\n/)) {
162
+ if (!line.trim())
163
+ continue;
164
+ // Caddy logs JSON; surface warnings and errors, keep the rest quiet.
165
+ if (/"level":"(warn|error)"/.test(line) || /^\S+\s+(WARN|ERROR)/.test(line) || !line.startsWith("{"))
166
+ options.log?.(`caddy: ${line.slice(0, 400)}`);
167
+ }
168
+ };
169
+ child.stdout?.on("data", receive);
170
+ child.stderr?.on("data", receive);
171
+ const exited = new Promise((done) => {
172
+ child.once("error", () => done(1));
173
+ child.once("exit", (code) => done(code));
174
+ });
175
+ const settled = await Promise.race([exited.then((code) => ({ code })), new Promise((r) => setTimeout(() => r({ code: undefined }), options.settleMs ?? 1500))]);
176
+ if (settled.code !== undefined) {
177
+ const reason = portPermissionRefused(output) ? portPermissionHint(options.binary) : output.trim().split("\n").slice(-3).join("\n");
178
+ throw new Error(`Caddy stopped right after starting (exit ${settled.code}).\n${reason}`);
179
+ }
180
+ return {
181
+ configFile,
182
+ exited,
183
+ stop: async () => {
184
+ if (child.exitCode !== null || child.signalCode !== null)
185
+ return;
186
+ child.kill("SIGTERM");
187
+ await Promise.race([exited, new Promise((r) => setTimeout(r, 5_000))]);
188
+ if (child.exitCode === null && child.signalCode === null)
189
+ child.kill("SIGKILL");
190
+ },
191
+ };
192
+ }
193
+ /** `--domain` takes a bare hostname; the same shape Settings → Custom domain accepts. */
194
+ export function normalizeDomainOption(raw) {
195
+ const value = raw.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/+$/, "");
196
+ const label = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
197
+ if (!value || value.length > 253 || !value.includes(".") || /[\s/:@?#]/.test(value) || !value.split(".").every((part) => label.test(part))) {
198
+ return { error: "--domain takes a bare hostname such as maus.example.com" };
199
+ }
200
+ if (/(?:^|\.)(?:localhost|local|internal|invalid)$/.test(value))
201
+ return { error: "--domain needs a public domain name that resolves to this machine" };
202
+ return value;
203
+ }
@@ -7,7 +7,7 @@
7
7
  // openmausbot setup [--data-dir ~/.openmausbot]
8
8
  // openmausbot start [serve options]
9
9
  // openmausbot serve [--port 8799] [--data-dir ~/.openmausbot] [--label "cab mini"]
10
- // [--public-url https://host] [--tailscale | --tunnel] [--no-pair]
10
+ // [--public-url https://host] [--tailscale | --tunnel | --domain HOST] [--no-pair]
11
11
  // openmausbot pair [--label "My MacBook"] [--client] [--public-url https://host]
12
12
  // openmausbot sessions [revoke <id>]
13
13
  // openmausbot status
@@ -29,12 +29,15 @@ import { dirname, join, resolve } from "node:path";
29
29
  import { createInterface } from "node:readline/promises";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import qrcode from "qrcode-terminal";
32
+ import { parseAllowList } from "./account-signin.js";
33
+ import { writeFileAtomic } from "./atomic.js";
34
+ import { ensureCaddy, normalizeDomainOption, startCaddy } from "./caddy.js";
32
35
  import { explainTailscaleFailure, tailscaleServe, tailscaleServeOff, tailscaleStatus } from "./tailscale.js";
33
36
  import { defaultSetupIo, SetupCancelled } from "./cli-prompts.js";
34
37
  import { normalizePhoneOrigin, phonePairingInstructions, runPhoneSetup } from "./cli-phone-setup.js";
35
- import { cleanupTunnelOrigin, createTunnelAccount, createTunnelOrigin, describeTunnelAccount, describeTunnelState, ensureCloudflared, guardianEntry, startTunnel, tunnelAccess, } from "./tunnel.js";
38
+ import { cleanupTunnelOrigin, createTunnelAccount, createTunnelOrigin, describeTunnelAccount, describeTunnelState, ensureCloudflared, FLEET_CREDENTIAL_ENV, fleetAccess, fleetCredential, guardianEntry, startTunnel, tunnelAccess, } from "./tunnel.js";
36
39
  const HERE = dirname(fileURLToPath(import.meta.url));
37
- const COMMANDS = ["setup", "start", "serve", "pair", "sessions", "status", "login", "logout", "browser", "help", "--help", "-h"];
40
+ const COMMANDS = ["setup", "start", "serve", "pair", "sessions", "status", "login", "logout", "access", "browser", "help", "--help", "-h"];
38
41
  export function parseArgs(argv, env = process.env) {
39
42
  const implicitStart = !argv.length || (argv[0].startsWith("--") && argv[0] !== "--help");
40
43
  const [command = "start", ...rest] = implicitStart ? ["start", ...argv] : argv;
@@ -51,6 +54,7 @@ export function parseArgs(argv, env = process.env) {
51
54
  pair: true,
52
55
  withDeps: false,
53
56
  json: false,
57
+ chatOnly: false,
54
58
  };
55
59
  for (let i = 0; i < rest.length; i += 1) {
56
60
  const arg = rest[i];
@@ -72,6 +76,12 @@ export function parseArgs(argv, env = process.env) {
72
76
  options.publicUrl = value().replace(/\/+$/, "");
73
77
  else if (arg === "--tailscale")
74
78
  options.tailscale = true;
79
+ else if (arg === "--domain") {
80
+ const domain = normalizeDomainOption(value());
81
+ if (typeof domain !== "string")
82
+ return domain;
83
+ options.domain = domain;
84
+ }
75
85
  else if (arg === "--tunnel")
76
86
  options.tunnel = true;
77
87
  else if (arg === "--client")
@@ -88,6 +98,13 @@ export function parseArgs(argv, env = process.env) {
88
98
  options.email = value();
89
99
  else if (options.command === "sessions" && arg === "revoke")
90
100
  options.revoke = value();
101
+ else if (options.command === "access" && !options.accessAction && (arg === "list" || arg === "add" || arg === "remove")) {
102
+ options.accessAction = arg;
103
+ if (arg !== "list")
104
+ options.email = value();
105
+ }
106
+ else if (options.command === "access" && arg === "--chat-only")
107
+ options.chatOnly = true;
91
108
  else if (options.command === "browser" && (arg === "install" || arg === "status"))
92
109
  options.browserAction = arg;
93
110
  else if (options.command === "browser" && arg === "--with-deps")
@@ -105,6 +122,10 @@ export function parseArgs(argv, env = process.env) {
105
122
  return { error: "--public-url must start with http:// or https://" };
106
123
  if (options.tailscale && options.tunnel)
107
124
  return { error: "choose one of --tailscale (your tailnet) and --tunnel (a public address)" };
125
+ if (options.command === "access" && !options.accessAction)
126
+ return { error: "access needs one of: list, add EMAIL [--chat-only], remove EMAIL" };
127
+ if (options.domain && (options.tailscale || options.tunnel || options.publicUrl))
128
+ return { error: "--domain already gives the server its address; drop --tailscale, --tunnel and --public-url" };
108
129
  if (options.local && (options.tailscale || options.tunnel || options.publicUrl))
109
130
  return { error: "--local cannot be combined with a remote-access option" };
110
131
  if (options.command === "browser" && !options.browserAction)
@@ -117,12 +138,13 @@ export const USAGE = `openmausbot — your team of AI bots, ready in a few steps
117
138
  openmausbot setup [--data-dir DIR]
118
139
  openmausbot start [the same options as serve]
119
140
  openmausbot serve [--port 8799] [--data-dir DIR] [--label NAME]
120
- [--public-url https://host] [--tailscale | --tunnel] [--no-pair]
141
+ [--public-url https://host] [--tailscale | --tunnel | --domain HOST] [--no-pair]
121
142
  openmausbot pair [--label NAME] [--client] [--public-url https://host]
122
143
  openmausbot sessions [revoke ID]
123
144
  openmausbot status
124
145
  openmausbot login [--email you@example.com]
125
146
  openmausbot logout
147
+ openmausbot access list | add EMAIL [--chat-only] | remove EMAIL
126
148
  openmausbot browser install [--with-deps] | status
127
149
 
128
150
  setup choose AI access and optional phone access; keep existing bots and chats
@@ -134,6 +156,9 @@ status what the server says about itself
134
156
  login signs this machine in to an OpenMausBot account (an emailed code)
135
157
  and reserves its public address for --tunnel
136
158
  logout releases that address and signs out
159
+ access who may sign in with an emailed code at /pair: an address or
160
+ @domain; --chat-only gives chat and approvals without settings.
161
+ Takes effect at once, no restart.
137
162
  browser install: the bots' browser engine (agent-browser, pinned) into the
138
163
  data dir, and Chrome for Testing into the user's browser cache.
139
164
  --with-deps also installs
@@ -147,6 +172,10 @@ browser install: the bots' browser engine (agent-browser, pinned) into the
147
172
  --tunnel serve at a public https://….openmausbot.com address through a
148
173
  Cloudflare tunnel: no domain, no proxy, no open port. Run
149
174
  \`openmausbot login\` once on this machine first.
175
+ --domain serve at https://HOST on your own domain: a pinned Caddy is
176
+ downloaded once and run alongside the server, and gets the
177
+ certificate itself. Point the domain's DNS at this machine and
178
+ open ports 80 and 443.
150
179
 
151
180
  --no-open do not open a browser window
152
181
  --no-pair skip phone setup and do not print a pairing code
@@ -418,13 +447,80 @@ export async function runStatus(options, io = defaultIo()) {
418
447
  }
419
448
  if (!options.json) {
420
449
  const account = describeTunnelAccount(createTunnelAccount({ dataDir: options.dataDir, version: serverVersion() }).credentials.read());
421
- if (account.address)
450
+ if (fleetCredential())
451
+ io.log(`public address: managed by the fleet (${FLEET_CREDENTIAL_ENV} is set; the address is fetched when serve --tunnel starts)`);
452
+ else if (account.address)
422
453
  io.log(`public address: ${account.address} (signed in as ${account.email ?? "?"}; serve it with --tunnel)`);
423
454
  }
424
455
  return code;
425
456
  }
457
+ /** The sign-in allow-list, edited straight in config.json: the server reads
458
+ * it per request, so this works with the server running or stopped and
459
+ * needs no restart. Environment variables (OMB_SIGNIN_EMAILS) win when set.
460
+ * Written the way the server writes it (atomic, 0600), touching only the
461
+ * one key, so nothing else in the file moves. */
462
+ export async function runAccess(options, io = defaultIo()) {
463
+ const file = join(options.dataDir, "config.json");
464
+ let raw = {};
465
+ if (existsSync(file)) {
466
+ try {
467
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
468
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
469
+ throw new Error("not an object");
470
+ raw = Object.fromEntries(Object.entries(parsed));
471
+ }
472
+ catch (error) {
473
+ io.error(`${file} could not be read (${message(error)}); fix it before changing who can sign in`);
474
+ return 1;
475
+ }
476
+ }
477
+ const current = typeof raw.signIn === "object" && raw.signIn !== null ? Object(raw.signIn) : {};
478
+ const list = (value) => parseAllowList(Array.isArray(value) ? value.map(String).join(",") : "");
479
+ const admins = list(Reflect.get(current, "admins"));
480
+ const members = list(Reflect.get(current, "members"));
481
+ const overridden = process.env.OMB_SIGNIN_EMAILS !== undefined || process.env.OMB_SIGNIN_MEMBER_EMAILS !== undefined;
482
+ const write = (next) => {
483
+ mkdirSync(options.dataDir, { recursive: true, mode: 0o700 });
484
+ writeFileAtomic(file, `${JSON.stringify({ ...raw, signIn: next }, null, 2)}\n`, { mode: 0o600 });
485
+ };
486
+ if (options.accessAction === "list") {
487
+ if (!admins.length && !members.length) {
488
+ io.log("nobody can sign in with an email yet; pairing codes only. Add someone with: openmausbot access add you@example.com");
489
+ return 0;
490
+ }
491
+ for (const entry of admins)
492
+ io.log(`${entry.padEnd(40)} full access`);
493
+ for (const entry of members)
494
+ io.log(`${entry.padEnd(40)} chat and approvals`);
495
+ if (overridden)
496
+ io.log("(OMB_SIGNIN_EMAILS / OMB_SIGNIN_MEMBER_EMAILS are set in the environment and win over this list while the server runs)");
497
+ return 0;
498
+ }
499
+ const entry = (options.email ?? "").trim().toLowerCase();
500
+ if (!entry || (!entry.startsWith("@") && !entry.includes("@")) || /\s/.test(entry)) {
501
+ io.error("give an email address, or @domain for everyone at that domain");
502
+ return 2;
503
+ }
504
+ const without = (items) => items.filter((item) => item !== entry);
505
+ if (options.accessAction === "remove") {
506
+ if (!admins.includes(entry) && !members.includes(entry)) {
507
+ io.error(`${entry} is not on the list`);
508
+ return 1;
509
+ }
510
+ write({ admins: without(admins), members: without(members) });
511
+ io.log(`${entry} can no longer sign in (existing sessions stay until they expire or are revoked with \`openmausbot sessions revoke\`)`);
512
+ return 0;
513
+ }
514
+ write(options.chatOnly ? { admins: without(admins), members: [...without(members), entry] } : { admins: [...without(admins), entry], members: without(members) });
515
+ io.log(`${entry} can sign in at /pair with an emailed code (${options.chatOnly ? "chat and approvals" : "full access"})`);
516
+ if (overridden)
517
+ io.log("note: OMB_SIGNIN_EMAILS / OMB_SIGNIN_MEMBER_EMAILS are set in the environment and win over this list while the server runs");
518
+ return 0;
519
+ }
426
520
  export async function runLogin(options, io = defaultIo()) {
427
521
  const account = createTunnelAccount({ dataDir: options.dataDir, version: serverVersion() });
522
+ if (fleetCredential())
523
+ io.log(`note: ${FLEET_CREDENTIAL_ENV} is set, so serve --tunnel will use that credential rather than this account`);
428
524
  if (account.credentials.status === "unavailable") {
429
525
  io.error(`${account.credentials.file} exists but could not be read; fix or remove it, then try again`);
430
526
  return 1;
@@ -549,24 +645,38 @@ export function serverEntry(here = HERE) {
549
645
  /** Everything `--tunnel` needs before the server starts, or the one reason
550
646
  * it cannot have it. Fails closed: no silent fallback to a local-only server. */
551
647
  async function planTunnel(options, log) {
552
- const account = createTunnelAccount({ dataDir: options.dataDir, version: serverVersion() });
553
- if (account.credentials.status === "unavailable")
554
- return { error: `${account.credentials.file} exists but could not be read; fix or remove it` };
555
- if (!describeTunnelAccount(account.credentials.read()).email) {
556
- return { error: "no account on this machine yet: run `openmausbot login` first, then `openmausbot serve --tunnel`" };
557
- }
558
- // A fresh connector token when the control plane answers; the saved one otherwise.
559
- try {
560
- const state = await account.service.retry();
561
- if (state.message && !tunnelAccess(account.credentials.read()))
562
- log(`tunnel: ${state.message}`);
648
+ let access = null;
649
+ const credential = fleetCredential();
650
+ if (credential) {
651
+ // A fleet-started container: the credential is the whole identity.
652
+ log(`tunnel: using the installation credential from ${FLEET_CREDENTIAL_ENV}`);
653
+ try {
654
+ access = await fleetAccess({ credential });
655
+ }
656
+ catch (error) {
657
+ return { error: `--tunnel: ${message(error)}` };
658
+ }
563
659
  }
564
- catch (error) {
565
- log(`tunnel: control plane not reachable right now (${message(error)}); using the saved address`);
660
+ else {
661
+ const account = createTunnelAccount({ dataDir: options.dataDir, version: serverVersion() });
662
+ if (account.credentials.status === "unavailable")
663
+ return { error: `${account.credentials.file} exists but could not be read; fix or remove it` };
664
+ if (!describeTunnelAccount(account.credentials.read()).email) {
665
+ return { error: "no account on this machine yet: run `openmausbot login` first, then `openmausbot serve --tunnel`" };
666
+ }
667
+ // A fresh connector token when the control plane answers; the saved one otherwise.
668
+ try {
669
+ const state = await account.service.retry();
670
+ if (state.message && !tunnelAccess(account.credentials.read()))
671
+ log(`tunnel: ${state.message}`);
672
+ }
673
+ catch (error) {
674
+ log(`tunnel: control plane not reachable right now (${message(error)}); using the saved address`);
675
+ }
676
+ access = tunnelAccess(account.credentials.read());
677
+ if (!access)
678
+ return { error: "this machine has no public address; run `openmausbot login` again" };
566
679
  }
567
- const access = tunnelAccess(account.credentials.read());
568
- if (!access)
569
- return { error: "this machine has no public address; run `openmausbot login` again" };
570
680
  let binary;
571
681
  try {
572
682
  binary = await ensureCloudflared({ dataDir: options.dataDir, log });
@@ -607,6 +717,17 @@ export async function runServe(options, log = console.log) {
607
717
  log(`note: --public-url is ignored with --tunnel; the address is ${plan.access.endpoint}`);
608
718
  publicUrl = plan.access.endpoint;
609
719
  }
720
+ let caddyBinary = null;
721
+ if (options.domain) {
722
+ try {
723
+ caddyBinary = await ensureCaddy({ dataDir: options.dataDir, log });
724
+ }
725
+ catch (error) {
726
+ console.error(`--domain: ${message(error)}`);
727
+ return 1;
728
+ }
729
+ publicUrl = `https://${options.domain}`;
730
+ }
610
731
  const entry = serverEntry();
611
732
  if (!entry.staticDir)
612
733
  log("note: no built UI found next to the server; the API runs but browsers get no page (build with `pnpm exec vite build`)");
@@ -685,12 +806,15 @@ export async function runServe(options, log = console.log) {
685
806
  child.once("exit", (code, signal) => { exited = code ?? (signal === "SIGTERM" || signal === "SIGINT" ? 0 : 1); done(exited); });
686
807
  });
687
808
  let tunnel = null;
809
+ let caddy = null;
688
810
  let stopping = null;
689
811
  const stop = () => {
690
812
  stopping ??= (async () => {
691
- // The gateway stops accepting before the server it forwards to goes away.
813
+ // The gateway and the edge stop accepting before the server they forward to goes away.
692
814
  if (tunnel)
693
815
  await tunnel.stop().catch(() => undefined);
816
+ if (caddy)
817
+ await caddy.stop().catch(() => undefined);
694
818
  if (tailscaleServing && tailscale)
695
819
  await tailscaleServeOff(tailscale).catch(() => undefined);
696
820
  if (exited === null) {
@@ -729,6 +853,21 @@ export async function runServe(options, log = console.log) {
729
853
  }
730
854
  if (stopping || exited !== null)
731
855
  return await childExit;
856
+ if (options.domain && caddyBinary) {
857
+ try {
858
+ caddy = await startCaddy({ binary: caddyBinary, dataDir: options.dataDir, domain: options.domain, appPort: options.port, webhookPort: Number(env.OMB_WEBHOOK_PORT), log });
859
+ log(`https: Caddy serves ${publicUrl} → http://127.0.0.1:${options.port}; it gets the certificate from Let's Encrypt once DNS for ${options.domain} points at this machine`);
860
+ void caddy.exited.then((code) => {
861
+ if (!stopping)
862
+ log(`caddy: stopped (exit ${code ?? "signal"}); ${publicUrl} is no longer served. Stop and start the server again.`);
863
+ });
864
+ }
865
+ catch (error) {
866
+ console.error(`--domain: ${message(error)}`);
867
+ await stop();
868
+ return 1;
869
+ }
870
+ }
732
871
  if (plan && child.pid) {
733
872
  tunnel = startTunnel({
734
873
  dataDir: options.dataDir,
@@ -872,6 +1011,8 @@ export async function main(argv = process.argv.slice(2)) {
872
1011
  return runStatus(options);
873
1012
  case "login":
874
1013
  return runLogin(options);
1014
+ case "access":
1015
+ return runAccess(options);
875
1016
  case "logout":
876
1017
  return runLogout(options);
877
1018
  case "browser":
@@ -734,14 +734,18 @@ async function semanticActAndObserve(id, action, ref, value, args) {
734
734
  * told when they finish, and the two that read nothing from the screen. */
735
735
  const OPEN_WHILE_DRIVEN = new Set(["computer_request_help", "computer_status", "observation_metrics"]);
736
736
  async function call(id, name, args) {
737
- if (!OPEN_WHILE_DRIVEN.has(name) && (await control.state(true)).held) {
738
- return text(id, CONTROL_REFUSAL, true);
737
+ if (!OPEN_WHILE_DRIVEN.has(name)) {
738
+ const state = await control.state(true);
739
+ if (state.held)
740
+ return text(id, state.blockedReason ?? CONTROL_REFUSAL, true);
739
741
  }
740
742
  if (name === "computer_request_help") {
741
743
  if (!control.configured) {
742
744
  return text(id, "nobody can be paged for this computer right now — carry on carefully", true);
743
745
  }
744
746
  const initial = await control.state(true);
747
+ if (initial.held && initial.blockedReason)
748
+ return text(id, initial.blockedReason, true);
745
749
  // If the person is already driving, don't clobber whatever plea they
746
750
  // are reading — just wait for the hand-back.
747
751
  const requestId = initial.held ? null : await control.requestHelp(String(args?.reason ?? ""));
@@ -753,6 +757,11 @@ async function call(id, name, args) {
753
757
  while (Date.now() < deadline) {
754
758
  await new Promise((resolve) => setTimeout(resolve, CONTROL_POLL_MS));
755
759
  const state = await control.state(true);
760
+ if (state.held && state.blockedReason) {
761
+ if (requestId)
762
+ await control.expireHelp(requestId);
763
+ return text(id, state.blockedReason, true);
764
+ }
756
765
  if (state.held)
757
766
  sawHold = true;
758
767
  if (!state.held && !state.helpOpen) {