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.
- package/dist/assets/index-A3CBTxwl.css +1 -0
- package/dist/assets/{index-kW5L1QeO.js → index-BwpNqgCm.js} +1 -1
- package/dist/assets/index-CDuyoVPJ.js +278 -0
- package/dist/index.html +2 -2
- package/dist-server/computer-proxy.js +14 -3
- package/dist-server/container-mcp.js +42 -19
- package/dist-server/drivers/agents-proxy.js +31 -0
- package/dist-server/index.js +2416 -993
- package/dist-server/local-computer-proxy.js +433 -0
- package/dist-server/local-computer.js +48 -5
- package/dist-server/mcp-server.js +38 -17
- package/dist-server/openmausbot.js +1346 -726
- package/dist-server/pair-cli.js +1346 -726
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/account-signin.js +103 -0
- package/dist-server/server/caddy.js +203 -0
- package/dist-server/server/cli.js +163 -22
- package/dist-server/server/computer-proxy.js +11 -2
- package/dist-server/server/config.js +15 -0
- package/dist-server/server/control-client.js +7 -1
- package/dist-server/server/drivers/agents-proxy.js +34 -0
- package/dist-server/server/drivers/claude-login-auth.js +334 -0
- package/dist-server/server/drivers/claude.js +8 -0
- package/dist-server/server/environment.js +1 -0
- package/dist-server/server/harness/registry.js +7 -1
- package/dist-server/server/index.js +778 -310
- package/dist-server/server/local-computer-proxy.js +28 -0
- package/dist-server/server/local-computer.js +17 -0
- package/dist-server/server/mcp-bridge.js +27 -11
- package/dist-server/server/peer-approval.js +19 -6
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/sessions.js +42 -1
- package/dist-server/server/steer-queue.js +10 -8
- package/dist-server/server/store.js +234 -34
- package/dist-server/server/team-backup.js +1 -0
- package/dist-server/server/tunnel.js +27 -1
- package/dist-server/server/turn-resources.js +50 -0
- package/dist-server/server/workspace.js +53 -3
- package/dist-server/vps-container-mcp.js +42 -19
- package/package.json +1 -1
- package/dist/assets/index-BftlaHAF.css +0 -1
- 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 (
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
565
|
-
|
|
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
|
|
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)
|
|
738
|
-
|
|
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) {
|