openmausbot 0.1.60 → 0.1.62
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/README.md +71 -8
- package/dist/assets/index-BSI5e_fY.js +264 -0
- package/dist/assets/{index-BIzf_lw1.js → index-DTeYtYoC.js} +1 -1
- package/dist/index.html +1 -1
- package/dist-server/container-mcp.js +16 -1
- package/dist-server/drivers/agents-proxy.js +8 -4
- package/dist-server/index.js +463 -134
- package/dist-server/openmausbot.js +33585 -14506
- package/dist-server/pair-cli.js +33585 -14506
- package/dist-server/server/browser-bundle-release.js +56 -0
- package/dist-server/server/browser-engine-release.js +13 -5
- package/dist-server/server/browser-engine.js +114 -15
- package/dist-server/server/cli-api-setup.js +144 -0
- package/dist-server/server/cli-phone-setup.js +130 -0
- package/dist-server/server/cli-prompts.js +185 -0
- package/dist-server/server/cli-setup.js +303 -0
- package/dist-server/server/cli.js +375 -70
- package/dist-server/server/config.js +25 -1
- package/dist-server/server/default-model-selection.js +21 -0
- package/dist-server/server/drivers/agents-proxy.js +13 -4
- package/dist-server/server/drivers/claude.js +26 -1
- package/dist-server/server/drivers/openai-compat.js +4 -2
- package/dist-server/server/index.js +117 -80
- package/dist-server/server/recall-disclosure.js +45 -0
- package/dist-server/server/request-auth.js +6 -0
- package/dist-server/server/screen-frame-gate.js +36 -1
- package/dist-server/server/screen-frame-source.js +57 -0
- package/dist-server/server/sessions.js +52 -4
- package/dist-server/vps-container-mcp.js +16 -1
- package/package.json +1 -1
- package/dist/assets/index-Tha1umgl.js +0 -264
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// `pnpm omb` (a checkout) — because scripts/bundle-server.mjs bundles this
|
|
5
5
|
// file next to the server.
|
|
6
6
|
//
|
|
7
|
+
// openmausbot setup [--data-dir ~/.openmausbot]
|
|
8
|
+
// openmausbot start [serve options]
|
|
7
9
|
// openmausbot serve [--port 8799] [--data-dir ~/.openmausbot] [--label "cab mini"]
|
|
8
10
|
// [--public-url https://host] [--tailscale | --tunnel] [--no-pair]
|
|
9
11
|
// openmausbot pair [--label "My MacBook"] [--client] [--public-url https://host]
|
|
@@ -21,19 +23,21 @@
|
|
|
21
23
|
// This module only exports; openmausbot.ts is the entry that runs main(), so
|
|
22
24
|
// bundling this file into other entries (pair-cli.ts) never runs it twice.
|
|
23
25
|
import { spawn } from "node:child_process";
|
|
24
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
26
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync } from "node:fs";
|
|
25
27
|
import { homedir } from "node:os";
|
|
26
28
|
import { dirname, join, resolve } from "node:path";
|
|
27
29
|
import { createInterface } from "node:readline/promises";
|
|
28
30
|
import { fileURLToPath } from "node:url";
|
|
29
31
|
import qrcode from "qrcode-terminal";
|
|
30
32
|
import { explainTailscaleFailure, tailscaleServe, tailscaleServeOff, tailscaleStatus } from "./tailscale.js";
|
|
31
|
-
import {
|
|
33
|
+
import { defaultSetupIo, SetupCancelled } from "./cli-prompts.js";
|
|
34
|
+
import { normalizePhoneOrigin, phonePairingInstructions, runPhoneSetup } from "./cli-phone-setup.js";
|
|
32
35
|
import { cleanupTunnelOrigin, createTunnelAccount, createTunnelOrigin, describeTunnelAccount, describeTunnelState, ensureCloudflared, guardianEntry, startTunnel, tunnelAccess, } from "./tunnel.js";
|
|
33
36
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
34
|
-
const COMMANDS = ["serve", "pair", "sessions", "status", "login", "logout", "browser", "help", "--help", "-h"];
|
|
37
|
+
const COMMANDS = ["setup", "start", "serve", "pair", "sessions", "status", "login", "logout", "browser", "help", "--help", "-h"];
|
|
35
38
|
export function parseArgs(argv, env = process.env) {
|
|
36
|
-
const
|
|
39
|
+
const implicitStart = !argv.length || (argv[0].startsWith("--") && argv[0] !== "--help");
|
|
40
|
+
const [command = "start", ...rest] = implicitStart ? ["start", ...argv] : argv;
|
|
37
41
|
if (!COMMANDS.includes(command)) {
|
|
38
42
|
return { error: `unknown command "${command}"` };
|
|
39
43
|
}
|
|
@@ -74,6 +78,10 @@ export function parseArgs(argv, env = process.env) {
|
|
|
74
78
|
options.client = true;
|
|
75
79
|
else if (arg === "--no-pair")
|
|
76
80
|
options.pair = false;
|
|
81
|
+
else if (arg === "--no-open")
|
|
82
|
+
options.open = false;
|
|
83
|
+
else if (arg === "--local")
|
|
84
|
+
options.local = true;
|
|
77
85
|
else if (arg === "--json")
|
|
78
86
|
options.json = true;
|
|
79
87
|
else if (arg === "--email")
|
|
@@ -97,12 +105,17 @@ export function parseArgs(argv, env = process.env) {
|
|
|
97
105
|
return { error: "--public-url must start with http:// or https://" };
|
|
98
106
|
if (options.tailscale && options.tunnel)
|
|
99
107
|
return { error: "choose one of --tailscale (your tailnet) and --tunnel (a public address)" };
|
|
108
|
+
if (options.local && (options.tailscale || options.tunnel || options.publicUrl))
|
|
109
|
+
return { error: "--local cannot be combined with a remote-access option" };
|
|
100
110
|
if (options.command === "browser" && !options.browserAction)
|
|
101
111
|
return { error: "browser needs an action: install or status" };
|
|
102
112
|
return options;
|
|
103
113
|
}
|
|
104
|
-
export const USAGE = `openmausbot —
|
|
114
|
+
export const USAGE = `openmausbot — your team of AI bots, ready in a few steps
|
|
105
115
|
|
|
116
|
+
openmausbot set up once, then open your workspace
|
|
117
|
+
openmausbot setup [--data-dir DIR]
|
|
118
|
+
openmausbot start [the same options as serve]
|
|
106
119
|
openmausbot serve [--port 8799] [--data-dir DIR] [--label NAME]
|
|
107
120
|
[--public-url https://host] [--tailscale | --tunnel] [--no-pair]
|
|
108
121
|
openmausbot pair [--label NAME] [--client] [--public-url https://host]
|
|
@@ -112,17 +125,21 @@ export const USAGE = `openmausbot — run the server anywhere, pair devices to i
|
|
|
112
125
|
openmausbot logout
|
|
113
126
|
openmausbot browser install [--with-deps] | status
|
|
114
127
|
|
|
115
|
-
|
|
128
|
+
setup choose AI access and optional phone access; keep existing bots and chats
|
|
129
|
+
start same as openmausbot: use your saved settings and open the workspace
|
|
130
|
+
serve starts the server without prompts and prints a pairing link + QR code
|
|
116
131
|
pair mints a pairing code against a running server (--client: chat only)
|
|
117
132
|
sessions lists paired devices; "sessions revoke ID" signs one out
|
|
118
133
|
status what the server says about itself
|
|
119
134
|
login signs this machine in to an OpenMausBot account (an emailed code)
|
|
120
135
|
and reserves its public address for --tunnel
|
|
121
136
|
logout releases that address and signs out
|
|
122
|
-
browser install: the bots' browser engine (agent-browser, pinned)
|
|
123
|
-
Chrome for Testing
|
|
124
|
-
|
|
125
|
-
|
|
137
|
+
browser install: the bots' browser engine (agent-browser, pinned) into the
|
|
138
|
+
data dir, and Chrome for Testing into the user's browser cache.
|
|
139
|
+
--with-deps also installs
|
|
140
|
+
the Linux libraries Chrome needs (run as root once). Then run
|
|
141
|
+
browser install as the user running serve, from that user's home.
|
|
142
|
+
status: what the current user and data directory have.
|
|
126
143
|
|
|
127
144
|
--tailscale serve over your tailnet: Tailscale terminates HTTPS and the
|
|
128
145
|
link uses this machine's MagicDNS name (needs Tailscale signed in
|
|
@@ -130,6 +147,13 @@ browser install: the bots' browser engine (agent-browser, pinned) and a
|
|
|
130
147
|
--tunnel serve at a public https://….openmausbot.com address through a
|
|
131
148
|
Cloudflare tunnel: no domain, no proxy, no open port. Run
|
|
132
149
|
\`openmausbot login\` once on this machine first.
|
|
150
|
+
|
|
151
|
+
--no-open do not open a browser window
|
|
152
|
+
--no-pair skip phone setup and do not print a pairing code
|
|
153
|
+
--local start locally this time, ignoring saved remote-access settings
|
|
154
|
+
|
|
155
|
+
Install once with \`npm install -g openmausbot\`, then type \`openmausbot\`.
|
|
156
|
+
Or run without a global install: \`npx openmausbot\`. Node 24+ is required.
|
|
133
157
|
`;
|
|
134
158
|
export function defaultIo() {
|
|
135
159
|
return {
|
|
@@ -161,19 +185,105 @@ export function serverVersion(here = HERE) {
|
|
|
161
185
|
const message = (error) => (error instanceof Error ? error.message : String(error));
|
|
162
186
|
// ── talking to a running server (loopback = owner) ────────────────────
|
|
163
187
|
async function api(port, path, init = {}) {
|
|
164
|
-
const res = await fetch(`http://127.0.0.1:${port}${path}`, { method: init.method, body: init.body, headers: { "content-type": "application/json" } });
|
|
188
|
+
const res = await fetch(`http://127.0.0.1:${port}${path}`, { method: init.method, body: init.body, headers: { "content-type": "application/json" }, signal: AbortSignal.timeout(3000) });
|
|
165
189
|
const body = await res.json().catch(() => ({}));
|
|
166
190
|
return { status: res.status, body };
|
|
167
191
|
}
|
|
168
|
-
async function serverUp(port) {
|
|
192
|
+
async function serverUp(port, pid) {
|
|
169
193
|
try {
|
|
170
|
-
const { status } = await api(port, "/api/health");
|
|
171
|
-
return status === 200;
|
|
194
|
+
const { status, body } = await api(port, "/api/health");
|
|
195
|
+
return status === 200 && body?.app === "openmausbot" && (pid === undefined || body.pid === pid);
|
|
172
196
|
}
|
|
173
197
|
catch {
|
|
174
198
|
return false;
|
|
175
199
|
}
|
|
176
200
|
}
|
|
201
|
+
/** Check identity before reusing a running process. Never attach to another
|
|
202
|
+
* workspace just because it happens to be listening on the requested port. */
|
|
203
|
+
export async function isWorkspaceRunning(options) {
|
|
204
|
+
try {
|
|
205
|
+
const { status, body } = await api(options.port, "/api/health");
|
|
206
|
+
if (status !== 200 || body?.app !== "openmausbot")
|
|
207
|
+
return false;
|
|
208
|
+
const expected = readFileSync(join(options.dataDir, "environment-id"), "utf8").trim();
|
|
209
|
+
const descriptor = await api(options.port, "/.well-known/openmausbot/environment");
|
|
210
|
+
return /^[0-9a-f-]{36}$/i.test(expected) && descriptor.status === 200 && descriptor.body?.environmentId === expected;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** No shell commands, credentials or remote URLs go to the OS URL opener. */
|
|
217
|
+
export async function openDashboard(port, env = process.env) {
|
|
218
|
+
if (env.SSH_CONNECTION || env.SSH_TTY || (process.platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY))
|
|
219
|
+
return false;
|
|
220
|
+
const url = `http://127.0.0.1:${port}`;
|
|
221
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32.exe" : "xdg-open";
|
|
222
|
+
const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
223
|
+
return new Promise((done) => {
|
|
224
|
+
const child = spawn(command, args, { stdio: "ignore", windowsHide: true });
|
|
225
|
+
const timer = setTimeout(() => { child.kill(); done(false); }, 3000);
|
|
226
|
+
child.once("error", () => { clearTimeout(timer); done(false); });
|
|
227
|
+
child.once("exit", (code) => { clearTimeout(timer); done(code === 0); });
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/** A valid URL alone is not enough: its public descriptor must identify this
|
|
231
|
+
* exact server. This probe never sends a pairing code or an auth credential. */
|
|
232
|
+
export async function verifyPhoneEndpoint(port, origin) {
|
|
233
|
+
if (!normalizePhoneOrigin(origin))
|
|
234
|
+
return false;
|
|
235
|
+
try {
|
|
236
|
+
const local = await api(port, "/.well-known/openmausbot/environment");
|
|
237
|
+
const remote = await fetch(`${origin}/.well-known/openmausbot/environment`, { signal: AbortSignal.timeout(5000), redirect: "error" });
|
|
238
|
+
if (local.status !== 200 || !remote.ok)
|
|
239
|
+
return false;
|
|
240
|
+
const descriptor = await remote.json();
|
|
241
|
+
return typeof local.body?.environmentId === "string" && local.body.environmentId.length > 0
|
|
242
|
+
&& descriptor.environmentId === local.body.environmentId;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
export function applyStartupPreferences(options, saved) {
|
|
249
|
+
if (options.local)
|
|
250
|
+
return { ...options, tunnel: false, tailscale: false, publicUrl: undefined, phone: undefined };
|
|
251
|
+
if (!saved || options.tunnel || options.tailscale || options.publicUrl)
|
|
252
|
+
return options;
|
|
253
|
+
if (saved.access === "public-url" && (!saved.publicUrl || !normalizePhoneOrigin(saved.publicUrl))) {
|
|
254
|
+
throw new Error("The saved phone address is not a valid HTTPS origin. Run openmausbot setup to correct it, or openmausbot --local to start only on this computer.");
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
...options,
|
|
258
|
+
tunnel: saved.access === "tunnel", tailscale: saved.access === "tailscale",
|
|
259
|
+
publicUrl: saved.access === "public-url" ? saved.publicUrl : undefined,
|
|
260
|
+
phone: saved.access === "local" ? undefined : saved.phone,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function startupPreferences(options) {
|
|
264
|
+
const access = options.local ? "local" : options.tunnel ? "tunnel" : options.tailscale ? "tailscale" : options.publicUrl ? "public-url" : "local";
|
|
265
|
+
const publicUrl = access === "public-url" ? normalizePhoneOrigin(options.publicUrl) : null;
|
|
266
|
+
if (access === "public-url" && !publicUrl)
|
|
267
|
+
throw new Error("Use an HTTPS origin without a password, path or query for saved phone access. The address was not saved.");
|
|
268
|
+
return {
|
|
269
|
+
access,
|
|
270
|
+
...(publicUrl ? { publicUrl } : {}),
|
|
271
|
+
...(!options.local && options.phone ? { phone: options.phone } : {}),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
async function showPhonePairing(options, origin, log) {
|
|
275
|
+
const ready = !!origin && await verifyPhoneEndpoint(options.port, origin);
|
|
276
|
+
if (!ready) {
|
|
277
|
+
log("Phone access is not reachable yet. Your local workspace is ready; no phone pairing code was created.");
|
|
278
|
+
log("Check the HTTPS connection, then run openmausbot pair again with the same --data-dir and --port.");
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
for (const line of phonePairingInstructions(options.phone ?? "ios", { origin: origin, ready }))
|
|
282
|
+
log(line);
|
|
283
|
+
log(await mintPairing(options.port, { client: true, label: options.label ?? (options.phone === "android" ? "Android" : "iPhone / iPad"), publicUrl: origin }));
|
|
284
|
+
log("Waiting for you to connect on the phone. Keep this terminal and the code private.");
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
177
287
|
/** The pairing link a device opens, rendered as text and a QR code. */
|
|
178
288
|
export function pairingBlock(input) {
|
|
179
289
|
const lines = [`pairing code: ${input.code}`, `expires: ${new Date(input.expiresAt).toLocaleTimeString()} (single use)`];
|
|
@@ -214,6 +324,44 @@ export async function runPair(options) {
|
|
|
214
324
|
console.error(`no OpenMausBot server on http://127.0.0.1:${options.port}; start one with \`openmausbot serve\` or set OMB_PORT`);
|
|
215
325
|
return 1;
|
|
216
326
|
}
|
|
327
|
+
if (process.stdin.isTTY && process.stdout.isTTY && !options.label && !options.client) {
|
|
328
|
+
const advertised = await api(options.port, "/api/auth/pairing");
|
|
329
|
+
let launch = options;
|
|
330
|
+
// The running server may use a one-time route override. Saved preferences
|
|
331
|
+
// describe the next launch, not necessarily the address working now.
|
|
332
|
+
let origin = options.publicUrl ?? (typeof advertised.body?.publicUrl === "string" ? advertised.body.publicUrl : undefined);
|
|
333
|
+
if (!origin) {
|
|
334
|
+
const { readCliStartup } = await import("./cli-setup.js");
|
|
335
|
+
launch = applyStartupPreferences(options, readCliStartup(options.dataDir));
|
|
336
|
+
origin = launch.publicUrl;
|
|
337
|
+
if (launch.tunnel)
|
|
338
|
+
origin = describeTunnelAccount(createTunnelAccount({ dataDir: options.dataDir, version: serverVersion() }).credentials.read()).address ?? undefined;
|
|
339
|
+
if (launch.tailscale) {
|
|
340
|
+
const status = await tailscaleStatus();
|
|
341
|
+
if (!("failure" in status) && status.status.dnsName)
|
|
342
|
+
origin = `https://${status.status.dnsName}`;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (!origin || !normalizePhoneOrigin(origin)) {
|
|
346
|
+
console.log("Your workspace is running only on this computer. A phone cannot use its localhost address.");
|
|
347
|
+
console.log("Stop the server, run openmausbot setup and choose phone access, then start openmausbot again.");
|
|
348
|
+
return 1;
|
|
349
|
+
}
|
|
350
|
+
const ui = defaultSetupIo();
|
|
351
|
+
try {
|
|
352
|
+
const selected = await ui.choose("Which phone are you connecting?", ["iPhone / iPad — app or Safari", "Android — web browser", "Cancel"], 0);
|
|
353
|
+
if (selected === 2)
|
|
354
|
+
return 0;
|
|
355
|
+
launch = { ...launch, phone: selected === 0 ? "ios" : "android" };
|
|
356
|
+
return await showPhonePairing(launch, origin, ui.log) ? 0 : 1;
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
if (!(error instanceof SetupCancelled))
|
|
360
|
+
throw error;
|
|
361
|
+
console.log("Pairing cancelled. Existing devices are unchanged.");
|
|
362
|
+
return 130;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
217
365
|
console.log(await mintPairing(options.port, { label: options.label, client: options.client, publicUrl: options.publicUrl }));
|
|
218
366
|
if (options.client)
|
|
219
367
|
console.log("(client scope: chat and approvals only; cannot change settings or pair others)");
|
|
@@ -343,6 +491,7 @@ export async function runLogout(options, io = defaultIo()) {
|
|
|
343
491
|
return 0;
|
|
344
492
|
}
|
|
345
493
|
export async function runBrowser(options, io = defaultIo()) {
|
|
494
|
+
const { browserEngineStatus, describeBrowserEngine, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary } = await import("./browser-engine.js");
|
|
346
495
|
const status = browserEngineStatus({ dataDir: options.dataDir });
|
|
347
496
|
if (options.browserAction === "status") {
|
|
348
497
|
io.log(describeBrowserEngine(status));
|
|
@@ -374,10 +523,12 @@ export async function runBrowser(options, io = defaultIo()) {
|
|
|
374
523
|
catch (error) {
|
|
375
524
|
io.error(`Chrome is not ready: ${message(error)}`);
|
|
376
525
|
if (process.platform === "linux" && !options.withDeps)
|
|
377
|
-
io.error("on Linux, Chrome
|
|
526
|
+
io.error("on Linux, install Chrome's system libraries with `sudo openmausbot browser install --with-deps`, then retry `openmausbot browser install` as the user running serve");
|
|
378
527
|
return 1;
|
|
379
528
|
}
|
|
380
|
-
io.log("
|
|
529
|
+
io.log("browser installed for this user and data directory; run serve as the same user, then enable it under Settings → Experimental and per bot");
|
|
530
|
+
if (process.platform === "linux" && options.withDeps)
|
|
531
|
+
io.log("if serve runs as another user, run `openmausbot browser install` from that user's login shell too");
|
|
381
532
|
return 0;
|
|
382
533
|
}
|
|
383
534
|
/** Where the server bundle lives relative to this file: next to it in the
|
|
@@ -429,6 +580,7 @@ async function planTunnel(options, log) {
|
|
|
429
580
|
return { access, binary, guardian, origin: createTunnelOrigin() };
|
|
430
581
|
}
|
|
431
582
|
export async function runServe(options, log = console.log) {
|
|
583
|
+
const { browserEngineStatus, describeBrowserEngine } = await import("./browser-engine.js");
|
|
432
584
|
if (await serverUp(options.port)) {
|
|
433
585
|
console.error(`something already answers on http://127.0.0.1:${options.port}; use \`openmausbot pair\` against it, or --port for a second server`);
|
|
434
586
|
return 1;
|
|
@@ -464,6 +616,8 @@ export async function runServe(options, log = console.log) {
|
|
|
464
616
|
OMB_PORT: String(options.port),
|
|
465
617
|
OMB_WEBHOOK_PORT: process.env.OMB_WEBHOOK_PORT || String(options.port + 1),
|
|
466
618
|
};
|
|
619
|
+
if (options.local)
|
|
620
|
+
delete env.OMB_PUBLIC_URL;
|
|
467
621
|
if (entry.staticDir)
|
|
468
622
|
env.OMB_STATIC_DIR = entry.staticDir;
|
|
469
623
|
if (entry.skillsDir && !process.env.OMB_SKILLS_DIR)
|
|
@@ -472,21 +626,63 @@ export async function runServe(options, log = console.log) {
|
|
|
472
626
|
env.OMB_ENVIRONMENT_LABEL = options.label;
|
|
473
627
|
if (plan)
|
|
474
628
|
env.OMB_TUNNEL_SOCKET = plan.origin.socketPath;
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
629
|
+
let logPath;
|
|
630
|
+
let logFd;
|
|
631
|
+
let tailscaleServing = false;
|
|
632
|
+
let tailscaleAttempted = false;
|
|
633
|
+
let startupCancelled = false;
|
|
634
|
+
const cancelStartup = () => { startupCancelled = true; };
|
|
635
|
+
process.on("SIGINT", cancelStartup);
|
|
636
|
+
process.on("SIGTERM", cancelStartup);
|
|
637
|
+
let child;
|
|
638
|
+
try {
|
|
639
|
+
if (options.guided) {
|
|
640
|
+
const logsDir = join(options.dataDir, "logs");
|
|
641
|
+
mkdirSync(logsDir, { recursive: true, mode: 0o700 });
|
|
642
|
+
logPath = join(logsDir, `server-${Date.now()}-${process.pid}.log`);
|
|
643
|
+
logFd = openSync(logPath, "wx", 0o600);
|
|
644
|
+
log("\nStarting your workspace…");
|
|
480
645
|
}
|
|
481
|
-
|
|
482
|
-
|
|
646
|
+
if (tailscale) {
|
|
647
|
+
tailscaleAttempted = true;
|
|
648
|
+
const served = await tailscaleServe(tailscale, options.port);
|
|
649
|
+
// The CLI can finish enabling background serving while cancellation is
|
|
650
|
+
// arriving. Wait for that bounded command, then undo it before exiting.
|
|
651
|
+
if (startupCancelled)
|
|
652
|
+
throw new SetupCancelled();
|
|
653
|
+
if ("failure" in served)
|
|
654
|
+
throw new Error(`--tailscale: ${explainTailscaleFailure(served.failure)}`);
|
|
655
|
+
tailscaleServing = true;
|
|
656
|
+
publicUrl = served.origin;
|
|
657
|
+
log(`tailscale: serving https://${tailscale.dnsName} → http://127.0.0.1:${options.port} (only your tailnet can reach it)`);
|
|
658
|
+
}
|
|
659
|
+
if (startupCancelled)
|
|
660
|
+
throw new SetupCancelled();
|
|
661
|
+
if (publicUrl)
|
|
662
|
+
env.OMB_PUBLIC_URL = publicUrl;
|
|
663
|
+
child = spawn(entry.command, entry.args, { env, stdio: ["ignore", logFd ?? "inherit", logFd ?? "inherit"] });
|
|
664
|
+
}
|
|
665
|
+
catch (error) {
|
|
666
|
+
if ((tailscaleServing || (startupCancelled && tailscaleAttempted)) && tailscale)
|
|
667
|
+
await tailscaleServeOff(tailscale).catch(() => undefined);
|
|
668
|
+
if (plan)
|
|
669
|
+
cleanupTunnelOrigin(plan.origin);
|
|
670
|
+
if (error instanceof SetupCancelled) {
|
|
671
|
+
log("Startup cancelled. No server was started; your saved work is unchanged.");
|
|
672
|
+
return 130;
|
|
673
|
+
}
|
|
674
|
+
throw error;
|
|
675
|
+
}
|
|
676
|
+
finally {
|
|
677
|
+
if (logFd !== undefined)
|
|
678
|
+
closeSync(logFd);
|
|
679
|
+
process.removeListener("SIGINT", cancelStartup);
|
|
680
|
+
process.removeListener("SIGTERM", cancelStartup);
|
|
483
681
|
}
|
|
484
|
-
if (publicUrl)
|
|
485
|
-
env.OMB_PUBLIC_URL = publicUrl;
|
|
486
|
-
const child = spawn(entry.command, entry.args, { env, stdio: ["ignore", "inherit", "inherit"] });
|
|
487
682
|
let exited = null;
|
|
488
|
-
|
|
489
|
-
exited =
|
|
683
|
+
const childExit = new Promise((done) => {
|
|
684
|
+
child.once("error", () => { exited = 1; done(1); });
|
|
685
|
+
child.once("exit", (code, signal) => { exited = code ?? (signal === "SIGTERM" || signal === "SIGINT" ? 0 : 1); done(exited); });
|
|
490
686
|
});
|
|
491
687
|
let tunnel = null;
|
|
492
688
|
let stopping = null;
|
|
@@ -495,60 +691,165 @@ export async function runServe(options, log = console.log) {
|
|
|
495
691
|
// The gateway stops accepting before the server it forwards to goes away.
|
|
496
692
|
if (tunnel)
|
|
497
693
|
await tunnel.stop().catch(() => undefined);
|
|
498
|
-
if (tailscale)
|
|
694
|
+
if (tailscaleServing && tailscale)
|
|
499
695
|
await tailscaleServeOff(tailscale).catch(() => undefined);
|
|
500
|
-
if (exited === null)
|
|
696
|
+
if (exited === null) {
|
|
501
697
|
child.kill("SIGTERM");
|
|
698
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), 10_000);
|
|
699
|
+
timer.unref();
|
|
700
|
+
await childExit;
|
|
701
|
+
clearTimeout(timer);
|
|
702
|
+
}
|
|
502
703
|
if (plan)
|
|
503
704
|
cleanupTunnelOrigin(plan.origin);
|
|
504
705
|
})();
|
|
505
706
|
return stopping;
|
|
506
707
|
};
|
|
507
|
-
|
|
508
|
-
process.on("
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
708
|
+
const onSignal = () => { void stop(); };
|
|
709
|
+
process.on("SIGINT", onSignal);
|
|
710
|
+
process.on("SIGTERM", onSignal);
|
|
711
|
+
try {
|
|
712
|
+
const deadline = Date.now() + 60_000;
|
|
713
|
+
while (Date.now() < deadline && exited === null && !stopping) {
|
|
714
|
+
if (await serverUp(options.port, child.pid))
|
|
715
|
+
break;
|
|
716
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
717
|
+
}
|
|
718
|
+
if (exited !== null) {
|
|
719
|
+
if (exited !== 0)
|
|
720
|
+
log(`OpenMausBot could not start.${logPath ? ` Details: ${logPath}` : " See the output above."}`);
|
|
721
|
+
return exited;
|
|
722
|
+
}
|
|
723
|
+
if (stopping)
|
|
724
|
+
return await childExit;
|
|
725
|
+
if (!(await serverUp(options.port, child.pid))) {
|
|
726
|
+
console.error(`OpenMausBot did not become ready within a minute.${logPath ? ` Details: ${logPath}` : " See its output above."}`);
|
|
727
|
+
await stop();
|
|
728
|
+
return 1;
|
|
729
|
+
}
|
|
730
|
+
if (stopping || exited !== null)
|
|
731
|
+
return await childExit;
|
|
732
|
+
if (plan && child.pid) {
|
|
733
|
+
tunnel = startTunnel({
|
|
734
|
+
dataDir: options.dataDir,
|
|
735
|
+
access: plan.access,
|
|
736
|
+
originTarget: { pid: child.pid, socketPath: plan.origin.socketPath },
|
|
737
|
+
binaryPath: plan.binary,
|
|
738
|
+
guardian: plan.guardian,
|
|
739
|
+
onState: (state) => log(describeTunnelState(state, plan.access.endpoint)),
|
|
740
|
+
});
|
|
741
|
+
tunnel.started.catch((error) => log(`tunnel: ${message(error)}`));
|
|
742
|
+
}
|
|
743
|
+
log("");
|
|
744
|
+
log(`OpenMausBot is running on http://127.0.0.1:${options.port}${publicUrl ? `, reachable at ${publicUrl}` : ""}`);
|
|
745
|
+
if (options.guided) {
|
|
746
|
+
log("Your bots and conversations are saved automatically.");
|
|
747
|
+
log(`Details if you need help: ${logPath}`);
|
|
748
|
+
if (options.open !== false && !await openDashboard(options.port))
|
|
749
|
+
log("Open the local address above in a browser on this computer.");
|
|
750
|
+
}
|
|
751
|
+
else {
|
|
752
|
+
log(`data: ${options.dataDir}`);
|
|
753
|
+
log(describeBrowserEngine(browserEngineStatus({ dataDir: options.dataDir })));
|
|
754
|
+
}
|
|
755
|
+
if (options.pair && options.phone) {
|
|
756
|
+
log("");
|
|
757
|
+
if (tunnel) {
|
|
758
|
+
// A connector may take a moment to become reachable; no pairing secret
|
|
759
|
+
// is created or sent to the public address until identity is verified.
|
|
760
|
+
log("Preparing the phone connection…");
|
|
761
|
+
let timer;
|
|
762
|
+
await Promise.race([tunnel.started.catch(() => undefined), childExit,
|
|
763
|
+
new Promise((done) => { timer = setTimeout(done, 15_000); })]);
|
|
764
|
+
if (timer)
|
|
765
|
+
clearTimeout(timer);
|
|
766
|
+
}
|
|
767
|
+
if (!stopping && exited === null)
|
|
768
|
+
await showPhonePairing(options, publicUrl, log);
|
|
769
|
+
}
|
|
770
|
+
else if (options.pair && !options.guided) {
|
|
771
|
+
log("");
|
|
772
|
+
log(await mintPairing(options.port, { label: options.label ? `${options.label} owner` : undefined, client: options.client, publicUrl: publicUrl ?? undefined }));
|
|
773
|
+
log("");
|
|
774
|
+
log("another device later: openmausbot pair --label \"Kitchen iPad\"");
|
|
775
|
+
}
|
|
776
|
+
log(options.guided ? "\nKeep this terminal open while using your bots. Ctrl+C stops the server, not your saved work." : "stop with Ctrl+C");
|
|
777
|
+
if (options.guided)
|
|
778
|
+
log("Next time: openmausbot · Change AI or phone setup: openmausbot setup · Pair another phone: openmausbot pair");
|
|
779
|
+
return await childExit;
|
|
519
780
|
}
|
|
520
|
-
|
|
521
|
-
console.error("the server did not answer within a minute; see its output above");
|
|
781
|
+
finally {
|
|
522
782
|
await stop();
|
|
783
|
+
process.removeListener("SIGINT", onSignal);
|
|
784
|
+
process.removeListener("SIGTERM", onSignal);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/** Keep setup imports behind the data-dir override: config binds its paths
|
|
788
|
+
* when first imported. `serve` remains usable with stdin closed. */
|
|
789
|
+
export async function runOnboardingCommand(options, io = defaultIo(), startServer = runServe, flow = {}) {
|
|
790
|
+
const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
791
|
+
if (options.command === "setup" && !interactive) {
|
|
792
|
+
io.error("Setup needs an interactive terminal. Run `npx openmausbot setup` in a terminal, then use `npx openmausbot serve` for unattended starts.");
|
|
523
793
|
return 1;
|
|
524
794
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
access
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
795
|
+
process.env.OMB_DATA_DIR = options.dataDir;
|
|
796
|
+
if (options.command !== "setup" && await (flow.running ?? isWorkspaceRunning)(options)) {
|
|
797
|
+
if (options.local || options.tunnel || options.tailscale || options.publicUrl) {
|
|
798
|
+
io.error("This workspace is already running. Stop it before changing local or remote access; the current connection was not changed.");
|
|
799
|
+
return 1;
|
|
800
|
+
}
|
|
801
|
+
io.log(`Your workspace is already running: http://127.0.0.1:${options.port}`);
|
|
802
|
+
io.log("No second server was started. Your existing bots and conversations are unchanged.");
|
|
803
|
+
if (interactive && options.open !== false)
|
|
804
|
+
await (flow.open ?? openDashboard)(options.port);
|
|
805
|
+
return 0;
|
|
806
|
+
}
|
|
807
|
+
const { runSetup, isSetupComplete, readCliStartup, saveCliStartup } = await import("./cli-setup.js");
|
|
808
|
+
const prompts = flow.prompts ?? defaultSetupIo();
|
|
809
|
+
try {
|
|
810
|
+
if (options.command === "setup" || !(await isSetupComplete(options.dataDir))) {
|
|
811
|
+
if (!interactive) {
|
|
812
|
+
io.error("No completed setup was found. Run `npx openmausbot setup` in an interactive terminal first, or use `npx openmausbot serve` with an existing configuration.");
|
|
813
|
+
return 1;
|
|
814
|
+
}
|
|
815
|
+
if (!(await runSetup({ dataDir: options.dataDir, port: options.port }))) {
|
|
816
|
+
io.log("Setup cancelled. Run openmausbot when you're ready.");
|
|
817
|
+
return 130;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
const saved = readCliStartup(options.dataDir);
|
|
821
|
+
// An explicit setup revisits the access choice. Normal starts reuse consent
|
|
822
|
+
// instead of asking again or unexpectedly turning a local session public.
|
|
823
|
+
let launch = options.command === "setup" ? options : applyStartupPreferences(options, saved);
|
|
824
|
+
if (interactive && options.pair && !options.local && (options.command === "setup" || !saved)) {
|
|
825
|
+
io.log("\nOne optional step: connect your phone. You can skip this and start chatting here.");
|
|
826
|
+
const result = await (flow.phoneSetup ?? runPhoneSetup)(launch, prompts, {
|
|
827
|
+
accountReady: (value) => !!describeTunnelAccount(createTunnelAccount({ dataDir: value.dataDir, version: serverVersion() }).credentials.read()).email,
|
|
828
|
+
login: (value, ui) => runLogin(value, {
|
|
829
|
+
log: ui.log, error: ui.log,
|
|
830
|
+
ask: (question) => /code/i.test(question) ? ui.secret(question) : ui.ask(question),
|
|
831
|
+
}),
|
|
832
|
+
});
|
|
833
|
+
launch = { ...result.options, phone: result.phone };
|
|
834
|
+
saveCliStartup(options.dataDir, startupPreferences(launch));
|
|
835
|
+
}
|
|
836
|
+
if (options.command === "setup") {
|
|
837
|
+
io.log("\nAll set. Start with: openmausbot (or npx openmausbot without a global install).");
|
|
838
|
+
if (options.dataDir !== join(homedir(), ".openmausbot") || options.port !== 8799) {
|
|
839
|
+
io.log(`Use the same --data-dir (${options.dataDir}) and --port (${options.port}) options when starting.`);
|
|
840
|
+
}
|
|
841
|
+
return 0;
|
|
842
|
+
}
|
|
843
|
+
if (saved)
|
|
844
|
+
io.log("\nWelcome back. Using your saved AI connection.");
|
|
845
|
+
return startServer({ ...launch, guided: interactive });
|
|
846
|
+
}
|
|
847
|
+
catch (error) {
|
|
848
|
+
if (!(error instanceof SetupCancelled))
|
|
849
|
+
throw error;
|
|
850
|
+
io.log("\nSetup stopped. Any AI setup already saved is kept; no server was started. Run openmausbot setup to continue.");
|
|
851
|
+
return 130;
|
|
545
852
|
}
|
|
546
|
-
log("stop with Ctrl+C");
|
|
547
|
-
return await new Promise((resolveExit) => {
|
|
548
|
-
child.on("exit", (code) => {
|
|
549
|
-
void stop().finally(() => resolveExit(code ?? 0));
|
|
550
|
-
});
|
|
551
|
-
});
|
|
552
853
|
}
|
|
553
854
|
export async function main(argv = process.argv.slice(2)) {
|
|
554
855
|
const options = parseArgs(argv);
|
|
@@ -556,7 +857,11 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
556
857
|
console.error(`${options.error}\n\n${USAGE}`);
|
|
557
858
|
return 2;
|
|
558
859
|
}
|
|
860
|
+
process.env.OMB_DATA_DIR = options.dataDir;
|
|
559
861
|
switch (options.command) {
|
|
862
|
+
case "setup":
|
|
863
|
+
case "start":
|
|
864
|
+
return runOnboardingCommand(options);
|
|
560
865
|
case "serve":
|
|
561
866
|
return runServe(options);
|
|
562
867
|
case "pair":
|
|
@@ -6,6 +6,7 @@ import { homedir } from "node:os";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { writeFileAtomic } from "./atomic.js";
|
|
9
|
+
import { EFFORT_LEVELS } from "./contracts.js";
|
|
9
10
|
import { parseStoredMcpServer } from "./mcp-registry.js";
|
|
10
11
|
import { parseJson, schemaIssue } from "./schema.js";
|
|
11
12
|
const optionalText = z.string().optional();
|
|
@@ -209,7 +210,19 @@ const instanceConfigSchema = z.object({
|
|
|
209
210
|
config: z.json().optional(),
|
|
210
211
|
});
|
|
211
212
|
const instanceConfigMapSchema = z.record(z.string(), instanceConfigSchema);
|
|
213
|
+
const defaultModelSelectionSchema = z.object({
|
|
214
|
+
instanceId: z.string().trim().min(1),
|
|
215
|
+
model: z.string().trim().min(1),
|
|
216
|
+
effort: z.enum(EFFORT_LEVELS).optional(),
|
|
217
|
+
});
|
|
212
218
|
const appConfigSchema = z.object({
|
|
219
|
+
defaultModelSelection: defaultModelSelectionSchema.optional(),
|
|
220
|
+
/** CLI-only launch preferences. Never enable remote access implicitly. */
|
|
221
|
+
cliStartup: z.object({
|
|
222
|
+
access: z.enum(["local", "tunnel", "tailscale", "public-url"]),
|
|
223
|
+
publicUrl: z.string().url().optional(),
|
|
224
|
+
phone: z.enum(["ios", "android"]).optional(),
|
|
225
|
+
}).optional(),
|
|
213
226
|
xai: z.object({ key: optionalText, url: optionalText }).optional(),
|
|
214
227
|
/** `model` seeds the default selection; `provider` pins an OpenRouter
|
|
215
228
|
* upstream (e.g. "fireworks"). Both are non-secret and optional. */
|
|
@@ -248,7 +261,7 @@ const appConfigSchema = z.object({
|
|
|
248
261
|
const storedAppConfigSchema = appConfigSchema.extend({
|
|
249
262
|
browserProfiles: storedBrowserProfilesSchema.optional(),
|
|
250
263
|
});
|
|
251
|
-
const appConfigPatchSchema = appConfigSchema.omit({ instances: true, mcpServers: true });
|
|
264
|
+
const appConfigPatchSchema = appConfigSchema.omit({ instances: true, mcpServers: true, cliStartup: true });
|
|
252
265
|
const jsonObjectSchema = z.record(z.string(), z.json());
|
|
253
266
|
/** Resolve a canonical profile record to its exact durable Electron
|
|
254
267
|
* partition identity. Callers must never substitute the display/API id. */
|
|
@@ -530,6 +543,13 @@ export function saveConfig(patch) {
|
|
|
530
543
|
// scalar, not a section: the merge loop above only walks objects
|
|
531
544
|
if (checkedPatch.language !== undefined)
|
|
532
545
|
disk.language = checkedPatch.language;
|
|
546
|
+
// A selection is replaced as one value, so changing engines also clears
|
|
547
|
+
// an effort level omitted from the new selection.
|
|
548
|
+
if (checkedPatch.defaultModelSelection !== undefined) {
|
|
549
|
+
disk.defaultModelSelection = checkedPatch.defaultModelSelection;
|
|
550
|
+
}
|
|
551
|
+
if (checkedPatch.cliStartup !== undefined)
|
|
552
|
+
disk.cliStartup = checkedPatch.cliStartup;
|
|
533
553
|
// Custom MCP mutations go through their own dedicated local API, but
|
|
534
554
|
// saveConfig remains the single atomic persistence boundary.
|
|
535
555
|
if (checkedPatch.mcpServers !== undefined) {
|
|
@@ -718,6 +738,10 @@ export function instanceConfigs(cfg) {
|
|
|
718
738
|
const merged = { ...current };
|
|
719
739
|
// A per-instance value always wins over the workspace default.
|
|
720
740
|
for (const [k, v] of Object.entries(defaults)) {
|
|
741
|
+
// Empty routing explicitly means "no upstream pin" for isolated
|
|
742
|
+
// API connections. Do not replace it with a workspace provider.
|
|
743
|
+
if (k === "provider" && typeof merged[k] === "string")
|
|
744
|
+
continue;
|
|
721
745
|
if (typeof merged[k] !== "string" || !merged[k].trim())
|
|
722
746
|
merged[k] = v;
|
|
723
747
|
}
|