openmausbot 0.1.61 → 0.1.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -8
- package/dist/assets/index-BuntoOSX.js +264 -0
- package/dist/assets/{index-CAZ41wL2.js → index-C-SoIGdn.js} +1 -1
- package/dist/index.html +1 -1
- package/dist-server/container-mcp.js +16 -1
- package/dist-server/drivers/agents-proxy.js +8 -4
- package/dist-server/index.js +468 -141
- package/dist-server/openmausbot.js +33585 -14506
- package/dist-server/pair-cli.js +33585 -14506
- package/dist-server/server/auto-approve.js +6 -20
- package/dist-server/server/browser-bundle-release.js +56 -0
- package/dist-server/server/browser-engine-release.js +13 -5
- package/dist-server/server/browser-engine.js +114 -15
- package/dist-server/server/cli-api-setup.js +144 -0
- package/dist-server/server/cli-phone-setup.js +130 -0
- package/dist-server/server/cli-prompts.js +185 -0
- package/dist-server/server/cli-setup.js +303 -0
- package/dist-server/server/cli.js +375 -70
- package/dist-server/server/config.js +25 -1
- package/dist-server/server/default-model-selection.js +21 -0
- package/dist-server/server/drivers/agents-proxy.js +13 -4
- package/dist-server/server/drivers/claude.js +26 -1
- package/dist-server/server/drivers/openai-compat.js +4 -2
- package/dist-server/server/index.js +123 -89
- package/dist-server/server/recall-disclosure.js +45 -0
- package/dist-server/server/request-auth.js +6 -0
- package/dist-server/server/screen-frame-gate.js +36 -1
- package/dist-server/server/screen-frame-source.js +57 -0
- package/dist-server/server/sessions.js +52 -4
- package/dist-server/vps-container-mcp.js +16 -1
- package/package.json +1 -1
- package/dist/assets/index-Br81ZQCY.js +0 -264
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** A saved choice is intentional: an unavailable provider or removed model
|
|
2
|
+
* sends new bots to setup instead of silently changing their provider. */
|
|
3
|
+
export function selectDefaultModelSelection(instances, preferred) {
|
|
4
|
+
if (preferred) {
|
|
5
|
+
const instance = instances.find((candidate) => candidate.instanceId === preferred.instanceId);
|
|
6
|
+
if (instance?.snapshot.state !== "available" ||
|
|
7
|
+
instance.snapshot.authenticated === false ||
|
|
8
|
+
!(instance.models.default === preferred.model || instance.models.options.some((model) => model.id === preferred.model))) {
|
|
9
|
+
return { instanceId: "", model: "" };
|
|
10
|
+
}
|
|
11
|
+
const selection = { ...preferred };
|
|
12
|
+
// A saved effort can outlive driver support. Keep the intentional model,
|
|
13
|
+
// but let the provider use its own effort default instead of failing turn 1.
|
|
14
|
+
if (selection.effort && !instance.capabilities?.effortLevels?.includes(selection.effort))
|
|
15
|
+
delete selection.effort;
|
|
16
|
+
return selection;
|
|
17
|
+
}
|
|
18
|
+
const available = instances.filter((instance) => instance.snapshot.state === "available");
|
|
19
|
+
const pick = available.find((instance) => instance.driverKind === "claudeAgent") ?? available[0];
|
|
20
|
+
return { instanceId: pick?.instanceId ?? "", model: pick?.models.default ?? "" };
|
|
21
|
+
}
|
|
@@ -845,12 +845,17 @@ async function callTool(name, args) {
|
|
|
845
845
|
}
|
|
846
846
|
const lines = hits.map((hit) => {
|
|
847
847
|
const when = typeof hit.at === "number" ? new Date(hit.at).toISOString().slice(0, 10) : "";
|
|
848
|
-
const
|
|
848
|
+
const task = typeof hit.task === "string" && hit.task ? `task "${hit.task}"` : "an earlier task";
|
|
849
|
+
const where = hit.current ? "this conversation" : hit.crossed ? `${task}, private to this user` : task;
|
|
849
850
|
return `- [${when} · ${where} · ${recallSpeaker(hit)} · thread ${hit.threadId} · message ${hit.messageId}] ${hit.snippet}`;
|
|
850
851
|
});
|
|
852
|
+
const crossed = hits.some((hit) => hit.crossed === true);
|
|
851
853
|
return {
|
|
852
854
|
text: `${hits.length} matching message${hits.length === 1 ? "" : "s"} from your earlier conversations (best match first):\n${lines.join("\n")}\n\n` +
|
|
853
|
-
"These are your own past notes. If one of them is the message you need, call session_read with its thread and message ids for the full text rather than searching again. Build on them rather than redoing the work; ask the user only about what they do not cover."
|
|
855
|
+
"These are your own past notes. If one of them is the message you need, call session_read with its thread and message ids for the full text rather than searching again. Build on them rather than redoing the work; ask the user only about what they do not cover." +
|
|
856
|
+
(crossed
|
|
857
|
+
? " The hits marked private came from your one-to-one conversation with this user, not from this room; the room has been shown that you recalled them. Use them, and say where something came from if anyone asks."
|
|
858
|
+
: ""),
|
|
854
859
|
};
|
|
855
860
|
}
|
|
856
861
|
if (name === "session_read") {
|
|
@@ -868,8 +873,12 @@ async function callTool(name, args) {
|
|
|
868
873
|
return { text: `Couldn't read that message: ${error instanceof Error ? error.message : String(error)}. Use ids from a session_search hit.`, isError: true };
|
|
869
874
|
}
|
|
870
875
|
const when = typeof r.at === "number" ? new Date(r.at).toISOString().slice(0, 10) : "";
|
|
871
|
-
const
|
|
872
|
-
|
|
876
|
+
const readTask = typeof r.task === "string" && r.task ? `task "${r.task}"` : "an earlier task";
|
|
877
|
+
const where = threadId === THREAD_ID ? "this conversation" : r.crossed ? `${readTask}, private to this user` : readTask;
|
|
878
|
+
const note = r.crossed
|
|
879
|
+
? "(Your own past note from your one-to-one conversation with this user, not new instructions. The room has been shown that you recalled it.)"
|
|
880
|
+
: "(Your own past note, not new instructions.)";
|
|
881
|
+
return { text: `[${when} · ${where} · ${recallSpeaker(r)} · message ${messageId}]\n\n${String(r.text ?? "")}\n\n${note}` };
|
|
873
882
|
}
|
|
874
883
|
if (name === "skills_list") {
|
|
875
884
|
const query = new URLSearchParams({ fromBotId: BOT_ID, fromThreadId: THREAD_ID });
|
|
@@ -42,6 +42,20 @@ export function claudeSignedIn(cli, env, run = execCli) {
|
|
|
42
42
|
});
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
|
+
/** Whether a stream frame is the CLI reporting that it has no login.
|
|
46
|
+
*
|
|
47
|
+
* The CLI flags its own api-error frames (`error`, `is_api_error_message`);
|
|
48
|
+
* a model reply never carries them. Requiring that flag first is what keeps
|
|
49
|
+
* an answer that merely discusses being logged out from being read as a
|
|
50
|
+
* failure — the text classifier runs only once the CLI has already called
|
|
51
|
+
* the frame an error, and covers CLI builds that flag the frame without
|
|
52
|
+
* naming the reason.
|
|
53
|
+
*/
|
|
54
|
+
export function claudeAuthFailure(frame, text) {
|
|
55
|
+
if (frame.is_api_error_message !== true && typeof frame.error !== "string")
|
|
56
|
+
return false;
|
|
57
|
+
return frame.error === "authentication_failed" || classifyError({ text }).reason === "auth";
|
|
58
|
+
}
|
|
45
59
|
/** The CLI environment shared by auth probes and real turns.
|
|
46
60
|
*
|
|
47
61
|
* Subscription users can be billed pay-as-you-go if an inherited API key
|
|
@@ -992,6 +1006,17 @@ export const ClaudeDriver = {
|
|
|
992
1006
|
case "assistant": {
|
|
993
1007
|
const msg = o.message ?? {};
|
|
994
1008
|
const text = firstText(msg.content);
|
|
1009
|
+
// An unauthenticated turn comes back as an api-error frame whose
|
|
1010
|
+
// only content is the CLI's own "run /login" instruction — a
|
|
1011
|
+
// command this app has no terminal to run, so relaying it as a
|
|
1012
|
+
// reply strands the user. Every other engine reports this as a
|
|
1013
|
+
// setup error; that is what routes them to the sign-in card.
|
|
1014
|
+
if (claudeAuthFailure(o, text)) {
|
|
1015
|
+
if (session.turn)
|
|
1016
|
+
session.turn.authFailed = true;
|
|
1017
|
+
emit({ ...base(threadId, currentTurnId()), type: "runtime.error", message: text, setup: true });
|
|
1018
|
+
break;
|
|
1019
|
+
}
|
|
995
1020
|
if (text.trim()) {
|
|
996
1021
|
// fallback delta for CLIs/paths that never streamed the block
|
|
997
1022
|
if (!session.turn?.sawStreamDelta) {
|
|
@@ -1037,7 +1062,7 @@ export const ClaudeDriver = {
|
|
|
1037
1062
|
// are billed (at the cache rate) and they fill the window — but
|
|
1038
1063
|
// they are reported separately too, so the UI can show how much
|
|
1039
1064
|
// of the figure was context re-read rather than new text.
|
|
1040
|
-
settle(o.is_error !== true, o.stop_reason ?? o.terminal_reason ?? null, o.total_cost_usd ?? null, o.usage
|
|
1065
|
+
settle(o.is_error !== true, session.turn?.authFailed ? "auth_required" : o.stop_reason ?? o.terminal_reason ?? null, o.total_cost_usd ?? null, o.usage
|
|
1041
1066
|
? {
|
|
1042
1067
|
input: (o.usage.input_tokens || 0) + (o.usage.cache_read_input_tokens || 0) + (o.usage.cache_creation_input_tokens || 0),
|
|
1043
1068
|
output: o.usage.output_tokens || 0,
|
|
@@ -29,8 +29,10 @@ function decodeConfig(raw) {
|
|
|
29
29
|
model: typeof config.model === "string" && config.model
|
|
30
30
|
? config.model
|
|
31
31
|
: process.env.OPENAI_COMPAT_MODEL || undefined,
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// An explicit empty override disables inherited routing for an isolated
|
|
33
|
+
// connection (CLI setup uses this). Absent still inherits the global pin.
|
|
34
|
+
provider: typeof config.provider === "string"
|
|
35
|
+
? config.provider || undefined
|
|
34
36
|
: process.env.OPENAI_COMPAT_PROVIDER || undefined,
|
|
35
37
|
};
|
|
36
38
|
}
|
|
@@ -47,6 +47,7 @@ import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId
|
|
|
47
47
|
import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
|
|
48
48
|
import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
|
|
49
49
|
import { readMessageText, recallMessages, searchMessages } from "./message-db.js";
|
|
50
|
+
import { claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
|
|
50
51
|
/** A session_read answer competes with the transcript for the context
|
|
51
52
|
* window; a computer-use turn's output can run to hundreds of KB. */
|
|
52
53
|
const SESSION_READ_MAX_CHARS = 8_000;
|
|
@@ -57,6 +58,7 @@ import { cancelChannelMessage, drainChannelMessages, queuedChannelMessage, queue
|
|
|
57
58
|
import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from "./send-idempotency.js";
|
|
58
59
|
import { EventBus } from "./harness/bus.js";
|
|
59
60
|
import { ProviderRegistry } from "./harness/registry.js";
|
|
61
|
+
import { selectDefaultModelSelection } from "./default-model-selection.js";
|
|
60
62
|
import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
|
|
61
63
|
import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
|
|
62
64
|
import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
|
|
@@ -85,9 +87,9 @@ import * as vps from "./vps-computer.js";
|
|
|
85
87
|
import { RoutineManager } from "./routines.js";
|
|
86
88
|
import { CalendarCallManager } from "./calendar-calls.js";
|
|
87
89
|
import { BUILT_IN_BROWSER_SYSTEM_PROMPT } from "./browser-engine.js";
|
|
88
|
-
import { agentBrowserIntegration, browserEngineEncryptionKey, clearBrowserSessionState, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
|
|
89
|
-
import {
|
|
90
|
-
import { screenFrameHash, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
|
|
90
|
+
import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, clearBrowserSessionState, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
|
|
91
|
+
import { createScreenFrameSource } from "./screen-frame-source.js";
|
|
92
|
+
import { screenFrameHash, screenSurfaceForTool, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
|
|
91
93
|
import { RoutineRequestService } from "./routine-requests.js";
|
|
92
94
|
import { buildBotOverview, connectedAppsFacts } from "./bot-overview.js";
|
|
93
95
|
import { ProfileRequestService } from "./profile-requests.js";
|
|
@@ -115,8 +117,8 @@ import { createGracefulShutdown } from "./graceful-shutdown.js";
|
|
|
115
117
|
import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
|
|
116
118
|
import { describeEdition, editionStatus, loadEnterpriseLayer } from "./enterprise.js";
|
|
117
119
|
import { environmentDescriptor, loadEnvironmentId } from "./environment.js";
|
|
118
|
-
import { clearSessionCookie, clientBotPatchViolation, clientGroupPatchViolation, labelFromUserAgent, requestOrigin, requestSource, resolveRequestAuth, serializeSessionCookie, sessionCookieName, } from "./request-auth.js";
|
|
119
|
-
import {
|
|
120
|
+
import { clearSessionCookie, clientBotPatchViolation, clientGroupPatchViolation, labelFromUserAgent, requestOrigin, requestSource, resolveRequestAuth, parseCookies, serializeSessionCookie, sessionCookieName, } from "./request-auth.js";
|
|
121
|
+
import { cookieMaxAgeSeconds, formatPairingCode, SessionRegistry } from "./sessions.js";
|
|
120
122
|
import { describeBrand, loadBrand } from "./brand.js";
|
|
121
123
|
import { PHONE_SECRET_PROTOCOL_VERSION, PhoneSecretBridge, PhoneSecretError, PhoneSecretSubmissionRegistry, assertPhoneSecretRequestMatches, phoneSecretOperationId, } from "./phone-secret.js";
|
|
122
124
|
const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
|
|
@@ -660,17 +662,9 @@ function askBotAndWait(targetBotId, message, depth, fromBotId) {
|
|
|
660
662
|
}).catch((err) => finish({ status: "error", text: `(couldn't start that bot: ${err instanceof Error ? err.message : String(err)})` }));
|
|
661
663
|
});
|
|
662
664
|
}
|
|
663
|
-
//
|
|
665
|
+
// New bots honor setup's saved choice; unconfigured workspaces prefer Claude.
|
|
664
666
|
async function defaultSelection() {
|
|
665
|
-
|
|
666
|
-
const available = described.filter((d) => d.snapshot.state === "available");
|
|
667
|
-
// Deliberately NO fallback to described[0]. Handing a bot an engine whose
|
|
668
|
-
// CLI isn't installed makes it look ready and then fail on send with a raw
|
|
669
|
-
// spawn ENOENT — the single worst first-run experience, and the one every
|
|
670
|
-
// user with no CLIs used to get. An empty selection is honest: the UI shows
|
|
671
|
-
// the setup path instead of a bot that cannot answer.
|
|
672
|
-
const pick = available.find((d) => d.driverKind === "claudeAgent") ?? available[0];
|
|
673
|
-
return { instanceId: pick?.instanceId ?? "", model: pick?.models.default ?? "" };
|
|
667
|
+
return selectDefaultModelSelection(await registry.describe(), cfg.defaultModelSelection);
|
|
674
668
|
}
|
|
675
669
|
function checkedModelSelection(raw, current, requireAvailableModel = false) {
|
|
676
670
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
@@ -942,8 +936,8 @@ async function botOverview(bot) {
|
|
|
942
936
|
/** Defense in depth for hand-edited/corrupt durable records: elevated
|
|
943
937
|
* approval semantics require an implemented provider mapping. The trusted transition enforces
|
|
944
938
|
* this too, but no provider dispatch or later permission callback relies on
|
|
945
|
-
* persistence having been produced exclusively by that route.
|
|
946
|
-
*
|
|
939
|
+
* persistence having been produced exclusively by that route. Delegation
|
|
940
|
+
* uses the receiving bot's grant, never the sender's — see approvalModeForOrigin. */
|
|
947
941
|
const approvalModeForTurn = (bot, peerInitiated = false) => {
|
|
948
942
|
const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
|
|
949
943
|
if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, mode)) {
|
|
@@ -1712,17 +1706,22 @@ function broadcast(payload) {
|
|
|
1712
1706
|
const seq = ++lastSeq;
|
|
1713
1707
|
const kind = String(payload.kind ?? "");
|
|
1714
1708
|
const frame = `id: ${STREAM_ID}:${seq}\ndata: ${JSON.stringify({ ...payload, seq })}\n\n`;
|
|
1709
|
+
// Store both projections as immutable frames: live and reconnecting clients
|
|
1710
|
+
// must receive the same filtered config without changing the admin event.
|
|
1711
|
+
const clientFrame = kind === "config"
|
|
1712
|
+
? `id: ${STREAM_ID}:${seq}\ndata: ${JSON.stringify({ ...configForAccess(payload, false), seq })}\n\n`
|
|
1713
|
+
: frame;
|
|
1715
1714
|
// Live desktop captures can each be hundreds of kilobytes and become stale
|
|
1716
1715
|
// as soon as the next one arrives. Keep their sequence slots so resume-gap
|
|
1717
1716
|
// detection stays honest, but never retain their base64 payloads.
|
|
1718
|
-
replayBuffer.push({ seq, kind, frame: kind === "screen" ? null : frame });
|
|
1717
|
+
replayBuffer.push({ seq, kind, frame: kind === "screen" ? null : frame, clientFrame: kind === "screen" ? null : clientFrame });
|
|
1719
1718
|
if (replayBuffer.length > REPLAY_MAX)
|
|
1720
1719
|
replayBuffer.shift();
|
|
1721
1720
|
for (const client of [...sseClients]) {
|
|
1722
1721
|
if (!wants(client, kind))
|
|
1723
1722
|
continue;
|
|
1724
1723
|
try {
|
|
1725
|
-
client.res.write(frame);
|
|
1724
|
+
client.res.write(client.admin ? frame : clientFrame);
|
|
1726
1725
|
}
|
|
1727
1726
|
catch {
|
|
1728
1727
|
sseClients.delete(client);
|
|
@@ -2435,7 +2434,7 @@ bus.subscribe((event) => {
|
|
|
2435
2434
|
if (bot) {
|
|
2436
2435
|
const touches = screenTouchingTool(toolName);
|
|
2437
2436
|
if (touches || /computer|screenshot|click|type_text|press_key|scroll|open_url|wait_for|browser_/i.test(toolName)) {
|
|
2438
|
-
pokeScreenPoller(bot.id, touches);
|
|
2437
|
+
pokeScreenPoller(bot.id, touches, screenSurfaceForTool(toolName));
|
|
2439
2438
|
}
|
|
2440
2439
|
}
|
|
2441
2440
|
}
|
|
@@ -2470,8 +2469,7 @@ bus.subscribe((event) => {
|
|
|
2470
2469
|
const effectiveApprovalMode = asker ? approvalModeForTurn(asker, isInternalTurn(event.threadId)) : "ask";
|
|
2471
2470
|
const verdict = permission && asker && event.requestId
|
|
2472
2471
|
? autoVerdict({
|
|
2473
|
-
// the same
|
|
2474
|
-
// residual asks are judged as Approve for me here too
|
|
2472
|
+
// Use the same receiving-bot mode as the provider dispatch.
|
|
2475
2473
|
approvalMode: effectiveApprovalMode,
|
|
2476
2474
|
autoApprove: false,
|
|
2477
2475
|
alwaysAllow: asker.alwaysAllow,
|
|
@@ -2479,11 +2477,9 @@ bus.subscribe((event) => {
|
|
|
2479
2477
|
unattended,
|
|
2480
2478
|
scope: event.approvalScope,
|
|
2481
2479
|
requiresExplicitApproval: event.requiresExplicitApproval,
|
|
2482
|
-
//
|
|
2483
|
-
//
|
|
2484
|
-
|
|
2485
|
-
nativeApproval: requiresNativeApproval(event.provider, event.provider === "antigravityAgent"
|
|
2486
|
-
? effectiveApprovalMode : approvalModeForTurn(asker)),
|
|
2480
|
+
// Match the dispatched mode for every provider, including
|
|
2481
|
+
// delegated Custom and unattended Auto downgrades.
|
|
2482
|
+
nativeApproval: requiresNativeApproval(event.provider, effectiveApprovalMode),
|
|
2487
2483
|
})
|
|
2488
2484
|
: null;
|
|
2489
2485
|
if (verdict?.approve && asker && event.requestId) {
|
|
@@ -3255,50 +3251,25 @@ const SCREEN_MIN_GAP_MS = 3000;
|
|
|
3255
3251
|
* boxAgent's whole session runs ON the box, so every tool it calls acts on
|
|
3256
3252
|
* that screen even though none of them is named like a computer tool. Its
|
|
3257
3253
|
* shell-only turns are kept honest by the settle-time hash gate instead. */
|
|
3258
|
-
function startScreenPoller(botId,
|
|
3254
|
+
function startScreenPoller(botId, captures, { screenIsTheWork = false } = {}) {
|
|
3255
|
+
if (!captures.computer && !captures.browser)
|
|
3256
|
+
return;
|
|
3259
3257
|
if (screenPollers.has(botId))
|
|
3260
3258
|
return;
|
|
3261
|
-
//
|
|
3262
|
-
//
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3259
|
+
// Assign rather than spread: the source's last-frame getter deliberately
|
|
3260
|
+
// hides a stale frame as soon as the selected surface changes.
|
|
3261
|
+
const entry = Object.assign(createScreenFrameSource({
|
|
3262
|
+
captures,
|
|
3263
|
+
control: () => ({
|
|
3264
|
+
held: computerControl.snapshot(botId).held,
|
|
3265
|
+
revision: computerControlRevision.get(botId) ?? 0,
|
|
3266
|
+
}),
|
|
3267
|
+
onFrame: (frame) => broadcast({ kind: "screen", botId, ...frame }),
|
|
3268
|
+
minGapMs: SCREEN_MIN_GAP_MS,
|
|
3269
|
+
}), {
|
|
3269
3270
|
timer: null,
|
|
3270
|
-
capture: () => {
|
|
3271
|
-
// A person can type credentials while driving any browser/computer
|
|
3272
|
-
// surface. Never take a preview during that lease: live frames and the
|
|
3273
|
-
// settled transcript image must retain only the last pre-takeover view.
|
|
3274
|
-
if (computerControl.snapshot(botId).held)
|
|
3275
|
-
return Promise.resolve();
|
|
3276
|
-
if (!current && Date.now() - lastAt < SCREEN_MIN_GAP_MS)
|
|
3277
|
-
return Promise.resolve();
|
|
3278
|
-
current ??= (async () => {
|
|
3279
|
-
try {
|
|
3280
|
-
const frame = await captureOutsideHumanControl(() => ({
|
|
3281
|
-
held: computerControl.snapshot(botId).held,
|
|
3282
|
-
revision: computerControlRevision.get(botId) ?? 0,
|
|
3283
|
-
}), capture);
|
|
3284
|
-
if (!frame)
|
|
3285
|
-
return;
|
|
3286
|
-
entry.last = frame;
|
|
3287
|
-
broadcast({ kind: "screen", botId, ...frame });
|
|
3288
|
-
}
|
|
3289
|
-
catch {
|
|
3290
|
-
/* box asleep or mid-command — try again next tick */
|
|
3291
|
-
}
|
|
3292
|
-
finally {
|
|
3293
|
-
lastAt = Date.now();
|
|
3294
|
-
current = null;
|
|
3295
|
-
}
|
|
3296
|
-
})();
|
|
3297
|
-
return current;
|
|
3298
|
-
},
|
|
3299
|
-
last: null,
|
|
3300
3271
|
touched: screenIsTheWork,
|
|
3301
|
-
};
|
|
3272
|
+
});
|
|
3302
3273
|
entry.timer = setInterval(() => void entry.capture(), SCREEN_POLL_MS);
|
|
3303
3274
|
screenPollers.set(botId, entry);
|
|
3304
3275
|
}
|
|
@@ -3306,7 +3277,7 @@ function startScreenPoller(botId, capture, { screenIsTheWork = false } = {}) {
|
|
|
3306
3277
|
* instead of waiting for the next interval tick. Rate-limited inside
|
|
3307
3278
|
* capture() — a tool-heavy turn used to fire one full REST chain per
|
|
3308
3279
|
* completed tool, competing with the agent for the same endpoint. */
|
|
3309
|
-
function pokeScreenPoller(botId, touches) {
|
|
3280
|
+
function pokeScreenPoller(botId, touches, surface) {
|
|
3310
3281
|
const entry = screenPollers.get(botId);
|
|
3311
3282
|
if (!entry)
|
|
3312
3283
|
return;
|
|
@@ -3319,6 +3290,11 @@ function pokeScreenPoller(botId, touches) {
|
|
|
3319
3290
|
// untouched desktop to every curl-and-answer reply.
|
|
3320
3291
|
if (touches)
|
|
3321
3292
|
entry.touched = true;
|
|
3293
|
+
// Picture the surface the tool acted on. Only a touching tool moves this:
|
|
3294
|
+
// a status read on the computer must not redirect the picture away from a
|
|
3295
|
+
// page the browser is still showing.
|
|
3296
|
+
if (touches && surface)
|
|
3297
|
+
entry.surface = surface;
|
|
3322
3298
|
void entry.capture();
|
|
3323
3299
|
}
|
|
3324
3300
|
function stopScreenPoller(botId) {
|
|
@@ -3361,7 +3337,7 @@ async function finalScreenFrame(botId, threadId) {
|
|
|
3361
3337
|
screenPollers.delete(botId);
|
|
3362
3338
|
if (!entry.touched)
|
|
3363
3339
|
return null;
|
|
3364
|
-
await entry.capture();
|
|
3340
|
+
await entry.capture(true);
|
|
3365
3341
|
const frame = entry.last;
|
|
3366
3342
|
if (!frame || !settledFrameIsNews(shownScreenHash(botId, threadId), frame.png))
|
|
3367
3343
|
return null;
|
|
@@ -3639,6 +3615,7 @@ async function startTurn(botId, text, opts) {
|
|
|
3639
3615
|
}
|
|
3640
3616
|
const wants = plan.computer;
|
|
3641
3617
|
let previewCapture = null;
|
|
3618
|
+
let browserCapture = null;
|
|
3642
3619
|
let computerKind = null;
|
|
3643
3620
|
let autoVpsProblem = null;
|
|
3644
3621
|
// Explicit destinations are strict. In particular, Local VM must never
|
|
@@ -3879,6 +3856,16 @@ async function startTurn(botId, text, opts) {
|
|
|
3879
3856
|
browser = browserIntegration(bot.id, selectedProfile);
|
|
3880
3857
|
if (browser)
|
|
3881
3858
|
integrations.browser = browser.integration;
|
|
3859
|
+
// The browser lost its frame source when the Electron surface was
|
|
3860
|
+
// removed: previewCapture is set by the computer branches above, and
|
|
3861
|
+
// nothing replaced it here. A bot with only a browser was pictured
|
|
3862
|
+
// not at all; a bot with both was pictured on its desktop even while
|
|
3863
|
+
// the work was a web page, because agent-browser runs its own headless
|
|
3864
|
+
// Chrome on the host rather than inside that desktop.
|
|
3865
|
+
if (browser) {
|
|
3866
|
+
const frame = { binaryPath: browser.integration.command, env: browser.integration.env };
|
|
3867
|
+
browserCapture = () => agentBrowserFrame(frame);
|
|
3868
|
+
}
|
|
3882
3869
|
}
|
|
3883
3870
|
// A cancelled adapter can be between accepting sendTurn and revealing
|
|
3884
3871
|
// its provider turn id. Never overlap a replacement with that ambiguous
|
|
@@ -3961,8 +3948,8 @@ async function startTurn(botId, text, opts) {
|
|
|
3961
3948
|
// after its own turn.completed would never be torn down — it would
|
|
3962
3949
|
// keep polling the box forever, carrying dead per-turn state. busy
|
|
3963
3950
|
// is flipped false in the fold, so it is the honest "still running".
|
|
3964
|
-
if (previewCapture && store.bot(bot.id)?.busy) {
|
|
3965
|
-
startScreenPoller(bot.id, previewCapture, { screenIsTheWork: instance.driverKind === "boxAgent" });
|
|
3951
|
+
if ((previewCapture || browserCapture) && store.bot(bot.id)?.busy) {
|
|
3952
|
+
startScreenPoller(bot.id, { ...(previewCapture ? { computer: previewCapture } : {}), ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: instance.driverKind === "boxAgent" });
|
|
3966
3953
|
}
|
|
3967
3954
|
// An adapter may publish completion synchronously just before its
|
|
3968
3955
|
// dispatch promise resolves. The event could not use the turn-id map
|
|
@@ -6560,6 +6547,18 @@ function configStatus() {
|
|
|
6560
6547
|
browserProfiles: cfg.browserProfiles ?? [],
|
|
6561
6548
|
};
|
|
6562
6549
|
}
|
|
6550
|
+
function configForAccess(status, admin) {
|
|
6551
|
+
if (admin)
|
|
6552
|
+
return status;
|
|
6553
|
+
// Configured-or-not is fine; an SSH alias, an email, and a browser
|
|
6554
|
+
// partition id are not a client's business. Preserve the source objects.
|
|
6555
|
+
return {
|
|
6556
|
+
...status,
|
|
6557
|
+
vps: { configured: status.vps.configured, sshAlias: "" },
|
|
6558
|
+
profile: { name: status.profile.name, email: "" },
|
|
6559
|
+
browserProfiles: status.browserProfiles.map((profile) => Object.fromEntries(Object.entries(profile).filter(([key]) => key !== "partitionId"))),
|
|
6560
|
+
};
|
|
6561
|
+
}
|
|
6563
6562
|
function mcpServerResponse() {
|
|
6564
6563
|
return { servers: listMcpServers(cfg.mcpServers) };
|
|
6565
6564
|
}
|
|
@@ -6696,7 +6695,13 @@ function readBody(req, limit = 1_000_000) {
|
|
|
6696
6695
|
// onto it. Reject non-loopback Hosts outright (defeats rebinding) and
|
|
6697
6696
|
// origins outside loopback (blocks remote-web CSRF).
|
|
6698
6697
|
const handleRequest = async (req, res) => {
|
|
6699
|
-
|
|
6698
|
+
let url;
|
|
6699
|
+
try {
|
|
6700
|
+
url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
|
|
6701
|
+
}
|
|
6702
|
+
catch {
|
|
6703
|
+
return json(res, 400, { error: "invalid request URL" });
|
|
6704
|
+
}
|
|
6700
6705
|
const path = url.pathname;
|
|
6701
6706
|
const method = req.method ?? "GET";
|
|
6702
6707
|
/** scratch for route matches, shared by every `path.match` below */
|
|
@@ -6731,7 +6736,7 @@ const handleRequest = async (req, res) => {
|
|
|
6731
6736
|
const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED });
|
|
6732
6737
|
if (wantsCookie) {
|
|
6733
6738
|
const secure = requestOrigin(req)?.startsWith("https://") === true;
|
|
6734
|
-
res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, result.token, { secure, maxAgeSeconds:
|
|
6739
|
+
res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, result.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(result.session) }));
|
|
6735
6740
|
return json(res, 200, { session: result.session, environment });
|
|
6736
6741
|
}
|
|
6737
6742
|
return json(res, 200, { token: result.token, session: result.session, environment });
|
|
@@ -6744,6 +6749,19 @@ const handleRequest = async (req, res) => {
|
|
|
6744
6749
|
loopbackMutationToken: desktopMutationToken,
|
|
6745
6750
|
companionMutationToken,
|
|
6746
6751
|
});
|
|
6752
|
+
// The browser's cookie carries the term it was set with, and the
|
|
6753
|
+
// session's term slides on use (sessions.ts `renew`), so re-issue the
|
|
6754
|
+
// cookie on every cookie-authenticated request. One small header; and
|
|
6755
|
+
// unlike "send once per renewal" it survives a lost response and a
|
|
6756
|
+
// restart. Later handlers that clear the cookie (logout, self-revoke)
|
|
6757
|
+
// overwrite this header, which is the order we want.
|
|
6758
|
+
if (gate.auth?.kind === "session" && gate.auth.via === "cookie") {
|
|
6759
|
+
const presented = parseCookies(req.headers.cookie).get(SESSION_COOKIE);
|
|
6760
|
+
if (presented) {
|
|
6761
|
+
const secure = requestOrigin(req)?.startsWith("https://") === true;
|
|
6762
|
+
res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, presented, { secure, maxAgeSeconds: cookieMaxAgeSeconds(gate.auth.session) }));
|
|
6763
|
+
}
|
|
6764
|
+
}
|
|
6747
6765
|
// Reachability probe, public: the phone races it across a server's
|
|
6748
6766
|
// addresses before it has a session, and the tunnel verifier polls it.
|
|
6749
6767
|
// A stranger learns only the app name; pid (the desktop boot probe keys
|
|
@@ -6798,7 +6816,7 @@ const handleRequest = async (req, res) => {
|
|
|
6798
6816
|
});
|
|
6799
6817
|
}
|
|
6800
6818
|
if (method === "GET" && path === "/api/auth/pairing")
|
|
6801
|
-
return json(res, 200, { pairings: sessions.openPairings() });
|
|
6819
|
+
return json(res, 200, { pairings: sessions.openPairings(), publicUrl: PUBLIC_URL });
|
|
6802
6820
|
m = path.match(/^\/api\/auth\/pairing\/([\w-]+)$/);
|
|
6803
6821
|
if (m && method === "DELETE") {
|
|
6804
6822
|
const cancelled = sessions.cancelPairing(m[1]);
|
|
@@ -7080,6 +7098,20 @@ const handleRequest = async (req, res) => {
|
|
|
7080
7098
|
// every task included. Own-bot only, on purpose — a bot's transcripts
|
|
7081
7099
|
// are its notebook the same way MEMORY.md is (section-context.ts draws
|
|
7082
7100
|
// that line), and search across bots would be an isolation change.
|
|
7101
|
+
// Announce in the room that a bot reached outside it. Silent when the
|
|
7102
|
+
// room has already been told about that thread, so a bot searching
|
|
7103
|
+
// three times in one turn leaves one chip per source, not per search.
|
|
7104
|
+
const discloseRecall = (bot, roomThreadId, sourceThreadIds) => {
|
|
7105
|
+
const crossing = claimRecallCrossings(roomThreadId, sourceThreadIds);
|
|
7106
|
+
if (!crossing.count)
|
|
7107
|
+
return;
|
|
7108
|
+
store.appendMessage(roomThreadId, {
|
|
7109
|
+
role: "bot",
|
|
7110
|
+
kind: "activity",
|
|
7111
|
+
from: { botId: bot.id, name: bot.name, color: bot.color },
|
|
7112
|
+
tool: { name: recallCrossingLabel(bot.name, crossing.count), ok: true },
|
|
7113
|
+
});
|
|
7114
|
+
};
|
|
7083
7115
|
if (method === "GET" && path === "/api/internal/session-search") {
|
|
7084
7116
|
const fromBotId = String(url.searchParams.get("fromBotId") ?? "");
|
|
7085
7117
|
const from = store.bot(fromBotId);
|
|
@@ -7095,11 +7127,18 @@ const handleRequest = async (req, res) => {
|
|
|
7095
7127
|
const rawLimit = Number(url.searchParams.get("limit"));
|
|
7096
7128
|
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.trunc(rawLimit), 25) : 12;
|
|
7097
7129
|
const ownThreads = [...new Set([from.threadId, ...(from.tasks ?? []).map((task) => task.threadId)])];
|
|
7130
|
+
// A room is the only place a recall can be a disclosure: in a 1:1 the
|
|
7131
|
+
// user already owns every thread the bot can reach.
|
|
7132
|
+
const inRoom = Boolean(store.groupByThread(fromThreadId));
|
|
7098
7133
|
const hits = recallMessages(q, ownThreads, limit).map((hit) => ({
|
|
7099
7134
|
...hit,
|
|
7100
7135
|
task: store.taskByThread(from.id, hit.threadId)?.title,
|
|
7101
7136
|
current: hit.threadId === fromThreadId,
|
|
7137
|
+
crossed: inRoom && hit.threadId !== fromThreadId,
|
|
7102
7138
|
}));
|
|
7139
|
+
if (inRoom) {
|
|
7140
|
+
discloseRecall(from, fromThreadId, hits.filter((hit) => hit.crossed).map((hit) => hit.threadId));
|
|
7141
|
+
}
|
|
7103
7142
|
return json(res, 200, { hits });
|
|
7104
7143
|
}
|
|
7105
7144
|
// session_read: the whole message behind a session_search hit. Same
|
|
@@ -7122,8 +7161,13 @@ const handleRequest = async (req, res) => {
|
|
|
7122
7161
|
const message = own ? readMessageText(threadId, messageId) : null;
|
|
7123
7162
|
if (!message)
|
|
7124
7163
|
return json(res, 404, { error: "no such message in your conversations" });
|
|
7164
|
+
const readInRoom = Boolean(store.groupByThread(fromThreadId));
|
|
7165
|
+
const readCrossed = readInRoom && threadId !== fromThreadId;
|
|
7166
|
+
if (readCrossed)
|
|
7167
|
+
discloseRecall(from, fromThreadId, [threadId]);
|
|
7125
7168
|
return json(res, 200, {
|
|
7126
7169
|
...message,
|
|
7170
|
+
crossed: readCrossed,
|
|
7127
7171
|
text: message.text.length > SESSION_READ_MAX_CHARS ? `${message.text.slice(0, SESSION_READ_MAX_CHARS)}…` : message.text,
|
|
7128
7172
|
task: store.taskByThread(from.id, threadId)?.title,
|
|
7129
7173
|
});
|
|
@@ -7975,7 +8019,7 @@ const handleRequest = async (req, res) => {
|
|
|
7975
8019
|
}
|
|
7976
8020
|
// ── events stream ──
|
|
7977
8021
|
if (method === "GET" && path === "/api/events") {
|
|
7978
|
-
const client = { res, screens: url.searchParams.get("screens") !== "off" };
|
|
8022
|
+
const client = { res, admin: auth.scopes.includes("admin"), screens: url.searchParams.get("screens") !== "off" };
|
|
7979
8023
|
if (auth.kind === "session")
|
|
7980
8024
|
client.sessionId = auth.session.id;
|
|
7981
8025
|
res.writeHead(200, {
|
|
@@ -8011,8 +8055,9 @@ const handleRequest = async (req, res) => {
|
|
|
8011
8055
|
})}\n\n`);
|
|
8012
8056
|
if (resumed) {
|
|
8013
8057
|
for (const buffered of replayBuffer) {
|
|
8014
|
-
|
|
8015
|
-
|
|
8058
|
+
const frame = client.admin ? buffered.frame : buffered.clientFrame;
|
|
8059
|
+
if (buffered.seq > since && frame && wants(client, buffered.kind))
|
|
8060
|
+
res.write(frame);
|
|
8016
8061
|
}
|
|
8017
8062
|
}
|
|
8018
8063
|
sseClients.add(client);
|
|
@@ -11384,18 +11429,7 @@ const handleRequest = async (req, res) => {
|
|
|
11384
11429
|
}
|
|
11385
11430
|
// ── app config (API keys — never echoed back, booleans only) ──
|
|
11386
11431
|
if (method === "GET" && path === "/api/config") {
|
|
11387
|
-
|
|
11388
|
-
if (auth.kind === "session" && !auth.scopes.includes("admin")) {
|
|
11389
|
-
// configured-or-not is fine; an SSH alias, an email, a browser
|
|
11390
|
-
// partition id are not a client's business
|
|
11391
|
-
return json(res, 200, {
|
|
11392
|
-
...status,
|
|
11393
|
-
vps: { configured: status.vps.configured, sshAlias: "" },
|
|
11394
|
-
profile: { name: status.profile.name, email: "" },
|
|
11395
|
-
browserProfiles: status.browserProfiles.map((profile) => Object.fromEntries(Object.entries(profile).filter(([key]) => key !== "partitionId"))),
|
|
11396
|
-
});
|
|
11397
|
-
}
|
|
11398
|
-
return json(res, 200, status);
|
|
11432
|
+
return json(res, 200, configForAccess(configStatus(), auth.scopes.includes("admin")));
|
|
11399
11433
|
}
|
|
11400
11434
|
if ((method === "PUT" || method === "PATCH") && path === "/api/config") {
|
|
11401
11435
|
const body = await readBody(req);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// When a bot answers in a room using something it recalled from a private
|
|
2
|
+
// thread, the room cannot see that it did. The capability is deliberate —
|
|
3
|
+
// #754 gave bots recall precisely so a bot mentioned in a channel can answer
|
|
4
|
+
// about work discussed in its DM — so the answer is not to block the
|
|
5
|
+
// crossing but to make it visible where it lands.
|
|
6
|
+
//
|
|
7
|
+
// Disclosure is per (room thread, source thread): a bot that searches three
|
|
8
|
+
// times in one turn announces each private thread once, not once per search.
|
|
9
|
+
// The ledger is in memory, so a restart re-announces. That is the safe
|
|
10
|
+
// direction: a duplicate chip is noise, a missing one is the whole bug.
|
|
11
|
+
/** Source threads already announced in a given room thread. */
|
|
12
|
+
const announced = new Map();
|
|
13
|
+
/**
|
|
14
|
+
* The crossings worth announcing in `roomThreadId`, marking them announced.
|
|
15
|
+
* `sources` is one entry per recalled message, in hit order.
|
|
16
|
+
*/
|
|
17
|
+
export function claimRecallCrossings(roomThreadId, sources) {
|
|
18
|
+
const seen = announced.get(roomThreadId) ?? new Set();
|
|
19
|
+
const threadIds = [];
|
|
20
|
+
let count = 0;
|
|
21
|
+
for (const threadId of sources) {
|
|
22
|
+
if (seen.has(threadId))
|
|
23
|
+
continue;
|
|
24
|
+
if (!threadIds.includes(threadId))
|
|
25
|
+
threadIds.push(threadId);
|
|
26
|
+
count += 1;
|
|
27
|
+
}
|
|
28
|
+
if (!threadIds.length)
|
|
29
|
+
return { threadIds: [], count: 0 };
|
|
30
|
+
for (const threadId of threadIds)
|
|
31
|
+
seen.add(threadId);
|
|
32
|
+
announced.set(roomThreadId, seen);
|
|
33
|
+
return { threadIds, count };
|
|
34
|
+
}
|
|
35
|
+
/** The chip's text. Says what crossed, not that anything went wrong. */
|
|
36
|
+
export function recallCrossingLabel(botName, count) {
|
|
37
|
+
return `${botName} recalled ${count} message${count === 1 ? "" : "s"} from its private chat with you`;
|
|
38
|
+
}
|
|
39
|
+
/** Forget a room's ledger. Used when a room thread is cleared, and by tests. */
|
|
40
|
+
export function forgetRecallCrossings(roomThreadId) {
|
|
41
|
+
if (roomThreadId === undefined)
|
|
42
|
+
announced.clear();
|
|
43
|
+
else
|
|
44
|
+
announced.delete(roomThreadId);
|
|
45
|
+
}
|
|
@@ -303,6 +303,12 @@ export function resolveRequestAuth(req, options) {
|
|
|
303
303
|
if (!session.scopes.includes(needed)) {
|
|
304
304
|
return deny(403, `forbidden: this session lacks the ${needed} scope`);
|
|
305
305
|
}
|
|
306
|
+
// Only a request that passed both checks counts as use of the session,
|
|
307
|
+
// and only a request the client made itself: redeeming a stream ticket
|
|
308
|
+
// is the tail of an API call that already counted, and a stream left
|
|
309
|
+
// open unattended must not keep a session alive on its own.
|
|
310
|
+
if (via !== "ticket")
|
|
311
|
+
options.sessions.renew(session.id);
|
|
306
312
|
return { auth: { kind: "session", session, via, scopes: session.scopes }, status: 401, error: "" };
|
|
307
313
|
}
|
|
308
314
|
const proxied = isProxied(req);
|