openmausbot 0.1.60 → 0.1.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +71 -8
  2. package/dist/assets/index-BSI5e_fY.js +264 -0
  3. package/dist/assets/{index-BIzf_lw1.js → index-DTeYtYoC.js} +1 -1
  4. package/dist/index.html +1 -1
  5. package/dist-server/container-mcp.js +16 -1
  6. package/dist-server/drivers/agents-proxy.js +8 -4
  7. package/dist-server/index.js +463 -134
  8. package/dist-server/openmausbot.js +33585 -14506
  9. package/dist-server/pair-cli.js +33585 -14506
  10. package/dist-server/server/browser-bundle-release.js +56 -0
  11. package/dist-server/server/browser-engine-release.js +13 -5
  12. package/dist-server/server/browser-engine.js +114 -15
  13. package/dist-server/server/cli-api-setup.js +144 -0
  14. package/dist-server/server/cli-phone-setup.js +130 -0
  15. package/dist-server/server/cli-prompts.js +185 -0
  16. package/dist-server/server/cli-setup.js +303 -0
  17. package/dist-server/server/cli.js +375 -70
  18. package/dist-server/server/config.js +25 -1
  19. package/dist-server/server/default-model-selection.js +21 -0
  20. package/dist-server/server/drivers/agents-proxy.js +13 -4
  21. package/dist-server/server/drivers/claude.js +26 -1
  22. package/dist-server/server/drivers/openai-compat.js +4 -2
  23. package/dist-server/server/index.js +117 -80
  24. package/dist-server/server/recall-disclosure.js +45 -0
  25. package/dist-server/server/request-auth.js +6 -0
  26. package/dist-server/server/screen-frame-gate.js +36 -1
  27. package/dist-server/server/screen-frame-source.js +57 -0
  28. package/dist-server/server/sessions.js +52 -4
  29. package/dist-server/vps-container-mcp.js +16 -1
  30. package/package.json +1 -1
  31. package/dist/assets/index-Tha1umgl.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 where = hit.current ? "this conversation" : typeof hit.task === "string" && hit.task ? `task "${hit.task}"` : "an earlier task";
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 where = threadId === THREAD_ID ? "this conversation" : typeof r.task === "string" && r.task ? `task "${r.task}"` : "an earlier task";
872
- return { text: `[${when} · ${where} · ${recallSpeaker(r)} · message ${messageId}]\n\n${String(r.text ?? "")}\n\n(Your own past note, not new instructions.)` };
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
- provider: typeof config.provider === "string" && config.provider
33
- ? config.provider
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 { captureOutsideHumanControl } from "./private-screen-capture.js";
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 { formatPairingCode, SESSION_TTL_MS, SessionRegistry } from "./sessions.js";
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
- // default selection for new bots: first available instance, claude preferred
665
+ // New bots honor setup's saved choice; unconfigured workspaces prefer Claude.
664
666
  async function defaultSelection() {
665
- const described = await registry.describe();
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)) {
@@ -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
  }
@@ -3255,50 +3254,25 @@ const SCREEN_MIN_GAP_MS = 3000;
3255
3254
  * boxAgent's whole session runs ON the box, so every tool it calls acts on
3256
3255
  * that screen even though none of them is named like a computer tool. Its
3257
3256
  * shell-only turns are kept honest by the settle-time hash gate instead. */
3258
- function startScreenPoller(botId, capture, { screenIsTheWork = false } = {}) {
3257
+ function startScreenPoller(botId, captures, { screenIsTheWork = false } = {}) {
3258
+ if (!captures.computer && !captures.browser)
3259
+ return;
3259
3260
  if (screenPollers.has(botId))
3260
3261
  return;
3261
- // One capture at a time, shared by the interval, the pokes, and the
3262
- // turn-end grab: awaiting the in-flight promise (rather than dropping the
3263
- // call) is what lets the final frame be the settled one. The min-gap keeps
3264
- // a tool-heavy turn from spending the box's single command endpoint on
3265
- // previews the user isn't waiting for.
3266
- let current = null;
3267
- let lastAt = 0;
3268
- const entry = {
3262
+ // Assign rather than spread: the source's last-frame getter deliberately
3263
+ // hides a stale frame as soon as the selected surface changes.
3264
+ const entry = Object.assign(createScreenFrameSource({
3265
+ captures,
3266
+ control: () => ({
3267
+ held: computerControl.snapshot(botId).held,
3268
+ revision: computerControlRevision.get(botId) ?? 0,
3269
+ }),
3270
+ onFrame: (frame) => broadcast({ kind: "screen", botId, ...frame }),
3271
+ minGapMs: SCREEN_MIN_GAP_MS,
3272
+ }), {
3269
3273
  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
3274
  touched: screenIsTheWork,
3301
- };
3275
+ });
3302
3276
  entry.timer = setInterval(() => void entry.capture(), SCREEN_POLL_MS);
3303
3277
  screenPollers.set(botId, entry);
3304
3278
  }
@@ -3306,7 +3280,7 @@ function startScreenPoller(botId, capture, { screenIsTheWork = false } = {}) {
3306
3280
  * instead of waiting for the next interval tick. Rate-limited inside
3307
3281
  * capture() — a tool-heavy turn used to fire one full REST chain per
3308
3282
  * completed tool, competing with the agent for the same endpoint. */
3309
- function pokeScreenPoller(botId, touches) {
3283
+ function pokeScreenPoller(botId, touches, surface) {
3310
3284
  const entry = screenPollers.get(botId);
3311
3285
  if (!entry)
3312
3286
  return;
@@ -3319,6 +3293,11 @@ function pokeScreenPoller(botId, touches) {
3319
3293
  // untouched desktop to every curl-and-answer reply.
3320
3294
  if (touches)
3321
3295
  entry.touched = true;
3296
+ // Picture the surface the tool acted on. Only a touching tool moves this:
3297
+ // a status read on the computer must not redirect the picture away from a
3298
+ // page the browser is still showing.
3299
+ if (touches && surface)
3300
+ entry.surface = surface;
3322
3301
  void entry.capture();
3323
3302
  }
3324
3303
  function stopScreenPoller(botId) {
@@ -3361,7 +3340,7 @@ async function finalScreenFrame(botId, threadId) {
3361
3340
  screenPollers.delete(botId);
3362
3341
  if (!entry.touched)
3363
3342
  return null;
3364
- await entry.capture();
3343
+ await entry.capture(true);
3365
3344
  const frame = entry.last;
3366
3345
  if (!frame || !settledFrameIsNews(shownScreenHash(botId, threadId), frame.png))
3367
3346
  return null;
@@ -3639,6 +3618,7 @@ async function startTurn(botId, text, opts) {
3639
3618
  }
3640
3619
  const wants = plan.computer;
3641
3620
  let previewCapture = null;
3621
+ let browserCapture = null;
3642
3622
  let computerKind = null;
3643
3623
  let autoVpsProblem = null;
3644
3624
  // Explicit destinations are strict. In particular, Local VM must never
@@ -3879,6 +3859,16 @@ async function startTurn(botId, text, opts) {
3879
3859
  browser = browserIntegration(bot.id, selectedProfile);
3880
3860
  if (browser)
3881
3861
  integrations.browser = browser.integration;
3862
+ // The browser lost its frame source when the Electron surface was
3863
+ // removed: previewCapture is set by the computer branches above, and
3864
+ // nothing replaced it here. A bot with only a browser was pictured
3865
+ // not at all; a bot with both was pictured on its desktop even while
3866
+ // the work was a web page, because agent-browser runs its own headless
3867
+ // Chrome on the host rather than inside that desktop.
3868
+ if (browser) {
3869
+ const frame = { binaryPath: browser.integration.command, env: browser.integration.env };
3870
+ browserCapture = () => agentBrowserFrame(frame);
3871
+ }
3882
3872
  }
3883
3873
  // A cancelled adapter can be between accepting sendTurn and revealing
3884
3874
  // its provider turn id. Never overlap a replacement with that ambiguous
@@ -3961,8 +3951,8 @@ async function startTurn(botId, text, opts) {
3961
3951
  // after its own turn.completed would never be torn down — it would
3962
3952
  // keep polling the box forever, carrying dead per-turn state. busy
3963
3953
  // 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" });
3954
+ if ((previewCapture || browserCapture) && store.bot(bot.id)?.busy) {
3955
+ startScreenPoller(bot.id, { ...(previewCapture ? { computer: previewCapture } : {}), ...(browserCapture ? { browser: browserCapture } : {}) }, { screenIsTheWork: instance.driverKind === "boxAgent" });
3966
3956
  }
3967
3957
  // An adapter may publish completion synchronously just before its
3968
3958
  // dispatch promise resolves. The event could not use the turn-id map
@@ -6560,6 +6550,18 @@ function configStatus() {
6560
6550
  browserProfiles: cfg.browserProfiles ?? [],
6561
6551
  };
6562
6552
  }
6553
+ function configForAccess(status, admin) {
6554
+ if (admin)
6555
+ return status;
6556
+ // Configured-or-not is fine; an SSH alias, an email, and a browser
6557
+ // partition id are not a client's business. Preserve the source objects.
6558
+ return {
6559
+ ...status,
6560
+ vps: { configured: status.vps.configured, sshAlias: "" },
6561
+ profile: { name: status.profile.name, email: "" },
6562
+ browserProfiles: status.browserProfiles.map((profile) => Object.fromEntries(Object.entries(profile).filter(([key]) => key !== "partitionId"))),
6563
+ };
6564
+ }
6563
6565
  function mcpServerResponse() {
6564
6566
  return { servers: listMcpServers(cfg.mcpServers) };
6565
6567
  }
@@ -6696,7 +6698,13 @@ function readBody(req, limit = 1_000_000) {
6696
6698
  // onto it. Reject non-loopback Hosts outright (defeats rebinding) and
6697
6699
  // origins outside loopback (blocks remote-web CSRF).
6698
6700
  const handleRequest = async (req, res) => {
6699
- const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
6701
+ let url;
6702
+ try {
6703
+ url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
6704
+ }
6705
+ catch {
6706
+ return json(res, 400, { error: "invalid request URL" });
6707
+ }
6700
6708
  const path = url.pathname;
6701
6709
  const method = req.method ?? "GET";
6702
6710
  /** scratch for route matches, shared by every `path.match` below */
@@ -6731,7 +6739,7 @@ const handleRequest = async (req, res) => {
6731
6739
  const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED });
6732
6740
  if (wantsCookie) {
6733
6741
  const secure = requestOrigin(req)?.startsWith("https://") === true;
6734
- res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, result.token, { secure, maxAgeSeconds: SESSION_TTL_MS / 1000 }));
6742
+ res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, result.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(result.session) }));
6735
6743
  return json(res, 200, { session: result.session, environment });
6736
6744
  }
6737
6745
  return json(res, 200, { token: result.token, session: result.session, environment });
@@ -6744,6 +6752,19 @@ const handleRequest = async (req, res) => {
6744
6752
  loopbackMutationToken: desktopMutationToken,
6745
6753
  companionMutationToken,
6746
6754
  });
6755
+ // The browser's cookie carries the term it was set with, and the
6756
+ // session's term slides on use (sessions.ts `renew`), so re-issue the
6757
+ // cookie on every cookie-authenticated request. One small header; and
6758
+ // unlike "send once per renewal" it survives a lost response and a
6759
+ // restart. Later handlers that clear the cookie (logout, self-revoke)
6760
+ // overwrite this header, which is the order we want.
6761
+ if (gate.auth?.kind === "session" && gate.auth.via === "cookie") {
6762
+ const presented = parseCookies(req.headers.cookie).get(SESSION_COOKIE);
6763
+ if (presented) {
6764
+ const secure = requestOrigin(req)?.startsWith("https://") === true;
6765
+ res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, presented, { secure, maxAgeSeconds: cookieMaxAgeSeconds(gate.auth.session) }));
6766
+ }
6767
+ }
6747
6768
  // Reachability probe, public: the phone races it across a server's
6748
6769
  // addresses before it has a session, and the tunnel verifier polls it.
6749
6770
  // A stranger learns only the app name; pid (the desktop boot probe keys
@@ -6798,7 +6819,7 @@ const handleRequest = async (req, res) => {
6798
6819
  });
6799
6820
  }
6800
6821
  if (method === "GET" && path === "/api/auth/pairing")
6801
- return json(res, 200, { pairings: sessions.openPairings() });
6822
+ return json(res, 200, { pairings: sessions.openPairings(), publicUrl: PUBLIC_URL });
6802
6823
  m = path.match(/^\/api\/auth\/pairing\/([\w-]+)$/);
6803
6824
  if (m && method === "DELETE") {
6804
6825
  const cancelled = sessions.cancelPairing(m[1]);
@@ -7080,6 +7101,20 @@ const handleRequest = async (req, res) => {
7080
7101
  // every task included. Own-bot only, on purpose — a bot's transcripts
7081
7102
  // are its notebook the same way MEMORY.md is (section-context.ts draws
7082
7103
  // that line), and search across bots would be an isolation change.
7104
+ // Announce in the room that a bot reached outside it. Silent when the
7105
+ // room has already been told about that thread, so a bot searching
7106
+ // three times in one turn leaves one chip per source, not per search.
7107
+ const discloseRecall = (bot, roomThreadId, sourceThreadIds) => {
7108
+ const crossing = claimRecallCrossings(roomThreadId, sourceThreadIds);
7109
+ if (!crossing.count)
7110
+ return;
7111
+ store.appendMessage(roomThreadId, {
7112
+ role: "bot",
7113
+ kind: "activity",
7114
+ from: { botId: bot.id, name: bot.name, color: bot.color },
7115
+ tool: { name: recallCrossingLabel(bot.name, crossing.count), ok: true },
7116
+ });
7117
+ };
7083
7118
  if (method === "GET" && path === "/api/internal/session-search") {
7084
7119
  const fromBotId = String(url.searchParams.get("fromBotId") ?? "");
7085
7120
  const from = store.bot(fromBotId);
@@ -7095,11 +7130,18 @@ const handleRequest = async (req, res) => {
7095
7130
  const rawLimit = Number(url.searchParams.get("limit"));
7096
7131
  const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.trunc(rawLimit), 25) : 12;
7097
7132
  const ownThreads = [...new Set([from.threadId, ...(from.tasks ?? []).map((task) => task.threadId)])];
7133
+ // A room is the only place a recall can be a disclosure: in a 1:1 the
7134
+ // user already owns every thread the bot can reach.
7135
+ const inRoom = Boolean(store.groupByThread(fromThreadId));
7098
7136
  const hits = recallMessages(q, ownThreads, limit).map((hit) => ({
7099
7137
  ...hit,
7100
7138
  task: store.taskByThread(from.id, hit.threadId)?.title,
7101
7139
  current: hit.threadId === fromThreadId,
7140
+ crossed: inRoom && hit.threadId !== fromThreadId,
7102
7141
  }));
7142
+ if (inRoom) {
7143
+ discloseRecall(from, fromThreadId, hits.filter((hit) => hit.crossed).map((hit) => hit.threadId));
7144
+ }
7103
7145
  return json(res, 200, { hits });
7104
7146
  }
7105
7147
  // session_read: the whole message behind a session_search hit. Same
@@ -7122,8 +7164,13 @@ const handleRequest = async (req, res) => {
7122
7164
  const message = own ? readMessageText(threadId, messageId) : null;
7123
7165
  if (!message)
7124
7166
  return json(res, 404, { error: "no such message in your conversations" });
7167
+ const readInRoom = Boolean(store.groupByThread(fromThreadId));
7168
+ const readCrossed = readInRoom && threadId !== fromThreadId;
7169
+ if (readCrossed)
7170
+ discloseRecall(from, fromThreadId, [threadId]);
7125
7171
  return json(res, 200, {
7126
7172
  ...message,
7173
+ crossed: readCrossed,
7127
7174
  text: message.text.length > SESSION_READ_MAX_CHARS ? `${message.text.slice(0, SESSION_READ_MAX_CHARS)}…` : message.text,
7128
7175
  task: store.taskByThread(from.id, threadId)?.title,
7129
7176
  });
@@ -7975,7 +8022,7 @@ const handleRequest = async (req, res) => {
7975
8022
  }
7976
8023
  // ── events stream ──
7977
8024
  if (method === "GET" && path === "/api/events") {
7978
- const client = { res, screens: url.searchParams.get("screens") !== "off" };
8025
+ const client = { res, admin: auth.scopes.includes("admin"), screens: url.searchParams.get("screens") !== "off" };
7979
8026
  if (auth.kind === "session")
7980
8027
  client.sessionId = auth.session.id;
7981
8028
  res.writeHead(200, {
@@ -8011,8 +8058,9 @@ const handleRequest = async (req, res) => {
8011
8058
  })}\n\n`);
8012
8059
  if (resumed) {
8013
8060
  for (const buffered of replayBuffer) {
8014
- if (buffered.seq > since && buffered.frame && wants(client, buffered.kind))
8015
- res.write(buffered.frame);
8061
+ const frame = client.admin ? buffered.frame : buffered.clientFrame;
8062
+ if (buffered.seq > since && frame && wants(client, buffered.kind))
8063
+ res.write(frame);
8016
8064
  }
8017
8065
  }
8018
8066
  sseClients.add(client);
@@ -11384,18 +11432,7 @@ const handleRequest = async (req, res) => {
11384
11432
  }
11385
11433
  // ── app config (API keys — never echoed back, booleans only) ──
11386
11434
  if (method === "GET" && path === "/api/config") {
11387
- const status = configStatus();
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);
11435
+ return json(res, 200, configForAccess(configStatus(), auth.scopes.includes("admin")));
11399
11436
  }
11400
11437
  if ((method === "PUT" || method === "PATCH") && path === "/api/config") {
11401
11438
  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);
@@ -37,7 +37,8 @@ const SCREEN_TOUCHING_TOOLS = new Set([
37
37
  "open_url",
38
38
  "browser_click",
39
39
  "browser_fill",
40
- // built-in browser
40
+ // built-in browser: the Electron surface's names, kept because a bot on an
41
+ // older remote harness can still call them
41
42
  "browser_navigate",
42
43
  "browser_type",
43
44
  "browser_press",
@@ -48,6 +49,20 @@ const SCREEN_TOUCHING_TOOLS = new Set([
48
49
  "browser_back",
49
50
  "browser_forward",
50
51
  "browser_screenshot",
52
+ // built-in browser: agent-browser's names. The engine swap replaced the
53
+ // Electron surface without renaming these here, so every browser turn
54
+ // stopped counting as screen work and no page ever reached the transcript.
55
+ // Read-only tools stay out by the same rule as everywhere else in this
56
+ // list: agent_browser_snapshot, _read and _get_text return text, and the
57
+ // waits change nothing the action before them did not already count.
58
+ "agent_browser_open",
59
+ "agent_browser_click",
60
+ "agent_browser_fill",
61
+ "agent_browser_type",
62
+ "agent_browser_press",
63
+ "agent_browser_select",
64
+ "agent_browser_check",
65
+ "agent_browser_screenshot",
51
66
  // Cua Driver (local Mac, Local VM, VPS)
52
67
  "double_click",
53
68
  "right_click",
@@ -78,3 +93,23 @@ export function screenFrameHash(png) {
78
93
  export function settledFrameIsNews(shownFrameHash, png) {
79
94
  return shownFrameHash === undefined || screenFrameHash(png) !== shownFrameHash;
80
95
  }
96
+ /** Which surface a screen-touching tool acted on. A bot can hold a computer
97
+ * and a browser at once, and they are different pictures: agent-browser runs
98
+ * its own headless Chrome on the host, not inside the bot's desktop. Picking
99
+ * by the tool is what keeps a browsed page from being illustrated with an
100
+ * idle Local VM. */
101
+ export function screenSurfaceForTool(toolName) {
102
+ const name = toolName.toLowerCase();
103
+ // The computer server's browser_click/fill act inside the desktop, not
104
+ // in agent-browser. Keep the server identity before stripping prefixes.
105
+ if (name.startsWith("mcp__computer__") || name.startsWith("computer_"))
106
+ return "computer";
107
+ if (name.startsWith("mcp__browser__"))
108
+ return "browser";
109
+ const bare = name.replace(/^mcp__.+?__/, "");
110
+ // Codex reports bare names. These two belong to computer-proxy; the
111
+ // standalone browser now uses the unambiguous agent_browser_* names.
112
+ if (bare === "browser_click" || bare === "browser_fill")
113
+ return "computer";
114
+ return bare.startsWith("agent_browser_") || bare.startsWith("browser_") ? "browser" : "computer";
115
+ }