openmausbot 0.1.66 → 0.1.68
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-CbaejjQA.js +278 -0
- package/dist/assets/{index-kW5L1QeO.js → index-HqooKFTS.js} +1 -1
- package/dist/assets/index-aJ5EWZOQ.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/computer-proxy.js +14 -3
- package/dist-server/container-mcp.js +44 -19
- package/dist-server/drivers/agents-proxy.js +59 -3
- package/dist-server/enterprise/server/index.js +14587 -0
- package/dist-server/index.js +2675 -1055
- 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 +1560 -745
- package/dist-server/pair-cli.js +1560 -745
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/account-signin.js +103 -0
- package/dist-server/server/brand.js +7 -0
- package/dist-server/server/caddy.js +203 -0
- package/dist-server/server/cli.js +185 -22
- package/dist-server/server/computer-proxy.js +11 -2
- package/dist-server/server/config.js +22 -1
- package/dist-server/server/control-client.js +7 -1
- package/dist-server/server/delegations.js +56 -11
- package/dist-server/server/drivers/agents-proxy.js +75 -5
- 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 +965 -363
- 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/routines.js +36 -9
- package/dist-server/server/service-cli.js +63 -0
- package/dist-server/server/service-unit.js +129 -0
- package/dist-server/server/sessions.js +42 -1
- package/dist-server/server/steer-queue.js +12 -9
- package/dist-server/server/store.js +260 -34
- package/dist-server/server/system-prompt.js +1 -0
- package/dist-server/server/team-backup.js +1 -0
- package/dist-server/server/tunnel.js +27 -1
- package/dist-server/server/turn-dispatch-guard.js +6 -0
- package/dist-server/server/turn-resources.js +50 -0
- package/dist-server/server/workspace.js +53 -3
- package/dist-server/vps-container-mcp.js +44 -19
- package/enterprise/LICENSE +32 -0
- package/enterprise/README.md +89 -0
- package/enterprise/server/index.js +14587 -0
- package/package.json +2 -1
- package/dist/assets/index-BftlaHAF.css +0 -1
- package/dist/assets/index-DuR3H-Hf.js +0 -278
|
@@ -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,16 @@ 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";
|
|
35
|
+
import { runServiceCommand } from "./service-cli.js";
|
|
32
36
|
import { explainTailscaleFailure, tailscaleServe, tailscaleServeOff, tailscaleStatus } from "./tailscale.js";
|
|
33
37
|
import { defaultSetupIo, SetupCancelled } from "./cli-prompts.js";
|
|
34
38
|
import { normalizePhoneOrigin, phonePairingInstructions, runPhoneSetup } from "./cli-phone-setup.js";
|
|
35
|
-
import { cleanupTunnelOrigin, createTunnelAccount, createTunnelOrigin, describeTunnelAccount, describeTunnelState, ensureCloudflared, guardianEntry, startTunnel, tunnelAccess, } from "./tunnel.js";
|
|
39
|
+
import { cleanupTunnelOrigin, createTunnelAccount, createTunnelOrigin, describeTunnelAccount, describeTunnelState, ensureCloudflared, FLEET_CREDENTIAL_ENV, fleetAccess, fleetCredential, guardianEntry, startTunnel, tunnelAccess, } from "./tunnel.js";
|
|
36
40
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
37
|
-
const COMMANDS = ["setup", "start", "serve", "pair", "sessions", "status", "login", "logout", "browser", "help", "--help", "-h"];
|
|
41
|
+
const COMMANDS = ["setup", "start", "serve", "pair", "sessions", "status", "login", "logout", "access", "service", "browser", "help", "--help", "-h"];
|
|
38
42
|
export function parseArgs(argv, env = process.env) {
|
|
39
43
|
const implicitStart = !argv.length || (argv[0].startsWith("--") && argv[0] !== "--help");
|
|
40
44
|
const [command = "start", ...rest] = implicitStart ? ["start", ...argv] : argv;
|
|
@@ -51,6 +55,7 @@ export function parseArgs(argv, env = process.env) {
|
|
|
51
55
|
pair: true,
|
|
52
56
|
withDeps: false,
|
|
53
57
|
json: false,
|
|
58
|
+
chatOnly: false,
|
|
54
59
|
};
|
|
55
60
|
for (let i = 0; i < rest.length; i += 1) {
|
|
56
61
|
const arg = rest[i];
|
|
@@ -72,6 +77,12 @@ export function parseArgs(argv, env = process.env) {
|
|
|
72
77
|
options.publicUrl = value().replace(/\/+$/, "");
|
|
73
78
|
else if (arg === "--tailscale")
|
|
74
79
|
options.tailscale = true;
|
|
80
|
+
else if (arg === "--domain") {
|
|
81
|
+
const domain = normalizeDomainOption(value());
|
|
82
|
+
if (typeof domain !== "string")
|
|
83
|
+
return domain;
|
|
84
|
+
options.domain = domain;
|
|
85
|
+
}
|
|
75
86
|
else if (arg === "--tunnel")
|
|
76
87
|
options.tunnel = true;
|
|
77
88
|
else if (arg === "--client")
|
|
@@ -88,6 +99,15 @@ export function parseArgs(argv, env = process.env) {
|
|
|
88
99
|
options.email = value();
|
|
89
100
|
else if (options.command === "sessions" && arg === "revoke")
|
|
90
101
|
options.revoke = value();
|
|
102
|
+
else if (options.command === "access" && !options.accessAction && (arg === "list" || arg === "add" || arg === "remove")) {
|
|
103
|
+
options.accessAction = arg;
|
|
104
|
+
if (arg !== "list")
|
|
105
|
+
options.email = value();
|
|
106
|
+
}
|
|
107
|
+
else if (options.command === "access" && arg === "--chat-only")
|
|
108
|
+
options.chatOnly = true;
|
|
109
|
+
else if (options.command === "service" && !options.serviceAction && (arg === "install" || arg === "uninstall"))
|
|
110
|
+
options.serviceAction = arg;
|
|
91
111
|
else if (options.command === "browser" && (arg === "install" || arg === "status"))
|
|
92
112
|
options.browserAction = arg;
|
|
93
113
|
else if (options.command === "browser" && arg === "--with-deps")
|
|
@@ -105,6 +125,12 @@ export function parseArgs(argv, env = process.env) {
|
|
|
105
125
|
return { error: "--public-url must start with http:// or https://" };
|
|
106
126
|
if (options.tailscale && options.tunnel)
|
|
107
127
|
return { error: "choose one of --tailscale (your tailnet) and --tunnel (a public address)" };
|
|
128
|
+
if (options.command === "access" && !options.accessAction)
|
|
129
|
+
return { error: "access needs one of: list, add EMAIL [--chat-only], remove EMAIL" };
|
|
130
|
+
if (options.command === "service" && !options.serviceAction)
|
|
131
|
+
return { error: "service needs one of: install [the same options as serve], uninstall" };
|
|
132
|
+
if (options.domain && (options.tailscale || options.tunnel || options.publicUrl))
|
|
133
|
+
return { error: "--domain already gives the server its address; drop --tailscale, --tunnel and --public-url" };
|
|
108
134
|
if (options.local && (options.tailscale || options.tunnel || options.publicUrl))
|
|
109
135
|
return { error: "--local cannot be combined with a remote-access option" };
|
|
110
136
|
if (options.command === "browser" && !options.browserAction)
|
|
@@ -117,12 +143,14 @@ export const USAGE = `openmausbot — your team of AI bots, ready in a few steps
|
|
|
117
143
|
openmausbot setup [--data-dir DIR]
|
|
118
144
|
openmausbot start [the same options as serve]
|
|
119
145
|
openmausbot serve [--port 8799] [--data-dir DIR] [--label NAME]
|
|
120
|
-
[--public-url https://host] [--tailscale | --tunnel] [--no-pair]
|
|
146
|
+
[--public-url https://host] [--tailscale | --tunnel | --domain HOST] [--no-pair]
|
|
121
147
|
openmausbot pair [--label NAME] [--client] [--public-url https://host]
|
|
122
148
|
openmausbot sessions [revoke ID]
|
|
123
149
|
openmausbot status
|
|
124
150
|
openmausbot login [--email you@example.com]
|
|
125
151
|
openmausbot logout
|
|
152
|
+
openmausbot access list | add EMAIL [--chat-only] | remove EMAIL
|
|
153
|
+
openmausbot service install [--domain HOST | --tunnel | --tailscale] [--port N] [--data-dir DIR] | uninstall
|
|
126
154
|
openmausbot browser install [--with-deps] | status
|
|
127
155
|
|
|
128
156
|
setup choose AI access and optional phone access; keep existing bots and chats
|
|
@@ -134,6 +162,13 @@ status what the server says about itself
|
|
|
134
162
|
login signs this machine in to an OpenMausBot account (an emailed code)
|
|
135
163
|
and reserves its public address for --tunnel
|
|
136
164
|
logout releases that address and signs out
|
|
165
|
+
access who may sign in with an emailed code at /pair: an address or
|
|
166
|
+
@domain; --chat-only gives chat and approvals without settings.
|
|
167
|
+
Takes effect at once, no restart.
|
|
168
|
+
service keep the server running across reboots: writes a systemd unit
|
|
169
|
+
(Linux) or a launchd agent (macOS) for the same serve options and
|
|
170
|
+
prints the commands that install it. Install the package
|
|
171
|
+
permanently first (npm install -g openmausbot).
|
|
137
172
|
browser install: the bots' browser engine (agent-browser, pinned) into the
|
|
138
173
|
data dir, and Chrome for Testing into the user's browser cache.
|
|
139
174
|
--with-deps also installs
|
|
@@ -147,6 +182,10 @@ browser install: the bots' browser engine (agent-browser, pinned) into the
|
|
|
147
182
|
--tunnel serve at a public https://….openmausbot.com address through a
|
|
148
183
|
Cloudflare tunnel: no domain, no proxy, no open port. Run
|
|
149
184
|
\`openmausbot login\` once on this machine first.
|
|
185
|
+
--domain serve at https://HOST on your own domain: a pinned Caddy is
|
|
186
|
+
downloaded once and run alongside the server, and gets the
|
|
187
|
+
certificate itself. Point the domain's DNS at this machine and
|
|
188
|
+
open ports 80 and 443.
|
|
150
189
|
|
|
151
190
|
--no-open do not open a browser window
|
|
152
191
|
--no-pair skip phone setup and do not print a pairing code
|
|
@@ -418,13 +457,80 @@ export async function runStatus(options, io = defaultIo()) {
|
|
|
418
457
|
}
|
|
419
458
|
if (!options.json) {
|
|
420
459
|
const account = describeTunnelAccount(createTunnelAccount({ dataDir: options.dataDir, version: serverVersion() }).credentials.read());
|
|
421
|
-
if (
|
|
460
|
+
if (fleetCredential())
|
|
461
|
+
io.log(`public address: managed by the fleet (${FLEET_CREDENTIAL_ENV} is set; the address is fetched when serve --tunnel starts)`);
|
|
462
|
+
else if (account.address)
|
|
422
463
|
io.log(`public address: ${account.address} (signed in as ${account.email ?? "?"}; serve it with --tunnel)`);
|
|
423
464
|
}
|
|
424
465
|
return code;
|
|
425
466
|
}
|
|
467
|
+
/** The sign-in allow-list, edited straight in config.json: the server reads
|
|
468
|
+
* it per request, so this works with the server running or stopped and
|
|
469
|
+
* needs no restart. Environment variables (OMB_SIGNIN_EMAILS) win when set.
|
|
470
|
+
* Written the way the server writes it (atomic, 0600), touching only the
|
|
471
|
+
* one key, so nothing else in the file moves. */
|
|
472
|
+
export async function runAccess(options, io = defaultIo()) {
|
|
473
|
+
const file = join(options.dataDir, "config.json");
|
|
474
|
+
let raw = {};
|
|
475
|
+
if (existsSync(file)) {
|
|
476
|
+
try {
|
|
477
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
478
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
479
|
+
throw new Error("not an object");
|
|
480
|
+
raw = Object.fromEntries(Object.entries(parsed));
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
io.error(`${file} could not be read (${message(error)}); fix it before changing who can sign in`);
|
|
484
|
+
return 1;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const current = typeof raw.signIn === "object" && raw.signIn !== null ? Object(raw.signIn) : {};
|
|
488
|
+
const list = (value) => parseAllowList(Array.isArray(value) ? value.map(String).join(",") : "");
|
|
489
|
+
const admins = list(Reflect.get(current, "admins"));
|
|
490
|
+
const members = list(Reflect.get(current, "members"));
|
|
491
|
+
const overridden = process.env.OMB_SIGNIN_EMAILS !== undefined || process.env.OMB_SIGNIN_MEMBER_EMAILS !== undefined;
|
|
492
|
+
const write = (next) => {
|
|
493
|
+
mkdirSync(options.dataDir, { recursive: true, mode: 0o700 });
|
|
494
|
+
writeFileAtomic(file, `${JSON.stringify({ ...raw, signIn: next }, null, 2)}\n`, { mode: 0o600 });
|
|
495
|
+
};
|
|
496
|
+
if (options.accessAction === "list") {
|
|
497
|
+
if (!admins.length && !members.length) {
|
|
498
|
+
io.log("nobody can sign in with an email yet; pairing codes only. Add someone with: openmausbot access add you@example.com");
|
|
499
|
+
return 0;
|
|
500
|
+
}
|
|
501
|
+
for (const entry of admins)
|
|
502
|
+
io.log(`${entry.padEnd(40)} full access`);
|
|
503
|
+
for (const entry of members)
|
|
504
|
+
io.log(`${entry.padEnd(40)} chat and approvals`);
|
|
505
|
+
if (overridden)
|
|
506
|
+
io.log("(OMB_SIGNIN_EMAILS / OMB_SIGNIN_MEMBER_EMAILS are set in the environment and win over this list while the server runs)");
|
|
507
|
+
return 0;
|
|
508
|
+
}
|
|
509
|
+
const entry = (options.email ?? "").trim().toLowerCase();
|
|
510
|
+
if (!entry || (!entry.startsWith("@") && !entry.includes("@")) || /\s/.test(entry)) {
|
|
511
|
+
io.error("give an email address, or @domain for everyone at that domain");
|
|
512
|
+
return 2;
|
|
513
|
+
}
|
|
514
|
+
const without = (items) => items.filter((item) => item !== entry);
|
|
515
|
+
if (options.accessAction === "remove") {
|
|
516
|
+
if (!admins.includes(entry) && !members.includes(entry)) {
|
|
517
|
+
io.error(`${entry} is not on the list`);
|
|
518
|
+
return 1;
|
|
519
|
+
}
|
|
520
|
+
write({ admins: without(admins), members: without(members) });
|
|
521
|
+
io.log(`${entry} can no longer sign in (existing sessions stay until they expire or are revoked with \`openmausbot sessions revoke\`)`);
|
|
522
|
+
return 0;
|
|
523
|
+
}
|
|
524
|
+
write(options.chatOnly ? { admins: without(admins), members: [...without(members), entry] } : { admins: [...without(admins), entry], members: without(members) });
|
|
525
|
+
io.log(`${entry} can sign in at /pair with an emailed code (${options.chatOnly ? "chat and approvals" : "full access"})`);
|
|
526
|
+
if (overridden)
|
|
527
|
+
io.log("note: OMB_SIGNIN_EMAILS / OMB_SIGNIN_MEMBER_EMAILS are set in the environment and win over this list while the server runs");
|
|
528
|
+
return 0;
|
|
529
|
+
}
|
|
426
530
|
export async function runLogin(options, io = defaultIo()) {
|
|
427
531
|
const account = createTunnelAccount({ dataDir: options.dataDir, version: serverVersion() });
|
|
532
|
+
if (fleetCredential())
|
|
533
|
+
io.log(`note: ${FLEET_CREDENTIAL_ENV} is set, so serve --tunnel will use that credential rather than this account`);
|
|
428
534
|
if (account.credentials.status === "unavailable") {
|
|
429
535
|
io.error(`${account.credentials.file} exists but could not be read; fix or remove it, then try again`);
|
|
430
536
|
return 1;
|
|
@@ -549,24 +655,38 @@ export function serverEntry(here = HERE) {
|
|
|
549
655
|
/** Everything `--tunnel` needs before the server starts, or the one reason
|
|
550
656
|
* it cannot have it. Fails closed: no silent fallback to a local-only server. */
|
|
551
657
|
async function planTunnel(options, log) {
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
658
|
+
let access = null;
|
|
659
|
+
const credential = fleetCredential();
|
|
660
|
+
if (credential) {
|
|
661
|
+
// A fleet-started container: the credential is the whole identity.
|
|
662
|
+
log(`tunnel: using the installation credential from ${FLEET_CREDENTIAL_ENV}`);
|
|
663
|
+
try {
|
|
664
|
+
access = await fleetAccess({ credential });
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
return { error: `--tunnel: ${message(error)}` };
|
|
668
|
+
}
|
|
563
669
|
}
|
|
564
|
-
|
|
565
|
-
|
|
670
|
+
else {
|
|
671
|
+
const account = createTunnelAccount({ dataDir: options.dataDir, version: serverVersion() });
|
|
672
|
+
if (account.credentials.status === "unavailable")
|
|
673
|
+
return { error: `${account.credentials.file} exists but could not be read; fix or remove it` };
|
|
674
|
+
if (!describeTunnelAccount(account.credentials.read()).email) {
|
|
675
|
+
return { error: "no account on this machine yet: run `openmausbot login` first, then `openmausbot serve --tunnel`" };
|
|
676
|
+
}
|
|
677
|
+
// A fresh connector token when the control plane answers; the saved one otherwise.
|
|
678
|
+
try {
|
|
679
|
+
const state = await account.service.retry();
|
|
680
|
+
if (state.message && !tunnelAccess(account.credentials.read()))
|
|
681
|
+
log(`tunnel: ${state.message}`);
|
|
682
|
+
}
|
|
683
|
+
catch (error) {
|
|
684
|
+
log(`tunnel: control plane not reachable right now (${message(error)}); using the saved address`);
|
|
685
|
+
}
|
|
686
|
+
access = tunnelAccess(account.credentials.read());
|
|
687
|
+
if (!access)
|
|
688
|
+
return { error: "this machine has no public address; run `openmausbot login` again" };
|
|
566
689
|
}
|
|
567
|
-
const access = tunnelAccess(account.credentials.read());
|
|
568
|
-
if (!access)
|
|
569
|
-
return { error: "this machine has no public address; run `openmausbot login` again" };
|
|
570
690
|
let binary;
|
|
571
691
|
try {
|
|
572
692
|
binary = await ensureCloudflared({ dataDir: options.dataDir, log });
|
|
@@ -607,6 +727,17 @@ export async function runServe(options, log = console.log) {
|
|
|
607
727
|
log(`note: --public-url is ignored with --tunnel; the address is ${plan.access.endpoint}`);
|
|
608
728
|
publicUrl = plan.access.endpoint;
|
|
609
729
|
}
|
|
730
|
+
let caddyBinary = null;
|
|
731
|
+
if (options.domain) {
|
|
732
|
+
try {
|
|
733
|
+
caddyBinary = await ensureCaddy({ dataDir: options.dataDir, log });
|
|
734
|
+
}
|
|
735
|
+
catch (error) {
|
|
736
|
+
console.error(`--domain: ${message(error)}`);
|
|
737
|
+
return 1;
|
|
738
|
+
}
|
|
739
|
+
publicUrl = `https://${options.domain}`;
|
|
740
|
+
}
|
|
610
741
|
const entry = serverEntry();
|
|
611
742
|
if (!entry.staticDir)
|
|
612
743
|
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 +816,15 @@ export async function runServe(options, log = console.log) {
|
|
|
685
816
|
child.once("exit", (code, signal) => { exited = code ?? (signal === "SIGTERM" || signal === "SIGINT" ? 0 : 1); done(exited); });
|
|
686
817
|
});
|
|
687
818
|
let tunnel = null;
|
|
819
|
+
let caddy = null;
|
|
688
820
|
let stopping = null;
|
|
689
821
|
const stop = () => {
|
|
690
822
|
stopping ??= (async () => {
|
|
691
|
-
// The gateway
|
|
823
|
+
// The gateway and the edge stop accepting before the server they forward to goes away.
|
|
692
824
|
if (tunnel)
|
|
693
825
|
await tunnel.stop().catch(() => undefined);
|
|
826
|
+
if (caddy)
|
|
827
|
+
await caddy.stop().catch(() => undefined);
|
|
694
828
|
if (tailscaleServing && tailscale)
|
|
695
829
|
await tailscaleServeOff(tailscale).catch(() => undefined);
|
|
696
830
|
if (exited === null) {
|
|
@@ -729,6 +863,21 @@ export async function runServe(options, log = console.log) {
|
|
|
729
863
|
}
|
|
730
864
|
if (stopping || exited !== null)
|
|
731
865
|
return await childExit;
|
|
866
|
+
if (options.domain && caddyBinary) {
|
|
867
|
+
try {
|
|
868
|
+
caddy = await startCaddy({ binary: caddyBinary, dataDir: options.dataDir, domain: options.domain, appPort: options.port, webhookPort: Number(env.OMB_WEBHOOK_PORT), log });
|
|
869
|
+
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`);
|
|
870
|
+
void caddy.exited.then((code) => {
|
|
871
|
+
if (!stopping)
|
|
872
|
+
log(`caddy: stopped (exit ${code ?? "signal"}); ${publicUrl} is no longer served. Stop and start the server again.`);
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
catch (error) {
|
|
876
|
+
console.error(`--domain: ${message(error)}`);
|
|
877
|
+
await stop();
|
|
878
|
+
return 1;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
732
881
|
if (plan && child.pid) {
|
|
733
882
|
tunnel = startTunnel({
|
|
734
883
|
dataDir: options.dataDir,
|
|
@@ -872,6 +1021,20 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
872
1021
|
return runStatus(options);
|
|
873
1022
|
case "login":
|
|
874
1023
|
return runLogin(options);
|
|
1024
|
+
case "access":
|
|
1025
|
+
return runAccess(options);
|
|
1026
|
+
case "service":
|
|
1027
|
+
return runServiceCommand({
|
|
1028
|
+
action: options.serviceAction ?? "install",
|
|
1029
|
+
dataDir: options.dataDir,
|
|
1030
|
+
port: options.port,
|
|
1031
|
+
domain: options.domain,
|
|
1032
|
+
tunnel: options.tunnel,
|
|
1033
|
+
tailscale: options.tailscale,
|
|
1034
|
+
label: options.label,
|
|
1035
|
+
script: process.argv[1] ?? "",
|
|
1036
|
+
node: process.execPath,
|
|
1037
|
+
}, { log: (line) => console.log(line), error: (line) => console.error(line) });
|
|
875
1038
|
case "logout":
|
|
876
1039
|
return runLogout(options);
|
|
877
1040
|
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) {
|
|
@@ -16,6 +16,8 @@ const BROWSER_PROFILE_ID = /^[a-z0-9_-]{1,40}$/;
|
|
|
16
16
|
export const DEFAULT_ROOM_TURN_TIMEOUT_MINUTES = 5;
|
|
17
17
|
export const MIN_ROOM_TURN_TIMEOUT_MINUTES = 1;
|
|
18
18
|
export const MAX_ROOM_TURN_TIMEOUT_MINUTES = 1_440;
|
|
19
|
+
export const DEFAULT_MAX_CONCURRENT_BOT_THREADS = 3;
|
|
20
|
+
export const MAX_CONCURRENT_BOT_THREADS = 10;
|
|
19
21
|
export const DEFAULT_LOCAL_VM_MODE = "shared";
|
|
20
22
|
export const DEFAULT_LOCAL_VM_MAX_INSTANCES = 2;
|
|
21
23
|
export const MIN_LOCAL_VM_MAX_INSTANCES = 1;
|
|
@@ -218,6 +220,9 @@ const defaultModelSelectionSchema = z.object({
|
|
|
218
220
|
const appConfigSchema = z.object({
|
|
219
221
|
/** Verified by the dedicated domain endpoint, never a generic config patch. */
|
|
220
222
|
customDomain: z.string().optional(),
|
|
223
|
+
/** Who may sign in with an emailed code (server/account-signin.ts):
|
|
224
|
+
* addresses or `@domain` entries; admins get every scope, members chat only. */
|
|
225
|
+
signIn: z.object({ admins: z.array(z.string().max(320)).max(500).optional(), members: z.array(z.string().max(320)).max(5000).optional() }).optional(),
|
|
221
226
|
defaultModelSelection: defaultModelSelectionSchema.optional(),
|
|
222
227
|
/** CLI-only launch preferences. Never enable remote access implicitly. */
|
|
223
228
|
cliStartup: z.object({
|
|
@@ -250,6 +255,7 @@ const appConfigSchema = z.object({
|
|
|
250
255
|
* system language. Unknown tags degrade to English in the renderer. */
|
|
251
256
|
language: optionalText,
|
|
252
257
|
rooms: roomConfigSchema.optional(),
|
|
258
|
+
threads: z.object({ maxConcurrentPerBot: z.number().int().min(1).max(MAX_CONCURRENT_BOT_THREADS) }).strict().optional(),
|
|
253
259
|
localVm: localVmConfigSchema.optional(),
|
|
254
260
|
features: featureConfigSchema.optional(),
|
|
255
261
|
browserProfiles: browserProfilesSchema.optional(),
|
|
@@ -346,6 +352,9 @@ export function vpsSshAlias(cfg) {
|
|
|
346
352
|
export function roomTurnTimeoutMinutes(cfg) {
|
|
347
353
|
return cfg.rooms?.turnTimeoutMinutes ?? DEFAULT_ROOM_TURN_TIMEOUT_MINUTES;
|
|
348
354
|
}
|
|
355
|
+
export function maxConcurrentBotThreads(cfg) {
|
|
356
|
+
return cfg.threads?.maxConcurrentPerBot ?? DEFAULT_MAX_CONCURRENT_BOT_THREADS;
|
|
357
|
+
}
|
|
349
358
|
export function localVmMode(cfg) {
|
|
350
359
|
return cfg.localVm?.mode ?? DEFAULT_LOCAL_VM_MODE;
|
|
351
360
|
}
|
|
@@ -424,6 +433,16 @@ export function loadConfig() {
|
|
|
424
433
|
cfg.imageGen = { ...cfg.imageGen };
|
|
425
434
|
if (process.env.OMB_OPENAI_IMAGE_KEY !== undefined)
|
|
426
435
|
cfg.imageGen.key = process.env.OMB_OPENAI_IMAGE_KEY;
|
|
436
|
+
// The sign-in allow-list: env is how a headless box or a container is
|
|
437
|
+
// bootstrapped before anyone can reach Settings.
|
|
438
|
+
const splitEmails = (value) => value.split(/[,\s]+/).map((entry) => entry.trim().toLowerCase()).filter(Boolean);
|
|
439
|
+
if (process.env.OMB_SIGNIN_EMAILS !== undefined || process.env.OMB_SIGNIN_MEMBER_EMAILS !== undefined) {
|
|
440
|
+
cfg.signIn = { ...cfg.signIn };
|
|
441
|
+
if (process.env.OMB_SIGNIN_EMAILS !== undefined)
|
|
442
|
+
cfg.signIn.admins = splitEmails(process.env.OMB_SIGNIN_EMAILS);
|
|
443
|
+
if (process.env.OMB_SIGNIN_MEMBER_EMAILS !== undefined)
|
|
444
|
+
cfg.signIn.members = splitEmails(process.env.OMB_SIGNIN_MEMBER_EMAILS);
|
|
445
|
+
}
|
|
427
446
|
return cfg;
|
|
428
447
|
}
|
|
429
448
|
/** After saveConfig() writes a credential, the running process's env must
|
|
@@ -531,7 +550,7 @@ export function saveConfig(patch, options = {}) {
|
|
|
531
550
|
const storedProfiles = storedBrowserProfilesSchema.safeParse(disk.browserProfiles);
|
|
532
551
|
if (storedProfiles.success)
|
|
533
552
|
disk.browserProfiles = storedProfiles.data;
|
|
534
|
-
for (const key of ["xai", "openaiCompat", "composio", "box", "opencodeGo", "tts", "imageGen", "profile", "rooms", "localVm", "features"]) {
|
|
553
|
+
for (const key of ["xai", "openaiCompat", "composio", "box", "opencodeGo", "tts", "imageGen", "profile", "rooms", "threads", "localVm", "features"]) {
|
|
535
554
|
const section = checkedPatch[key];
|
|
536
555
|
if (!section)
|
|
537
556
|
continue;
|
|
@@ -547,6 +566,8 @@ export function saveConfig(patch, options = {}) {
|
|
|
547
566
|
disk.language = checkedPatch.language;
|
|
548
567
|
if (checkedPatch.customDomain !== undefined)
|
|
549
568
|
disk.customDomain = checkedPatch.customDomain;
|
|
569
|
+
if (checkedPatch.signIn !== undefined)
|
|
570
|
+
disk.signIn = checkedPatch.signIn;
|
|
550
571
|
// A selection is replaced as one value, so changing engines also clears
|
|
551
572
|
// an effort level omitted from the new selection.
|
|
552
573
|
if (checkedPatch.defaultModelSelection !== undefined) {
|
|
@@ -29,7 +29,13 @@ export function createControlClient(options) {
|
|
|
29
29
|
if (!res.ok)
|
|
30
30
|
return UNAVAILABLE;
|
|
31
31
|
const body = await res.json().catch(() => null);
|
|
32
|
-
|
|
32
|
+
if (typeof body?.held !== "boolean" || typeof body?.helpOpen !== "boolean")
|
|
33
|
+
return UNAVAILABLE;
|
|
34
|
+
return {
|
|
35
|
+
held: body.held,
|
|
36
|
+
helpOpen: body.helpOpen,
|
|
37
|
+
...(typeof body.blockedReason === "string" && body.blockedReason.trim() ? { blockedReason: body.blockedReason } : {}),
|
|
38
|
+
};
|
|
33
39
|
}
|
|
34
40
|
catch {
|
|
35
41
|
return UNAVAILABLE;
|
|
@@ -251,7 +251,10 @@ export function queueDelegation(bus, from, item, maxDepth, sourceThreadId = from
|
|
|
251
251
|
* of the target turn is delegated to `runTarget` so delegations.ts
|
|
252
252
|
* stays free of harness-level concerns (commsDepth is the only thing
|
|
253
253
|
* the caller needs). */
|
|
254
|
-
export function drainDelegations(bus, approvalBus, threadId, runTarget
|
|
254
|
+
export function drainDelegations(bus, approvalBus, threadId, runTarget,
|
|
255
|
+
/** Terminal failures before dispatch also need to wake the source. A
|
|
256
|
+
* launched peer reports through its provider-turn finalizer instead. */
|
|
257
|
+
onSettled) {
|
|
255
258
|
if (drainingThreads.has(threadId)) {
|
|
256
259
|
queuedRedrains.add(threadId);
|
|
257
260
|
return;
|
|
@@ -263,6 +266,10 @@ export function drainDelegations(bus, approvalBus, threadId, runTarget) {
|
|
|
263
266
|
drainingThreads.add(threadId);
|
|
264
267
|
void (async () => {
|
|
265
268
|
for (const item of snapshot) {
|
|
269
|
+
// Stop/deletion may remove queued work while another item awaits a
|
|
270
|
+
// person's approval. A stale snapshot is never authority to launch it.
|
|
271
|
+
if (!pendingDelegations.get(threadId)?.some((candidate) => candidate.id === item.id))
|
|
272
|
+
continue;
|
|
266
273
|
// A shared channel's thread is not owned by any single bot, so each
|
|
267
274
|
// queued item carries its own source bot identity.
|
|
268
275
|
const from = bus.store.botByThread(threadId) ??
|
|
@@ -307,8 +314,19 @@ export function drainDelegations(bus, approvalBus, threadId, runTarget) {
|
|
|
307
314
|
finally {
|
|
308
315
|
// A requeued item (busy target, retries left) stays for the drain
|
|
309
316
|
// that the target's own settling turn will trigger.
|
|
317
|
+
const stillQueued = pendingDelegations.get(threadId)?.some((candidate) => candidate.id === item.id);
|
|
310
318
|
if (outcome !== "requeued")
|
|
311
319
|
acknowledgeDelegation(threadId, item.id);
|
|
320
|
+
if (outcome === "settled" && stillQueued) {
|
|
321
|
+
const receipt = findDelegationReceipt(item.id);
|
|
322
|
+
try {
|
|
323
|
+
if (receipt)
|
|
324
|
+
onSettled?.(receipt);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
console.error("delegation settled but its source could not be resumed", error);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
312
330
|
}
|
|
313
331
|
}
|
|
314
332
|
})().finally(() => {
|
|
@@ -321,7 +339,7 @@ export function drainDelegations(bus, approvalBus, threadId, runTarget) {
|
|
|
321
339
|
const snapshotIds = new Set(snapshot.map((item) => item.id));
|
|
322
340
|
const hasNewItems = pendingDelegations.get(threadId)?.some((item) => !snapshotIds.has(item.id)) ?? false;
|
|
323
341
|
if (redrainRequested || hasNewItems) {
|
|
324
|
-
drainDelegations(bus, approvalBus, threadId, runTarget);
|
|
342
|
+
drainDelegations(bus, approvalBus, threadId, runTarget, onSettled);
|
|
325
343
|
}
|
|
326
344
|
});
|
|
327
345
|
}
|
|
@@ -368,6 +386,19 @@ export function discardDelegations(bus, threadId) {
|
|
|
368
386
|
async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarget) {
|
|
369
387
|
let sender = from;
|
|
370
388
|
let target = bus.store.bot(item.toBotId);
|
|
389
|
+
// Retained approval authorizes the message, not a deleted conversation or
|
|
390
|
+
// revoked room membership. Check every retry before writing to its source.
|
|
391
|
+
if (!sourceThreadBelongsToBot(bus.store, sender.id, sourceThreadId)) {
|
|
392
|
+
recordDelegationReceipt({
|
|
393
|
+
id: item.id,
|
|
394
|
+
sourceThreadId,
|
|
395
|
+
toBotId: item.toBotId,
|
|
396
|
+
toBotName: target?.name ?? item.toBotId,
|
|
397
|
+
status: "dropped",
|
|
398
|
+
result: "the source conversation no longer belongs to the delegating bot",
|
|
399
|
+
});
|
|
400
|
+
return "settled";
|
|
401
|
+
}
|
|
371
402
|
if (!target) {
|
|
372
403
|
recordDelegationReceipt({
|
|
373
404
|
id: item.id,
|
|
@@ -422,6 +453,23 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
|
|
|
422
453
|
}
|
|
423
454
|
if (sender.approvePeerComms && !item.approvalAlreadyGranted) {
|
|
424
455
|
const verdict = await requestPeerApproval(approvalBus, sender, target, item.message, "delegate_bot", sourceThreadId);
|
|
456
|
+
if (!pendingDelegations.get(sourceThreadId)?.some((candidate) => candidate.id === item.id))
|
|
457
|
+
return "settled";
|
|
458
|
+
// Approval may have waited for minutes. Revalidate before even reporting
|
|
459
|
+
// a denial, which otherwise recreates a deleted source transcript.
|
|
460
|
+
const current = bus.store.bot(item.toBotId);
|
|
461
|
+
const currentSender = bus.store.bot(from.id);
|
|
462
|
+
if (!current || !currentSender || !sourceThreadBelongsToBot(bus.store, currentSender.id, sourceThreadId)) {
|
|
463
|
+
recordDelegationReceipt({
|
|
464
|
+
id: item.id,
|
|
465
|
+
sourceThreadId,
|
|
466
|
+
toBotId: item.toBotId,
|
|
467
|
+
toBotName: target.name,
|
|
468
|
+
status: "dropped",
|
|
469
|
+
result: "the peer or source conversation no longer exists",
|
|
470
|
+
});
|
|
471
|
+
return "settled";
|
|
472
|
+
}
|
|
425
473
|
if (verdict !== "allow") {
|
|
426
474
|
recordDelegationReceipt({
|
|
427
475
|
id: item.id,
|
|
@@ -438,14 +486,11 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
|
|
|
438
486
|
});
|
|
439
487
|
return "settled";
|
|
440
488
|
}
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
const currentSender = bus.store.bot(from.id);
|
|
447
|
-
if (!current || !currentSender || !sourceThreadBelongsToBot(bus.store, currentSender.id, sourceThreadId))
|
|
448
|
-
return "settled";
|
|
489
|
+
// A busy-target retry must not ask for the very same approval again.
|
|
490
|
+
item.approvalAlreadyGranted = true;
|
|
491
|
+
savePending();
|
|
492
|
+
// Recheck peer access and busy state too: an approval must not start a
|
|
493
|
+
// second turn or mirror an exchange that cannot actually happen.
|
|
449
494
|
if (dropIfUnreachable(bus, currentSender, current, sourceThreadId, item)) {
|
|
450
495
|
return "settled";
|
|
451
496
|
}
|
|
@@ -490,7 +535,7 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
|
|
|
490
535
|
const reasonLine = item.reason ? `\n\n[Reason: ${item.reason}]` : "";
|
|
491
536
|
const prefixed = `[Delegated by @${sender.name}, another bot in this OpenMausBot workspace. Do the work and reply directly.]\n\n${item.message}${reasonLine}`;
|
|
492
537
|
await runTarget(item.toBotId, prefixed, item.depth + 1, sourceThreadId, channel, item.id, sender.id);
|
|
493
|
-
return "
|
|
538
|
+
return "dispatched";
|
|
494
539
|
}
|
|
495
540
|
/** The source thread may be a bot's own task or a shared group where the
|
|
496
541
|
* bot is a member. This is the same check the API gate uses for group turns. */
|