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.
- package/dist/assets/{index-B9rLIY81.js → index-D0xt96rv.js} +15 -15
- package/dist/assets/{index-D-u0LYsV.js → index-Dq2j4Ikr.js} +1 -1
- package/dist/index.html +1 -1
- package/dist-server/companion/src/routes.js +213 -0
- package/dist-server/index.js +252 -24
- package/dist-server/openmausbot.js +45 -386
- package/dist-server/pair-cli.js +45 -386
- package/dist-server/server/auto-approve.js +24 -0
- package/dist-server/server/bot-profile.js +17 -0
- package/dist-server/server/channel-queue.js +1 -0
- package/dist-server/server/computer-control.js +15 -0
- package/dist-server/server/exit.js +9 -0
- package/dist-server/server/index.js +119 -22
- package/dist-server/server/openmausbot.js +3 -2
- package/dist-server/server/pair-cli.js +3 -2
- package/dist-server/server/peer-approval.js +27 -0
- package/dist-server/server/peer-provenance.js +4 -1
- package/dist-server/server/peer-roster.js +15 -0
- package/dist-server/server/request-auth.js +10 -0
- package/dist-server/server/tunnel.js +1 -1
- package/dist-server/tunnel-guardian.js +6 -19
- package/package.json +1 -1
|
@@ -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" })
|
|
@@ -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
|
-
|
|
858
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
5483
|
-
* a bot
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
3
|
+
import { exitAfterFlush } from "./exit.js";
|
|
4
|
+
main().then(exitAfterFlush, (error) => {
|
|
4
5
|
console.error(error instanceof Error ? error.message : String(error));
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
+
);
|