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.
Files changed (53) hide show
  1. package/dist/assets/index-CbaejjQA.js +278 -0
  2. package/dist/assets/{index-kW5L1QeO.js → index-HqooKFTS.js} +1 -1
  3. package/dist/assets/index-aJ5EWZOQ.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/computer-proxy.js +14 -3
  6. package/dist-server/container-mcp.js +44 -19
  7. package/dist-server/drivers/agents-proxy.js +59 -3
  8. package/dist-server/enterprise/server/index.js +14587 -0
  9. package/dist-server/index.js +2675 -1055
  10. package/dist-server/local-computer-proxy.js +433 -0
  11. package/dist-server/local-computer.js +48 -5
  12. package/dist-server/mcp-server.js +38 -17
  13. package/dist-server/openmausbot.js +1560 -745
  14. package/dist-server/pair-cli.js +1560 -745
  15. package/dist-server/proxy-paths.js +1 -0
  16. package/dist-server/server/account-signin.js +103 -0
  17. package/dist-server/server/brand.js +7 -0
  18. package/dist-server/server/caddy.js +203 -0
  19. package/dist-server/server/cli.js +185 -22
  20. package/dist-server/server/computer-proxy.js +11 -2
  21. package/dist-server/server/config.js +22 -1
  22. package/dist-server/server/control-client.js +7 -1
  23. package/dist-server/server/delegations.js +56 -11
  24. package/dist-server/server/drivers/agents-proxy.js +75 -5
  25. package/dist-server/server/drivers/claude-login-auth.js +334 -0
  26. package/dist-server/server/drivers/claude.js +8 -0
  27. package/dist-server/server/environment.js +1 -0
  28. package/dist-server/server/harness/registry.js +7 -1
  29. package/dist-server/server/index.js +965 -363
  30. package/dist-server/server/local-computer-proxy.js +28 -0
  31. package/dist-server/server/local-computer.js +17 -0
  32. package/dist-server/server/mcp-bridge.js +27 -11
  33. package/dist-server/server/peer-approval.js +19 -6
  34. package/dist-server/server/proxy-paths.js +1 -0
  35. package/dist-server/server/routines.js +36 -9
  36. package/dist-server/server/service-cli.js +63 -0
  37. package/dist-server/server/service-unit.js +129 -0
  38. package/dist-server/server/sessions.js +42 -1
  39. package/dist-server/server/steer-queue.js +12 -9
  40. package/dist-server/server/store.js +260 -34
  41. package/dist-server/server/system-prompt.js +1 -0
  42. package/dist-server/server/team-backup.js +1 -0
  43. package/dist-server/server/tunnel.js +27 -1
  44. package/dist-server/server/turn-dispatch-guard.js +6 -0
  45. package/dist-server/server/turn-resources.js +50 -0
  46. package/dist-server/server/workspace.js +53 -3
  47. package/dist-server/vps-container-mcp.js +44 -19
  48. package/enterprise/LICENSE +32 -0
  49. package/enterprise/README.md +89 -0
  50. package/enterprise/server/index.js +14587 -0
  51. package/package.json +2 -1
  52. package/dist/assets/index-BftlaHAF.css +0 -1
  53. 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 (account.address)
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
- const account = createTunnelAccount({ dataDir: options.dataDir, version: serverVersion() });
553
- if (account.credentials.status === "unavailable")
554
- return { error: `${account.credentials.file} exists but could not be read; fix or remove it` };
555
- if (!describeTunnelAccount(account.credentials.read()).email) {
556
- return { error: "no account on this machine yet: run `openmausbot login` first, then `openmausbot serve --tunnel`" };
557
- }
558
- // A fresh connector token when the control plane answers; the saved one otherwise.
559
- try {
560
- const state = await account.service.retry();
561
- if (state.message && !tunnelAccess(account.credentials.read()))
562
- log(`tunnel: ${state.message}`);
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
- catch (error) {
565
- log(`tunnel: control plane not reachable right now (${message(error)}); using the saved address`);
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 stops accepting before the server it forwards to goes away.
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) && (await control.state(true)).held) {
738
- return text(id, CONTROL_REFUSAL, true);
737
+ if (!OPEN_WHILE_DRIVEN.has(name)) {
738
+ const state = await control.state(true);
739
+ if (state.held)
740
+ return text(id, state.blockedReason ?? CONTROL_REFUSAL, true);
739
741
  }
740
742
  if (name === "computer_request_help") {
741
743
  if (!control.configured) {
742
744
  return text(id, "nobody can be paged for this computer right now — carry on carefully", true);
743
745
  }
744
746
  const initial = await control.state(true);
747
+ if (initial.held && initial.blockedReason)
748
+ return text(id, initial.blockedReason, true);
745
749
  // If the person is already driving, don't clobber whatever plea they
746
750
  // are reading — just wait for the hand-back.
747
751
  const requestId = initial.held ? null : await control.requestHelp(String(args?.reason ?? ""));
@@ -753,6 +757,11 @@ async function call(id, name, args) {
753
757
  while (Date.now() < deadline) {
754
758
  await new Promise((resolve) => setTimeout(resolve, CONTROL_POLL_MS));
755
759
  const state = await control.state(true);
760
+ if (state.held && state.blockedReason) {
761
+ if (requestId)
762
+ await control.expireHelp(requestId);
763
+ return text(id, state.blockedReason, true);
764
+ }
756
765
  if (state.held)
757
766
  sawHold = true;
758
767
  if (!state.held && !state.helpOpen) {
@@ -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
- return { held: body?.held === true, helpOpen: body?.helpOpen === true };
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
- // The approval could have been sitting for up to 15 minutes. Everything
442
- // checked above is a stale snapshot now: re-read both bots and re-check
443
- // busy, or an allow can start a second turn on a bot that is mid-turn —
444
- // and mirror a "Messaged @X" chip for an exchange that never happens.
445
- const current = bus.store.bot(item.toBotId);
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 "settled";
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. */