openmausbot 0.1.55 → 0.1.56

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.
@@ -2,6 +2,21 @@ import { z } from "zod";
2
2
  import { BOT_AVATAR_CROPS, botAvatarCropSchema, botAvatarUrlSchema } from "../shared/bot-avatar.js";
3
3
  import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
4
4
  import { MASCOT_BODY_IDS, mascotBodySchema } from "../shared/mascot-bodies.js";
5
+ /** A name or title is quoted inside prompts and cards as one line — a
6
+ * roster entry, a "Name: …" speaker line, the bracketed provenance note.
7
+ * Text that can break out of that line (a newline, a control byte, the
8
+ * Unicode separators) is refused at the door rather than flattened later,
9
+ * so what the person sees in the sidebar is what every prompt sees too.
10
+ * Written as a scan because a control-character class is the kind of
11
+ * literal the linter (rightly) refuses. */
12
+ export function fitsOnOneLine(value) {
13
+ for (let i = 0; i < value.length; i += 1) {
14
+ const code = value.charCodeAt(i);
15
+ if (code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0x2028 || code === 0x2029)
16
+ return false;
17
+ }
18
+ return true;
19
+ }
5
20
  export const BOT_PROFILE_PATCH_FIELDS = [
6
21
  "name",
7
22
  "title",
@@ -18,10 +33,12 @@ const profilePatchSchema = z.object({
18
33
  .string({ error: "name must be a string" })
19
34
  .max(BOT_PROFILE_LIMITS.name, { error: "name must be at most 100 characters" })
20
35
  .refine((value) => Boolean(value.trim()), { error: "name must not be empty" })
36
+ .refine(fitsOnOneLine, { error: "name must fit on one line" })
21
37
  .optional(),
22
38
  title: z
23
39
  .string({ error: "title must be a string" })
24
40
  .max(BOT_PROFILE_LIMITS.title, { error: "title must be at most 200 characters" })
41
+ .refine(fitsOnOneLine, { error: "title must fit on one line" })
25
42
  .optional(),
26
43
  description: z
27
44
  .string({ error: "description must be a string" })
@@ -17,6 +17,7 @@ export function queueChannelMessage(groupId, threadId, text, options = {}) {
17
17
  replyToId: options.replyToId,
18
18
  sendId: options.sendId,
19
19
  mode: options.mode ?? "chat",
20
+ via: options.via,
20
21
  };
21
22
  entry.items.push(item);
22
23
  queues.set(threadId, entry);
@@ -147,3 +147,18 @@ export class ComputerControl {
147
147
  return snapshot;
148
148
  }
149
149
  }
150
+ /** Safely executes an async tool or desktop control call, catching runtime exceptions gracefully. */
151
+ export async function executeToolSafely(toolName, fn) {
152
+ try {
153
+ const data = await fn();
154
+ return { success: true, data };
155
+ }
156
+ catch (err) {
157
+ const errorMessage = err?.message || String(err) || 'Unknown execution error';
158
+ console.error(`[Tool Execution Error] Name: ${toolName} | Details: ${errorMessage}`);
159
+ return {
160
+ success: false,
161
+ error: `Tool '${toolName}' failed to execute: ${errorMessage}`
162
+ };
163
+ }
164
+ }
@@ -0,0 +1,9 @@
1
+ // End the process with a code once stdout and stderr have flushed. On Linux a
2
+ // pipe (CI's `| grep`, a journal) takes writes asynchronously, so
3
+ // `process.exit` right after `console.error` can drop the very line that
4
+ // says what went wrong. A write callback fires only once its data is out.
5
+ export function exitAfterFlush(code) {
6
+ process.exitCode = code;
7
+ const flushed = (stream) => new Promise((done) => stream.write("", () => done()));
8
+ void Promise.all([flushed(process.stdout), flushed(process.stderr)]).then(() => process.exit(code));
9
+ }
@@ -10,7 +10,7 @@ import { botAvatarUrlFromStoredPath } from "../shared/bot-avatar.js";
10
10
  import { approvalModeFor, supportsApprovalMode, requiresNativeApproval, isEmergencyApprovalDowngrade, isApprovalMode, } from "../shared/approval-mode.js";
11
11
  import { escapeAttribute } from "../src/lib/composer-attachments.js";
12
12
  import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, isReusableCredentialRequest, isCredentialTargetId, } from "../shared/credential-request.js";
13
- import { autoVerdict, rememberableApprovalKey } from "./auto-approve.js";
13
+ import { approvalModeForOrigin, autoVerdict, rememberableApprovalKey } from "./auto-approve.js";
14
14
  import { requestReview, resolveAutoReviewMode, shouldReview } from "./auto-review.js";
15
15
  import { updateClaudeCli } from "./claude-update.js";
16
16
  import { BrowserCleanupCoordinator, finalizeBrowserCleanupMutation, requireBrowserCleanupAcknowledged, } from "./browser-lifecycle-cleanup.js";
@@ -20,7 +20,7 @@ import { validateBotCwd } from "./bot-cwd.js";
20
20
  import { ATTACHMENTS_DIR, attachmentExists, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, readAttachment, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
21
21
  import { messageFileDisposition, messageFileDownloadName, messageAttachmentName, messageFileRoots, messageImageTargetAt, messageReferencesFile, openMessageFile, } from "./message-file.js";
22
22
  import { avatarGenerationRequestSchema, avatarGenerationStateMatches, generateAvatarImage, snapshotAvatarGenerationState, } from "./avatar-image.js";
23
- import { parseBotProfilePatch } from "./bot-profile.js";
23
+ import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
24
24
  import { groupTurnCwd } from "./room-cwd.js";
25
25
  import { RoomTurnDeadline, RoomTurnStallRegistry, roomTurnTimeoutMessage } from "./room-turn-timeout.js";
26
26
  import * as box from "./box.js";
@@ -28,7 +28,7 @@ import { boxCreateRecoverySnapshot, retireDeletedBoxCreate } from "./box-create-
28
28
  import { boxAccountResourceChangeError, cloudBackendChangeError, vpsAliasResourceChangeError, } from "./cloud-backend.js";
29
29
  import * as composio from "./composio.js";
30
30
  import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
31
- import { peerAllowed, peerRosterSystemPrompt, reachablePeers, roomPeerRosterSystemPrompt } from "./peer-roster.js";
31
+ import { peerAllowed, peerName, peerRosterSystemPrompt, reachablePeers, roomPeerRosterSystemPrompt, roomRosterLine } from "./peer-roster.js";
32
32
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
33
33
  import { containerComputerAction, containerComputerExists, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
34
34
  import { ensureDirs, instanceConfigs, loadConfig, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, saveConfig, showToolCallsEnabled, skillRecorderEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
@@ -156,6 +156,7 @@ const DESKTOP_MANAGED = process.env.OMB_DESKTOP_PARENT === "1";
156
156
  // Empty is deliberately a deny-all bootstrap state. Only Electron's private
157
157
  // utility-process port can replace it with the per-launch owner capability.
158
158
  let desktopMutationToken = DESKTOP_MANAGED ? "" : undefined;
159
+ let companionMutationToken = DESKTOP_MANAGED ? "" : undefined;
159
160
  // Where remote clients reach this server (a proxy's public address); pairing URLs use it.
160
161
  const PUBLIC_URL = process.env.OMB_PUBLIC_URL?.trim().replace(/\/+$/, "") || null;
161
162
  const cfg = loadConfig();
@@ -188,6 +189,9 @@ function applyDesktopMutationTokenMessage(raw) {
188
189
  throw new Error("invalid desktop mutation capability");
189
190
  }
190
191
  desktopMutationToken = message.token;
192
+ if (typeof message.companionToken === "string" && /^[A-Za-z0-9_-]{43}$/.test(message.companionToken)) {
193
+ companionMutationToken = message.companionToken;
194
+ }
191
195
  return true;
192
196
  }
193
197
  const browserCleanup = new BrowserCleanupCoordinator({
@@ -853,9 +857,10 @@ const wireTrustedApprovalBot = (bot) => {
853
857
  /** Defense in depth for hand-edited/corrupt durable records: elevated
854
858
  * approval semantics require an implemented provider mapping. The trusted transition enforces
855
859
  * this too, but no provider dispatch or later permission callback relies on
856
- * persistence having been produced exclusively by that route. */
857
- const approvalModeForTurn = (bot) => {
858
- const mode = approvalModeFor(bot);
860
+ * persistence having been produced exclusively by that route. And a turn
861
+ * another bot started never runs as Full — see approvalModeForOrigin. */
862
+ const approvalModeForTurn = (bot, peerInitiated = false) => {
863
+ const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
859
864
  if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
860
865
  return "ask";
861
866
  }
@@ -2376,7 +2381,9 @@ bus.subscribe((event) => {
2376
2381
  const unattended = permission && asker && event.requestId ? isUnattended(asker.id) : false;
2377
2382
  const verdict = permission && asker && event.requestId
2378
2383
  ? autoVerdict({
2379
- approvalMode: approvalModeForTurn(asker),
2384
+ // the same origin the dispatch used, so a peer-started turn's
2385
+ // residual asks are judged as Approve for me here too
2386
+ approvalMode: approvalModeForTurn(asker, isInternalTurn(event.threadId)),
2380
2387
  autoApprove: false,
2381
2388
  alwaysAllow: asker.alwaysAllow,
2382
2389
  }, event.tool, event.summary, {
@@ -3421,7 +3428,12 @@ async function startTurn(botId, text, opts) {
3421
3428
  directTurnDispatchClaims.set(bot.id, { id: dispatchClaimId, threadId, phase: "setup" });
3422
3429
  beginInternalCapabilityGeneration(threadId, dispatchClaimId);
3423
3430
  store.setActivity(bot.id, "working");
3424
- store.patchBot(bot.id, { unread: false });
3431
+ // The badge is "this bot answered you, and you have not looked yet". A
3432
+ // person starting a turn has looked; a teammate's hop has not — the fold
3433
+ // never re-marks an internal turn, so clearing here would silently spend
3434
+ // a signal the person still owes a glance to.
3435
+ if (commsDepth === 0)
3436
+ store.patchBot(bot.id, { unread: false });
3425
3437
  turnUsage.delete(threadId);
3426
3438
  void (async () => {
3427
3439
  try {
@@ -3747,7 +3759,7 @@ async function startTurn(botId, text, opts) {
3747
3759
  threadId,
3748
3760
  text: turnText,
3749
3761
  images: turnImages,
3750
- approvalMode: approvalModeForTurn(liveBot ?? bot),
3762
+ approvalMode: approvalModeForTurn(liveBot ?? bot, commsDepth > 0),
3751
3763
  model,
3752
3764
  effort,
3753
3765
  // a rewound thread never resumes the abandoned branch's session
@@ -4339,7 +4351,12 @@ function serializeRoomContext(threadId, userName, textOverride, readerBotId) {
4339
4351
  .slice(-GROUP_CONTEXT_MESSAGES)
4340
4352
  .map((m) => {
4341
4353
  const rendered = textOverride?.messageId === m.id ? { ...m, text: textOverride.text } : m;
4342
- const speaker = m.role === "user" ? userName : (m.from?.name ?? "Bot");
4354
+ // a bot's name is quoted on the speaker line, so it gets one line; a
4355
+ // user line that came through the API says so, since the reader would
4356
+ // otherwise take it for the person typing
4357
+ const speaker = m.role === "user"
4358
+ ? m.via === "api" ? `${userName} (sent through the local API, not typed)` : userName
4359
+ : m.from ? peerName(m.from.name) : "Bot";
4343
4360
  const line = `${speaker}: ${transcriptText(rendered, messagesById, userName)}`;
4344
4361
  // A room reply is the room talking. A post_to_room message is another
4345
4362
  // bot's text carried in from somewhere else, so it says so — the
@@ -4366,7 +4383,7 @@ const ROOM_POST_MAX_CHARS = 4_000;
4366
4383
  // approval bus: peer-approval.ts only needs to push cards and broadcast
4367
4384
  // them — its pending map lives in the module so the two respond endpoints
4368
4385
  // can call resolvePeerComms without holding a reference back to here.
4369
- const approvalBus = { store, broadcast };
4386
+ const approvalBus = { store, broadcast, notify };
4370
4387
  // Approvals live only in memory, so any peer card still open on disk is one
4371
4388
  // whose resolver died with the previous process. Left alone it can never be
4372
4389
  // answered, and the composer stays disabled behind it — settle them at boot.
@@ -4634,7 +4651,7 @@ setupRetry = 0) {
4634
4651
  const roster = readyGroup.memberIds
4635
4652
  .map((id) => store.bot(id))
4636
4653
  .filter((b) => Boolean(b))
4637
- .map((b) => `@${b.name}${b.title ? ` (${b.title})` : ""}`)
4654
+ .map(roomRosterLine)
4638
4655
  .join(", ");
4639
4656
  // The roster above is who an @mention can reach; the section's other bots
4640
4657
  // are who it cannot. The 1:1 prompt has carried a peer roster since #774,
@@ -5246,6 +5263,7 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
5246
5263
  sendId,
5247
5264
  channelMode,
5248
5265
  queueId,
5266
+ via: options.via,
5249
5267
  });
5250
5268
  if (!group.dm)
5251
5269
  store.titleGroupTaskFromFirstMessage(group.id, text, threadId);
@@ -5376,7 +5394,7 @@ function drainQueuedChannelSends() {
5376
5394
  drainChannelMessages((groupId) => {
5377
5395
  const group = store.group(groupId);
5378
5396
  return group ? groupIsWorking(group) : false;
5379
- }, ({ groupId, threadId, text, replyToId, sendId, mode, id }) => {
5397
+ }, ({ groupId, threadId, text, replyToId, sendId, mode, id, via }) => {
5380
5398
  const group = store.group(groupId);
5381
5399
  const ownsThread = group?.dm
5382
5400
  ? group.threadId === threadId
@@ -5384,7 +5402,7 @@ function drainQueuedChannelSends() {
5384
5402
  if (!group || !ownsThread)
5385
5403
  return;
5386
5404
  try {
5387
- startGroupTurn(groupId, text, resolveReplyTarget(threadId, replyToId), sendId, mode, id);
5405
+ startGroupTurn(groupId, text, resolveReplyTarget(threadId, replyToId), sendId, mode, id, { via });
5388
5406
  }
5389
5407
  catch (error) {
5390
5408
  store.appendMessage(threadId, {
@@ -5478,14 +5496,17 @@ function connectorThread(botId, threadId) {
5478
5496
  * last, and a second copy of it could only ever disagree.
5479
5497
  *
5480
5498
  * Only a person puts a user-role message in a room: the composer, or a
5481
- * calendar call they scheduled. No bot has that ingress — post_to_room
5482
- * appends role "bot", which is the rule this whole surface turns on — so
5483
- * a bot cannot re-arm the ceiling it just spent. */
5499
+ * calendar call they scheduled. No bot tool has that ingress — post_to_room
5500
+ * appends role "bot", which is the rule this whole surface turns on. The
5501
+ * one door a bot's shell could reach on a headless server, the HTTP API
5502
+ * with no session behind it, stamps what it lets in (Message.via), and a
5503
+ * line so stamped does not count here — so a bot cannot re-arm the ceiling
5504
+ * it just spent. */
5484
5505
  function lastHumanRoomMessageAt(group) {
5485
5506
  const messages = store.messagesFor(group.threadId);
5486
5507
  for (let i = messages.length - 1; i >= 0; i -= 1) {
5487
5508
  const message = messages[i];
5488
- if (message.role === "user" && message.kind === "text")
5509
+ if (message.role === "user" && message.kind === "text" && !message.via)
5489
5510
  return message.at;
5490
5511
  }
5491
5512
  return undefined;
@@ -6469,6 +6490,7 @@ const handleRequest = async (req, res) => {
6469
6490
  streamPath: "/api/events",
6470
6491
  url,
6471
6492
  loopbackMutationToken: desktopMutationToken,
6493
+ companionMutationToken,
6472
6494
  });
6473
6495
  // Reachability probe, public: the phone races it across a server's
6474
6496
  // addresses before it has a session, and the tunnel verifier polls it.
@@ -7004,7 +7026,12 @@ const handleRequest = async (req, res) => {
7004
7026
  currentFrom = freshFrom;
7005
7027
  currentTarget = freshTarget;
7006
7028
  }
7007
- const channel = getOrCreateChannel(store, currentFrom, currentTarget);
7029
+ // An ask made from inside a room is mirrored into that room — the
7030
+ // conversation the person is actually reading — the way delegate_bot
7031
+ // already does. The pair channel is for asks made from a bot's own
7032
+ // thread; sending a room's ask there put the whole exchange behind
7033
+ // an unbadged "A ⇄ B" entry nobody had a reason to open.
7034
+ const channel = getOrCreateChannel(store, currentFrom, currentTarget, connectorThread(currentFrom.id, fromThreadId)?.group);
7008
7035
  mirrorExchange(commsBus, currentFrom, currentTarget, message, channel, fromThreadId);
7009
7036
  const prefixed = withPeerProvenance(message, {
7010
7037
  botName: currentFrom.name,
@@ -7312,6 +7339,12 @@ const handleRequest = async (req, res) => {
7312
7339
  return json(res, 400, { error: "name must be at most 80 characters" });
7313
7340
  if (role.length > 120)
7314
7341
  return json(res, 400, { error: "role must be at most 120 characters" });
7342
+ // the same door the profile endpoints keep: both fields are quoted
7343
+ // into every other room member's system prompt, one line each
7344
+ if (!fitsOnOneLine(name))
7345
+ return json(res, 400, { error: "name must fit on one line" });
7346
+ if (!fitsOnOneLine(role))
7347
+ return json(res, 400, { error: "role must fit on one line" });
7315
7348
  if (instructions.length > 1_000) {
7316
7349
  return json(res, 400, { error: "instructions must be at most 1000 characters" });
7317
7350
  }
@@ -8071,7 +8104,9 @@ const handleRequest = async (req, res) => {
8071
8104
  const userName = cfg.profile?.name?.trim() || "User";
8072
8105
  const lines = [`# ${title}`, ""];
8073
8106
  for (const msg of messages) {
8074
- const who = msg.role === "user" ? userName : (msg.from?.name ?? bot?.name ?? "Bot");
8107
+ const who = msg.role === "user"
8108
+ ? msg.via === "api" ? `${userName} (via the local API)` : userName
8109
+ : (msg.from?.name ?? bot?.name ?? "Bot");
8075
8110
  if (msg.kind === "text" && msg.text)
8076
8111
  lines.push(`**${who}:**`, "", msg.text, "");
8077
8112
  else if (msg.kind === "activity" && msg.tool)
@@ -8774,6 +8809,19 @@ const handleRequest = async (req, res) => {
8774
8809
  }
8775
8810
  const sendId = parseSendId(body.sendId);
8776
8811
  const replyTo = resolveReplyTarget(threadId, body.replyToId);
8812
+ // Who is this "user"? On a headless server loopback is the owner by
8813
+ // design, and a bot's shell is a loopback caller too. A request with
8814
+ // no paired session and no browser origin cannot be told from a
8815
+ // script, so its message is stamped rather than trusted as typed —
8816
+ // the room's readers, its posting budget and its transcript all look
8817
+ // at that stamp — and the send is logged where the operator can see
8818
+ // it. The desktop app never gets here: its owner capability is
8819
+ // checked before this handler runs.
8820
+ const browserOrigin = typeof req.headers.origin === "string" && req.headers.origin.trim() !== "";
8821
+ const via = auth.kind === "loopback" && !DESKTOP_MANAGED && !browserOrigin ? "api" : undefined;
8822
+ if (via) {
8823
+ console.warn(`room message from ${requestSource(req)} through the local API (no session, no browser origin) into "${group.name}"`);
8824
+ }
8777
8825
  const receipt = await sendSequencer.run(sendId ? `group:${group.id}:${threadId}:${sendId}` : undefined, sendFingerprint(text, replyTo?.id, channelMode), async () => {
8778
8826
  if (sendId) {
8779
8827
  const accepted = acceptedSendMatch(store.messagesFor(threadId), sendId, text, replyTo?.id, channelMode);
@@ -8806,10 +8854,11 @@ const handleRequest = async (req, res) => {
8806
8854
  replyToId: replyTo?.id,
8807
8855
  sendId,
8808
8856
  mode: channelMode,
8857
+ via,
8809
8858
  });
8810
8859
  return { ok: true, queued: true, queueId: queued.id, threadId };
8811
8860
  }
8812
- const message = startGroupTurn(current.id, text, replyTo, sendId, channelMode);
8861
+ const message = startGroupTurn(current.id, text, replyTo, sendId, channelMode, undefined, { via });
8813
8862
  return { ok: true, threadId, message };
8814
8863
  });
8815
8864
  return json(res, 202, receipt);
@@ -9379,6 +9428,45 @@ const handleRequest = async (req, res) => {
9379
9428
  }
9380
9429
  patch.alwaysAllow = [...new Set(body.alwaysAllow)].slice(0, 200);
9381
9430
  }
9431
+ // What "the proof of a human" above actually rests on. In the packaged
9432
+ // desktop it is real: every mutation here already carried the owner
9433
+ // capability a tool call cannot forge. Outside it — `pnpm dev`, the
9434
+ // CLI, the Docker stack — loopback is the owner by design, so the
9435
+ // acknowledgement flag and the settings that loosen a bot's leash
9436
+ // (a wider peer list, the peer-approval gate switched off, a section
9437
+ // move that changes who is in reach, a standing always-allow grant)
9438
+ // are one curl away from the bot they constrain. What such a request
9439
+ // does NOT have is a paired session or a browser origin; and the one
9440
+ // moment a bot's shell can send it is while a turn is running. So an
9441
+ // originless, session-less loopback caller may loosen a bot only
9442
+ // while every bot is idle — and is logged when it does — while the
9443
+ // served UI (a browser, with its origin) and a paired device keep
9444
+ // working mid-turn as before. A bar, not a wall: the wall is the
9445
+ // desktop capability or a paired session, which is what the refusal
9446
+ // points at.
9447
+ const loosened = [];
9448
+ if (body.peers !== undefined && Array.isArray(existingBot?.peers)) {
9449
+ const nextPeers = patch.peers;
9450
+ if (nextPeers === undefined || (Array.isArray(nextPeers) && nextPeers.some((peerId) => !existingBot.peers.includes(peerId)))) {
9451
+ loosened.push("peers");
9452
+ }
9453
+ }
9454
+ if (body.approvePeerComms === false && existingBot?.approvePeerComms === true)
9455
+ loosened.push("approvePeerComms");
9456
+ if (section !== undefined && sectionKey(existingBot?.section) !== sectionKey(section))
9457
+ loosened.push("section");
9458
+ if (Array.isArray(patch.alwaysAllow) && patch.alwaysAllow.some((key) => !(existingBot?.alwaysAllow ?? []).includes(key))) {
9459
+ loosened.push("alwaysAllow");
9460
+ }
9461
+ const browserOrigin = typeof req.headers.origin === "string" && req.headers.origin.trim() !== "";
9462
+ if (loosened.length && auth.kind === "loopback" && !DESKTOP_MANAGED && !browserOrigin) {
9463
+ if (store.bots.some((candidate) => candidate.busy)) {
9464
+ return json(res, 409, {
9465
+ error: "A bot is working right now, so this change has to come from the desktop app or a paired device. Try again once every bot is idle.",
9466
+ });
9467
+ }
9468
+ console.warn(`bot ${m[1]}: ${loosened.join(", ")} loosened by ${requestSource(req)} through the local API with no paired session`);
9469
+ }
9382
9470
  if (existingBot?.computer === "local" && computerSpecified && requestedComputer !== "local") {
9383
9471
  const routineThread = routines.activeBotRunForBot(existingBot.id)?.threadId;
9384
9472
  const groupThread = activeGroupTurnForBot(existingBot.id)?.threadId;
@@ -9936,7 +10024,16 @@ const handleRequest = async (req, res) => {
9936
10024
  if (!current.busy) {
9937
10025
  throw Object.assign(new Error("the running turn ended before the steered message could be recorded"), { status: 409 });
9938
10026
  }
9939
- clearUnattended(current.id);
10027
+ // A person steering a webhook turn is present, and auto mode may
10028
+ // follow them again. But this route is also reachable from the
10029
+ // bot's own shell on a headless server (loopback is the owner
10030
+ // there), and "continue" typed by the turn itself must not be
10031
+ // the thing that lifts the block written against it — so only
10032
+ // a request that proves a person (a paired session, or the
10033
+ // desktop's owner capability, which every mutation there has
10034
+ // already shown) clears the mark.
10035
+ if (auth.kind === "session" || DESKTOP_MANAGED)
10036
+ clearUnattended(current.id);
9940
10037
  const message = store.appendMessage(threadId, {
9941
10038
  role: "user",
9942
10039
  kind: "text",
@@ -1,6 +1,7 @@
1
1
  // Entry point for the `openmausbot` command (see cli.ts).
2
2
  import { main } from "./cli.js";
3
- main().then((code) => process.exit(code), (error) => {
3
+ import { exitAfterFlush } from "./exit.js";
4
+ main().then(exitAfterFlush, (error) => {
4
5
  console.error(error instanceof Error ? error.message : String(error));
5
- process.exit(1);
6
+ exitAfterFlush(1);
6
7
  });
@@ -1,7 +1,8 @@
1
1
  // Compatibility alias: `openmausbot pair` lives in cli.ts now. Kept because
2
2
  // docs and images reference dist-server/pair-cli.js.
3
3
  import { main } from "./cli.js";
4
- main(["pair", ...process.argv.slice(2)]).then((code) => process.exit(code), (error) => {
4
+ import { exitAfterFlush } from "./exit.js";
5
+ main(["pair", ...process.argv.slice(2)]).then(exitAfterFlush, (error) => {
5
6
  console.error(error instanceof Error ? error.message : String(error));
6
- process.exit(1);
7
+ exitAfterFlush(1);
7
8
  });
@@ -14,6 +14,7 @@
14
14
  // the user-facing Always-allow flow already mirrors that key back into
15
15
  // `alwaysAllow`, so the two sides never disagree about what was granted.
16
16
  import { newId } from "./contracts.js";
17
+ import { buildNotification } from "./notify.js";
17
18
  import { peerAllowKey } from "./peer-approval-key.js";
18
19
  export { peerAllowKey } from "./peer-approval-key.js";
19
20
  /** Mark the card answered so the UI stops treating it as pending. Mirrors
@@ -28,6 +29,11 @@ function settleCard(pending, behavior, source) {
28
29
  pending.bus.store.patchMessage(pending.threadId, pending.messageId, {
29
30
  card: { ...existing.card, answered: behavior, dismissed: source !== "user" },
30
31
  });
32
+ // answered (by whoever): the turn is working again — the same hand-back
33
+ // the request.resolved fold does for a provider's card
34
+ const waiting = pending.bus.store.bot(pending.fromBotId);
35
+ if (waiting?.activity === "waiting-on-you")
36
+ pending.bus.store.setActivity(waiting.id, "working");
31
37
  }
32
38
  /** requestId → pending ask. Lives only in memory — restarting the
33
39
  * server cancels every in-flight approval, like provider permissions do. */
@@ -62,6 +68,26 @@ function pushApprovalCard(bus, from, target, message, action, requestId, sourceT
62
68
  });
63
69
  return note;
64
70
  }
71
+ /** This card is the one bot-to-bot event that blocks on a person — for up
72
+ * to fifteen minutes — so it gets exactly what a provider's card gets: the
73
+ * bot shows as waiting rather than working, and a "needs approval" frame
74
+ * goes out aimed at the thread the card is in. Without this the sidebar
75
+ * read "Working…" with no dot and no banner until the timer denied it, and
76
+ * the bot then reported it could not reach a teammate nobody knew it had
77
+ * asked for. A bot speaking in a room is not reachable in its own thread,
78
+ * so the room the card landed in is named, the way takeover does. */
79
+ function announceCard(bus, from, card, sourceThreadId) {
80
+ // the bot is not working now — it is waiting on a person. A delegation
81
+ // drained after its turn has already settled is idle, and stays so.
82
+ const live = bus.store.bot(from.id);
83
+ if (live?.busy)
84
+ bus.store.setActivity(from.id, "waiting-on-you");
85
+ const room = bus.store.groupByThread(sourceThreadId);
86
+ const group = room && !room.dm ? { id: room.id, name: room.name } : undefined;
87
+ const title = card.card?.title ?? "";
88
+ const detail = card.card?.subtitle ? `${title} — ${card.card.subtitle}` : title;
89
+ bus.notify?.(buildNotification("approval", from, sourceThreadId, detail, { avatarUrl: from.avatarUrl, group }));
90
+ }
65
91
  /** Ask the user (in the source task thread) whether `from` may `action` `target`.
66
92
  * Resolves with `"allow"` or `"deny"`. If `from.alwaysAllow` already
67
93
  * covers the (action, target) pair, returns `"allow"` immediately
@@ -75,6 +101,7 @@ export function requestPeerApproval(bus, from, target, message, action, sourceTh
75
101
  // the card has to exist before the entry, so a timeout or an answer can
76
102
  // always find it to settle
77
103
  const card = pushApprovalCard(bus, from, target, message, action, requestId, sourceThreadId);
104
+ announceCard(bus, from, card, sourceThreadId);
78
105
  const timer = setTimeout(() => {
79
106
  // 15 minutes without an answer → deny. Keeps an unattended bot from
80
107
  // stalling its own turn forever (matches the Claude broker timeout).
@@ -16,8 +16,11 @@
16
16
  // The note opens with "[Message from @Name" or "[Posted by @Name" because
17
17
  // the shape of the first few words is what a model actually keys on, and
18
18
  // that marker has been in the ask path since peer comms shipped.
19
+ import { peerName } from "./peer-roster.js";
19
20
  /** The bracketed provenance line on its own. */
20
- export function peerProvenanceNote({ botName, delivery, unattended }) {
21
+ export function peerProvenanceNote({ botName: rawName, delivery, unattended }) {
22
+ // the note is one bracketed line, and the name must not be able to end it
23
+ const botName = peerName(rawName);
21
24
  const opening = delivery === "ask_bot"
22
25
  ? `Message from @${botName}, another bot in this OpenMausBot workspace`
23
26
  : `Posted by @${botName}, another bot in this OpenMausBot workspace`;
@@ -57,6 +57,21 @@ const clip = (value, max) => {
57
57
  const flat = oneLine(value);
58
58
  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
59
59
  };
60
+ /** A bot's name where it is about to be quoted INSIDE harness text — the
61
+ * bracketed provenance note, a room transcript's "Name: …" speaker line.
62
+ * Flattened and clipped like a roster entry, and with the brackets the
63
+ * provenance note is built from taken out: "Scout]" would otherwise close
64
+ * the note early and let whatever follows read as a speaker of its own. */
65
+ export function peerName(value) {
66
+ return clip(value.replace(/[[\]]/g, " "), ROSTER_NAME_MAX);
67
+ }
68
+ /** One member of a room as the room prompt lists it. Same discipline as the
69
+ * 1:1 roster: a title carrying a newline would otherwise land in every OTHER
70
+ * member's system prompt as a line of its own. */
71
+ export function roomRosterLine(member) {
72
+ const role = member.title ? clip(member.title, ROSTER_ROLE_MAX) : "";
73
+ return `@${clip(member.name, ROSTER_NAME_MAX)}${role ? ` (${role})` : ""}`;
74
+ }
60
75
  /** Render a team as roster lines. Every knob is the caller's, not the
61
76
  * renderer's: how many names a bot needs — and how much detail — depends on
62
77
  * what it is expected to do with them. */
@@ -1,5 +1,6 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import { isIP } from "node:net";
3
+ import { denyReason as companionDenial } from "../companion/src/routes.js";
3
4
  const LOOPBACK_SCOPES = ["admin", "client"];
4
5
  export function isLoopbackHost(host) {
5
6
  if (!host)
@@ -307,6 +308,15 @@ export function resolveRequestAuth(req, options) {
307
308
  const proxied = isProxied(req);
308
309
  const loopback = !proxied && isLoopbackHost(headerValue(req.headers.host)) && isAllowedOrigin(headerValue(req.headers.origin));
309
310
  if (loopback) {
311
+ const companionToken = headerValue(req.headers["x-openmausbot-companion-auth"]);
312
+ if (companionToken && options.loopbackMutationToken !== undefined) {
313
+ if (!secureTokenMatch(companionToken, options.companionMutationToken ?? "") ||
314
+ req.headers["x-openmausbot-companion"] !== "1" ||
315
+ !/^[\w-]{1,128}$/.test(headerValue(req.headers["x-openmausbot-companion-device"]) ?? "") ||
316
+ companionDenial({ path, method, authenticated: true }))
317
+ return deny(403, "forbidden: invalid companion request");
318
+ return { auth: { kind: "loopback", scopes: LOOPBACK_SCOPES }, status: 401, error: "" };
319
+ }
310
320
  if (options.loopbackMutationToken !== undefined &&
311
321
  mutatingPublicRoute(method, path) &&
312
322
  !secureTokenMatch(headerValue(req.headers[DESKTOP_OWNER_HEADER]), options.loopbackMutationToken)) {
@@ -126,7 +126,7 @@ export function guardianEntry(here = HERE) {
126
126
  const bundled = join(here, "tunnel-guardian.js");
127
127
  if (existsSync(bundled))
128
128
  return bundled;
129
- const source = resolve(here, "..", "electron", "managed-companion-guardian.mjs");
129
+ const source = resolve(here, "..", "electron", "managed-companion-guardian-main.mjs");
130
130
  return existsSync(source) ? source : null;
131
131
  }
132
132
  export async function ensureCloudflared(options) {
@@ -1,7 +1,6 @@
1
1
  // electron/managed-companion-guardian.mjs
2
2
  import { spawn } from "node:child_process";
3
3
  import path2 from "node:path";
4
- import { fileURLToPath } from "node:url";
5
4
 
6
5
  // electron/companion-origin-gateway.mjs
7
6
  import http from "node:http";
@@ -236,12 +235,6 @@ function minimalCloudflaredEnvironment(environment = process.env, platform = pro
236
235
  }
237
236
  return minimal;
238
237
  }
239
- function minimalGuardianEnvironment(environment = process.env, platform = process.platform) {
240
- return {
241
- ...minimalCloudflaredEnvironment(environment, platform),
242
- ELECTRON_RUN_AS_NODE: "1"
243
- };
244
- }
245
238
  function processIsAlive(pid) {
246
239
  try {
247
240
  process.kill(pid, 0);
@@ -376,15 +369,9 @@ function guardianArguments(argv) {
376
369
  }
377
370
  return { cloudflaredBinary, tokenFile, target, originPort };
378
371
  }
379
- var isDirectExecution = process.argv[1] && path2.resolve(process.argv[1]) === path2.resolve(fileURLToPath(import.meta.url));
380
- if (isDirectExecution) {
381
- runManagedCompanionGuardian(guardianArguments(process.argv.slice(2))).then(
382
- (code) => process.exit(code),
383
- () => process.exit(1)
384
- );
385
- }
386
- export {
387
- minimalCloudflaredEnvironment,
388
- minimalGuardianEnvironment,
389
- runManagedCompanionGuardian
390
- };
372
+
373
+ // electron/managed-companion-guardian-main.mjs
374
+ runManagedCompanionGuardian(guardianArguments(process.argv.slice(2))).then(
375
+ (code) => process.exit(code),
376
+ () => process.exit(1)
377
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openmausbot",
3
- "version": "0.1.55",
3
+ "version": "0.1.56",
4
4
  "description": "Run the OpenMausBot server anywhere and pair your devices to it",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",