openmausbot 0.1.91 → 0.1.92

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 (150) hide show
  1. package/dist/assets/DesktopViewer-Do6lBa4C.js +5 -0
  2. package/dist/assets/{abnfDiagram-O67JEVCF-yP-w8pRq.js → abnfDiagram-O67JEVCF-Cd3X7Hxc.js} +1 -1
  3. package/dist/assets/{arc-DsivfZEh.js → arc-DIYc7Ymx.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CILw7lzT.js → architectureDiagram-NJMV4G6O-DBmRGVyP.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-DEQUV3HF.js → blockDiagram-BEXU5L5S-CPFePSEE.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-lSXCZoKA.js → c4Diagram-YGBWAQC7-DD_DO1qy.js} +1 -1
  7. package/dist/assets/channel-Dtk5Mtic.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-QCD5bgWP.js → chunk-2Q5K7J3B-BNEeNSjX.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-C5-iZJuL.js → chunk-3FUC2YCW-BbinLaqH.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-cxsawMpY.js → chunk-5VM5RSS4-BBgYrgDR.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-DV8tV8H7.js → chunk-GTNCS2PH-ezXj-kA-.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-CJFZyfbW.js → chunk-GWA4HPMP-9FxZ3diV.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-BigbMXco.js → chunk-JWPE2WC7-CO-40JpI.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-oVxTfpDy.js → chunk-NETBCI7D-BEhjslYE.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CH4BiroW.js → chunk-WEXAMYUT-CNeq4n7_.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-KEDa6gax.js → chunk-XXDRQBXY-OsCY7KSi.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BiPjfqVv.js → classDiagram-v2-NBCMYWYE-CloRUEtk.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-Caxew3OY.js → cose-bilkent-JH36ORCC-D1BGZhp4.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-B_Pui369.js → cynefin-OW5HDTMX-CUf4T2CN.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-X64ydIhP.js → cynefinDiagram-VND7K2PF-DPEZrFGA.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-TqYCI1E1.js → dagre-6A5THRUB-bDe6GIv5.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-TeOt3rAQ.js → diagram-22UHCM2B-CdcUQz55.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-D70BLhDA.js → diagram-3UASUU5V-BZpqGg7c.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-BbQDLwUC.js → diagram-ATOU4E4O-DvGM6ugp.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-ChNJZ1u-.js → diagram-CDSNMT55-DSTmMm0s.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-DMBl2APa.js → diagram-MLGK6HIB-15cucnMn.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-CEqDchOp.js → diagram-MPIPVDR6-tMbuaE6n.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BVDKRp_-.js → ebnfDiagram-ZINNZB2B-BYxYzjHI.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CRkofO9B.js → elk-276RUBZZ-v6bj852W.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-DruOMh5e.js → erDiagram-OPXOYQCR-5lBBlnd2.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-8GMQeB7t.js → flowDiagram-KWPJA3E3-CZyl1zIj.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-ORE9jcsM.js → ganttDiagram-FUAMR5RP-BiAI-ltZ.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-BmsedsWh.js → gitGraphDiagram-X574FWY7-BAZISkj3.js} +1 -1
  34. package/dist/assets/index-Cdmjrl28.css +1 -0
  35. package/dist/assets/{index-DKNEM8Q6.js → index-DGBaEXrM.js} +1 -1
  36. package/dist/assets/index-DR7YPMkW.js +597 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-E_p7AooJ.js → infoDiagram-VRGFBTTK-B6ooOy-Z.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-Y-B2mKda.js → ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-eA8RELcK.js → journeyDiagram-ZHPQQLJL-DWqlzIEe.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DqIL2e8e.js → kanban-definition-PNTS6WVX-JX6piDwL.js} +1 -1
  41. package/dist/assets/{linear-yC_29R6e.js → linear-CmOWzX0P.js} +1 -1
  42. package/dist/assets/{mermaid.core-VhEMkSND.js → mermaid.core-CF1zwoQ-.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-oJ7kyQZy.js → mindmap-definition-NLK3R4M7-CGh2K4fW.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-BmfYcY0O.js → pegDiagram-GJSIUBJH-CDwYN0s7.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DKvgP7w9.js → pieDiagram-5QR66LMP-1rfjJocB.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-BEKL3W0L.js → quadrantDiagram-O4NWA36T-tKPvPiok.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BEsiqR5s.js → railroadDiagram-XR7U4H2S-BHLm5vXR.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-DWBcWvAP.js → requirementDiagram-PLB6GJNP-CB45STp7.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-rpZ2_O4s.js → sankeyDiagram-IPEJSGJF-55QA_huC.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-B99HQdJR.js → sequenceDiagram-PO4LG4MO-BckFmQHn.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-BjN0qT5T.js → sizeCapture-INFHLROL-BFCOJF11.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-BbsVgGsC.js → stateDiagram-v2-GCMORJYK-CZvmiVVN.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DG3gKkeR.js → swimlanes-2SLR337P-BpDUCEKy.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BTHOdtF_.js → timeline-definition-EJHVYXUP-BpxHq6IK.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-acLSLFwc.js → usecaseDiagram-POWQR4AR-Cj66vm02.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-C5JzNX9G.js → vennDiagram-UO4OBE2U-C9nRkWE_.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DL1-PrR_.js → wardleyDiagram-VNRHLVJA-D-me_nWj.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-Bvr1477p.js → xychartDiagram-PMCCYNJV-Dc_fL4mk.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist/novnc-NOTICE.txt +118 -0
  62. package/dist-server/cloud-home-start.js +112 -14
  63. package/dist-server/container-mcp.js +195 -15
  64. package/dist-server/drivers/agents-proxy.js +5 -4
  65. package/dist-server/enterprise/server/index.js +5 -1
  66. package/dist-server/index.js +9300 -4155
  67. package/dist-server/local-computer-proxy.js +128 -7
  68. package/dist-server/mcp-gate.js +11 -5
  69. package/dist-server/model-catalog/models-dev.snapshot.json +7299 -0
  70. package/dist-server/openmausbot.js +3910 -1182
  71. package/dist-server/pair-cli.js +3910 -1182
  72. package/dist-server/server/boat.js +2 -2
  73. package/dist-server/server/browser-engine.js +10 -1
  74. package/dist-server/server/browser-live.js +46 -2
  75. package/dist-server/server/browser-runtime.js +74 -8
  76. package/dist-server/server/browser-tool-shape.js +6 -4
  77. package/dist-server/server/chief-of-staff.js +1 -1
  78. package/dist-server/server/cloud-home-start.js +118 -26
  79. package/dist-server/server/cloud-home.js +39 -3
  80. package/dist-server/server/cloud-lending.js +74 -10
  81. package/dist-server/server/cloud-owner.js +196 -0
  82. package/dist-server/server/cloud-secrets-boot.js +6 -0
  83. package/dist-server/server/cloud-secrets.js +65 -0
  84. package/dist-server/server/config.js +174 -14
  85. package/dist-server/server/container-computer.js +3 -2
  86. package/dist-server/server/desktop-viewer-targets.js +26 -0
  87. package/dist-server/server/drivers/acp/core.js +167 -21
  88. package/dist-server/server/drivers/acp/opencode-go.js +614 -85
  89. package/dist-server/server/drivers/agents-catalog.js +6 -6
  90. package/dist-server/server/drivers/antigravity-acp.js +69 -11
  91. package/dist-server/server/drivers/antigravity-temp.js +360 -0
  92. package/dist-server/server/drivers/antigravity.js +21 -0
  93. package/dist-server/server/drivers/chatgpt-plan-auth.js +481 -0
  94. package/dist-server/server/drivers/claude.js +312 -34
  95. package/dist-server/server/drivers/codex.js +228 -18
  96. package/dist-server/server/drivers/grok.js +2 -2
  97. package/dist-server/server/drivers/mistral.js +4 -3
  98. package/dist-server/server/drivers/openai-chat-protocol.js +4 -3
  99. package/dist-server/server/drivers/openai-chat.js +41 -3
  100. package/dist-server/server/drivers/openai-compat.js +73 -23
  101. package/dist-server/server/engine-install.js +46 -12
  102. package/dist-server/server/env-path.js +15 -3
  103. package/dist-server/server/harness/registry.js +2 -2
  104. package/dist-server/server/index.js +1338 -184
  105. package/dist-server/server/lending-memory.js +479 -0
  106. package/dist-server/server/local-vm-idle.js +24 -6
  107. package/dist-server/server/managed-desktop.js +3 -0
  108. package/dist-server/server/mcp-bridge.js +5 -3
  109. package/dist-server/server/mcp-http.js +6 -3
  110. package/dist-server/server/mcp-oauth-discovery.js +133 -0
  111. package/dist-server/server/mcp-oauth-store.js +70 -0
  112. package/dist-server/server/mcp-oauth.js +550 -0
  113. package/dist-server/server/mcp-probe.js +14 -1
  114. package/dist-server/server/mcp-registry.js +43 -0
  115. package/dist-server/server/memory-upkeep.js +94 -84
  116. package/dist-server/server/model-catalog/catalog.js +374 -0
  117. package/dist-server/server/model-catalog/presets.js +176 -0
  118. package/dist-server/server/model-catalog/trim.js +219 -0
  119. package/dist-server/server/provider-key-check.js +4 -2
  120. package/dist-server/server/recent-work.js +1 -1
  121. package/dist-server/server/request-auth.js +23 -0
  122. package/dist-server/server/routes/antigravity-leftovers.js +45 -0
  123. package/dist-server/server/routes/bot-memory.js +31 -6
  124. package/dist-server/server/routes/desktop-viewer.js +194 -0
  125. package/dist-server/server/routine-requests.js +1 -1
  126. package/dist-server/server/routines.js +1 -1
  127. package/dist-server/server/sessions.js +56 -9
  128. package/dist-server/server/skills.js +25 -10
  129. package/dist-server/server/store.js +62 -122
  130. package/dist-server/server/thread-events.js +2 -0
  131. package/dist-server/server/thread-starters.js +33 -0
  132. package/dist-server/server/thread-title.js +46 -0
  133. package/dist-server/server/tts/fish.js +4 -5
  134. package/dist-server/server/tts/index.js +5 -3
  135. package/dist-server/server/vps-computer.js +79 -12
  136. package/dist-server/server/webhooks.js +18 -1
  137. package/dist-server/server/workspace-backup-policy.js +3 -0
  138. package/dist-server/server/workspace-backup.js +3 -0
  139. package/dist-server/server/workspace.js +65 -15
  140. package/dist-server/shared/workspace-backup-client.js +1 -0
  141. package/dist-server/src/lib/citations.js +177 -0
  142. package/dist-server/src/lib/composer-attachments.js +7 -1
  143. package/dist-server/vps-container-mcp.js +196 -15
  144. package/dist-server/workspace-backup.worker.js +73 -9
  145. package/enterprise/server/index.js +5 -1
  146. package/package.json +1 -1
  147. package/dist/assets/channel-DrfGMu7X.js +0 -1
  148. package/dist/assets/index-BlnCEcU6.js +0 -588
  149. package/dist/assets/index-DVxjOvID.css +0 -1
  150. package/dist/assets/swimlanesDiagram-TC7HE7FX-BCZhxl7e.js +0 -8
@@ -388,7 +388,7 @@ async function waitReady(cfg, boxId, budgetMs = 90_000) {
388
388
  const boatIdCache = new Map();
389
389
  function boatInventoryProblem(status, body, included = false) {
390
390
  if (status === 401 || status === 403) {
391
- return included ? INCLUDED_BOAT_UNAVAILABLE : "boat.dev rejected the Boat API key — update it in Settings → Connections";
391
+ return included ? INCLUDED_BOAT_UNAVAILABLE : "boat.dev rejected the Boat API key — update it in Settings → API keys";
392
392
  }
393
393
  if (status === 429)
394
394
  return "boat.dev is rate-limiting this account — wait a minute and refresh";
@@ -708,7 +708,7 @@ export async function inspectBoatIdentity(cfg, boxId) {
708
708
  function inventoryFailure(inventory) {
709
709
  const error = new Error(inventory.configured
710
710
  ? (inventory.problem ?? "Cloud computer inventory is unavailable")
711
- : "Boat is not configured — add its API key in Settings → Connections");
711
+ : "Boat is not configured — add its API key in Settings → API keys");
712
712
  error.status = inventory.configured ? 503 : 409;
713
713
  return error;
714
714
  }
@@ -442,8 +442,17 @@ export function agentBrowserIntegration(input) {
442
442
  ...(sourceEnv.USERPROFILE ? { USERPROFILE: sourceEnv.USERPROFILE } : {}),
443
443
  })),
444
444
  };
445
- if (input.headless !== false)
445
+ // A Linux host with no display cannot run headed Chrome, and OMB's managed
446
+ // browser is headless automation (docs/browser-packaging.md). Pin headless
447
+ // there even for a headed-capable caller. The engine's own flag is
448
+ // AGENT_BROWSER_HEADED; an explicit "0" pins the default so a daemon that
449
+ // kept a headed launch config still comes back headless instead of failing
450
+ // every launch until it is killed by hand (#1383).
451
+ const displayless = (input.platform ?? process.platform) === "linux" && !sourceEnv.DISPLAY && !sourceEnv.WAYLAND_DISPLAY;
452
+ if (input.headless !== false || displayless) {
446
453
  env.AGENT_BROWSER_HEADLESS = "1";
454
+ env.AGENT_BROWSER_HEADED = "0";
455
+ }
447
456
  if (input.attachCdpUrl)
448
457
  env.AGENT_BROWSER_CDP = input.attachCdpUrl;
449
458
  // MCP clients may filter the parent environment. Carry the configured
@@ -39,6 +39,19 @@ function displayUrl(value) {
39
39
  return "";
40
40
  }
41
41
  }
42
+ /** Chrome's own launch-failure wording: the engine is installed and its
43
+ * daemon answers, but no browser can start (no display, an early exit).
44
+ * Distinct from a missing engine — the fix is a fresh session, not an
45
+ * install, so the generic "check the engine is installed" guidance would
46
+ * send the operator the wrong way (#1383). */
47
+ function browserLaunchFailure(error) {
48
+ const detail = [
49
+ error?.stderr,
50
+ error?.stdout,
51
+ error instanceof Error ? error.message : String(error),
52
+ ].filter((part) => typeof part === "string").join("\n");
53
+ return /missing x server|\$DISPLAY|devtoolsactiveport|chrome exited|could not launch|failed to launch|browser launch failed/iu.test(detail);
54
+ }
42
55
  /** Project only the viewer protocol. Never relay engine commands, results or console output. */
43
56
  export function normalizeBrowserLiveMessage(value) {
44
57
  const message = object(value);
@@ -150,6 +163,7 @@ export function parseBrowserLiveAction(value) {
150
163
  export class BrowserLive {
151
164
  runtime;
152
165
  viewers = new Map();
166
+ sessionResets = new Map();
153
167
  constructor({ runtime }) { this.runtime = runtime; }
154
168
  current(viewer) {
155
169
  try {
@@ -298,12 +312,37 @@ export class BrowserLive {
298
312
  throw new Error("stale viewer");
299
313
  const result = object(JSON.parse(stdout));
300
314
  const data = object(result?.data);
301
- if (result?.success !== true || !data)
302
- throw new Error("browser command failed");
315
+ if (result?.success !== true || !data) {
316
+ // Keep the engine's own error internal (logged, never sent) so a
317
+ // launch failure is recognized for what it is below.
318
+ const detail = [result?.error, result?.message].filter((part) => typeof part === "string").join(" ");
319
+ throw new Error(detail ? `browser command failed: ${detail}` : "browser command failed");
320
+ }
303
321
  return data;
304
322
  }
305
323
  catch (error) {
306
324
  console.warn("browser-live:", error);
325
+ if (browserLaunchFailure(error)) {
326
+ // The daemon is alive but holding a launch configuration Chrome
327
+ // cannot satisfy on this host (a headed launch with no display). It
328
+ // fails every command the same way until the daemon is killed —
329
+ // discard the session so the next connect starts a fresh one. One
330
+ // tracked reset per session: a reconnect or repeated command joins
331
+ // the in-flight teardown instead of spawning another (#1383).
332
+ if (!this.sessionResets.has(viewer.session)) {
333
+ const reset = closeBrowserSession(viewer.spec.command, env).then((closed) => {
334
+ if (closed)
335
+ console.warn(`browser-live: reset ${viewer.session} after a failed browser launch; reconnecting starts a fresh session`);
336
+ else
337
+ console.warn(`browser-live: reset of ${viewer.session} after a failed browser launch did not finish; the next command retries it`);
338
+ return closed;
339
+ }, () => false);
340
+ this.sessionResets.set(viewer.session, reset);
341
+ void reset.finally(() => { if (this.sessionResets.get(viewer.session) === reset)
342
+ this.sessionResets.delete(viewer.session); });
343
+ }
344
+ throw new BrowserLiveError("The browser could not start on this server. Reconnect to retry with a fresh browser session.", 503);
345
+ }
307
346
  throw new BrowserLiveError("The browser could not complete this action. Check that the browser engine is installed, then reconnect.", 503);
308
347
  }
309
348
  }
@@ -380,6 +419,11 @@ export class BrowserLive {
380
419
  options.res.once("close", () => this.close(viewer));
381
420
  options.res.once("error", () => this.close(viewer));
382
421
  try {
422
+ // A failed launch already started this session's daemon teardown; wait
423
+ // for it so the status probe meets a fresh daemon, not the stuck one.
424
+ const resetting = this.sessionResets.get(viewer.session);
425
+ if (resetting)
426
+ await resetting;
383
427
  let status = await this.command(viewer, ["stream", "status"]);
384
428
  if (status.enabled !== true) {
385
429
  try {
@@ -1,6 +1,16 @@
1
1
  import { killCliTree, spawnCli } from "./procs.js";
2
2
  import { DEFAULT_BROWSER_RESULT_BUDGET, shapeBrowserToolResult, slimBrowserToolList, stripHarnessOwnedArguments } from "./browser-tool-shape.js";
3
3
  export const BROWSER_CONTROL_REFUSAL = "Browser tools are paused while a person controls this browser. Wait for them to hand control back; do not try another browser or execution tool.";
4
+ /** Harness-owned recovery tool, advertised beside the engine's own tools. A
5
+ * phone or chat-only user has no Browser panel Restart button; without this
6
+ * an interrupted action refuses the bot's browser until the app restarts. */
7
+ export const BROWSER_RESTART_TOOL = "restart_browser";
8
+ const BROWSER_RESTART_TOOL_SPEC = {
9
+ name: BROWSER_RESTART_TOOL,
10
+ description: "Close and restart your browser. Use this only when a browser tool says a browser action was interrupted and the browser must be restarted. Open pages are closed; afterwards open the page you need again and check whether the interrupted action already happened before repeating it.",
11
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
12
+ };
13
+ const BROWSER_INTERRUPTED = `A browser action was interrupted. Restart this browser with the ${BROWSER_RESTART_TOOL} tool before continuing.`;
4
14
  const MAX_REQUEST_BYTES = 1_048_576;
5
15
  const MAX_RESPONSE_BYTES = 16_777_216;
6
16
  /** Startup, not per-request work: a cold engine spawn can exceed a tight
@@ -200,10 +210,15 @@ class BrowserClient {
200
210
  export class BrowserRuntime {
201
211
  gates = new Map();
202
212
  clients = new Map();
213
+ /** Last advertised tools per session, so a turn that starts while the
214
+ * browser is uncertain still sees the tools it can use after recovering. */
215
+ toolLists = new Map();
203
216
  options;
204
- constructor(options = {}) {
217
+ closeBrowser;
218
+ constructor({ closeBrowser, ...options } = {}) {
205
219
  const budget = Number(process.env.OMB_BROWSER_RESULT_BUDGET);
206
220
  this.options = { requestTimeoutMs: 120_000, takeoverTimeoutMs: 15_000, idleMs: 60_000, maxPending: 16, resultBudget: Number.isFinite(budget) && budget > 0 ? budget : DEFAULT_BROWSER_RESULT_BUDGET, ...options };
221
+ this.closeBrowser = closeBrowser ?? (async () => false);
207
222
  }
208
223
  gate(session) {
209
224
  let gate = this.gates.get(session);
@@ -227,7 +242,7 @@ export class BrowserRuntime {
227
242
  if (gate.owner !== null)
228
243
  throw new Error(BROWSER_CONTROL_REFUSAL);
229
244
  if (gate.uncertain)
230
- throw new Error("A browser action was interrupted. Restart this browser before continuing.");
245
+ throw new Error(BROWSER_INTERRUPTED);
231
246
  if (gate.closing)
232
247
  throw new Error("The browser is closing. Try again shortly.");
233
248
  gate.agents++;
@@ -250,10 +265,21 @@ export class BrowserRuntime {
250
265
  // must refuse the closing window itself or its client outlives restart().
251
266
  if (method !== "tools/call" && this.gate(session).closing)
252
267
  throw new Error("The browser is closing. Try again shortly.");
268
+ if (method === "tools/call" && params && typeof params === "object" && params.name === BROWSER_RESTART_TOOL) {
269
+ beforeDispatch?.();
270
+ await this.agentRestart(session, () => this.closeBrowser(session, spec));
271
+ beforeDispatch?.(); // A turn revoked while the browser closed receives no result.
272
+ return { content: [{ type: "text", text: "Browser restarted. Open pages were closed; open the page you need again and check whether the interrupted action already happened before repeating it." }] };
273
+ }
253
274
  // Uncertainty survives client replacement and never self-resolves: refuse
254
- // every new browser request until an explicit restart clears it.
255
- if (this.gate(session).uncertain)
256
- throw new Error("A browser action was interrupted. Restart this browser before continuing.");
275
+ // every new browser request until an explicit restart clears it. Listing
276
+ // never reaches the engine; it answers from the last list so the restart
277
+ // tool is reachable and the engine's tools are there once it succeeds.
278
+ if (this.gate(session).uncertain) {
279
+ if (method === "tools/list")
280
+ return withRestartTool(this.toolLists.get(session) ?? { tools: [] });
281
+ throw new Error(BROWSER_INTERRUPTED);
282
+ }
257
283
  const invoke = async () => {
258
284
  const key = JSON.stringify([spec.command, spec.args, Object.entries(spec.env).sort(([a], [b]) => a.localeCompare(b))]);
259
285
  let entry = this.clients.get(session);
@@ -281,8 +307,11 @@ export class BrowserRuntime {
281
307
  const request = method === "tools/call" ? stripHarnessOwnedArguments(params) : params;
282
308
  let result = await entry.client.rpc(method, request);
283
309
  beforeDispatch?.(); // A turn revoked while the tool ran receives no result.
284
- if (method === "tools/list")
285
- return slimBrowserToolList(result);
310
+ if (method === "tools/list") {
311
+ const tools = withRestartTool(slimBrowserToolList(result));
312
+ this.toolLists.set(session, tools);
313
+ return tools;
314
+ }
286
315
  const toolName = request && typeof request === "object" && typeof request.name === "string" ? request.name : undefined;
287
316
  if (toolName === "agent_browser_open" && result && typeof result === "object" &&
288
317
  result.isError !== true) {
@@ -448,6 +477,35 @@ export class BrowserRuntime {
448
477
  this.changed(gate);
449
478
  }
450
479
  }
480
+ /** The agent's own recovery, for a turn with no Browser panel to press
481
+ * Restart in. The same barrier as restart() — the native browser must
482
+ * close — but it never takes a person's hold, and a failed close leaves no
483
+ * owner behind that would lock a person out of the panel. */
484
+ async agentRestart(session, closeBrowser) {
485
+ const gate = this.gate(session);
486
+ if (gate.owner !== null)
487
+ throw new Error(BROWSER_CONTROL_REFUSAL);
488
+ if (gate.closing || gate.releasing || gate.agents || gate.humans)
489
+ throw new Error("The browser is busy. Wait for current work to finish before restarting.");
490
+ gate.ready = false;
491
+ gate.closing = true;
492
+ this.changed(gate);
493
+ try {
494
+ if (!await closeBrowser())
495
+ throw new Error("The browser could not be closed. Ask the person to press Restart in the Browser panel of OpenMausBot on their computer, or to restart OpenMausBot.");
496
+ await this.clients.get(session)?.client.stop();
497
+ await this.clients.get(session)?.client.stop(); // see restart()
498
+ gate.uncertain = false;
499
+ }
500
+ catch (error) {
501
+ gate.uncertain = true;
502
+ throw error;
503
+ }
504
+ finally {
505
+ gate.closing = false;
506
+ this.changed(gate);
507
+ }
508
+ }
451
509
  /** Call after the underlying browser is closed when recovering an uncertain
452
510
  * action. This closes the MCP transport, not saved logins or profile files. */
453
511
  async close(session) {
@@ -460,10 +518,18 @@ export class BrowserRuntime {
460
518
  await this.clients.get(session)?.client.stop();
461
519
  gate.closing = false;
462
520
  this.changed(gate);
463
- if (!gate.owner && !gate.agents && !gate.humans)
521
+ if (!gate.owner && !gate.agents && !gate.humans) {
464
522
  this.gates.delete(session);
523
+ this.toolLists.delete(session);
524
+ }
465
525
  }
466
526
  async closeAll() {
467
527
  await Promise.all([...new Set([...this.clients.keys(), ...this.gates.keys()])].map((session) => this.close(session)));
468
528
  }
469
529
  }
530
+ function withRestartTool(result) {
531
+ if (!result || typeof result !== "object" || !Array.isArray(result.tools))
532
+ return result;
533
+ const tools = (result.tools).filter((tool) => tool?.name !== BROWSER_RESTART_TOOL);
534
+ return { ...result, tools: [...tools, BROWSER_RESTART_TOOL_SPEC] };
535
+ }
@@ -12,11 +12,13 @@
12
12
  import { trimResultText } from "./mcp-trim.js";
13
13
  /** Launch, session and network settings OpenMausBot owns through the
14
14
  * environment (see browser-engine.ts). A model has no business setting them
15
- * per call — `session` would reach another bot's browser, `extraArgs` and
16
- * `caCert` change the launch — and each one cost more schema than the tool's
17
- * own description. */
15
+ * per call — `session` would reach another bot's browser, `extraArgs`,
16
+ * `caCert` and `headed` change the launch — and each one cost more schema
17
+ * than the tool's own description. `headed` in particular overrides
18
+ * AGENT_BROWSER_HEADED and the managed config, so a single call can pin a
19
+ * daemon to a launch mode this host cannot satisfy (#1383). */
18
20
  export const HARNESS_OWNED_BROWSER_PARAMS = new Set([
19
- "allowedDomains", "caCert", "clearCaCert", "extraArgs", "idleTimeout", "namespace",
21
+ "allowedDomains", "caCert", "clearCaCert", "extraArgs", "headed", "idleTimeout", "namespace",
20
22
  "restore", "restoreCheckFn", "restoreCheckText", "restoreCheckUrl", "restoreSave", "session", "timeoutMs",
21
23
  ]);
22
24
  /** Characters of one browser result that may enter the model's context.
@@ -29,7 +29,7 @@ export function chiefOfStaffSystemPrompt(chiefId, bots, canDelegate, trustedOpen
29
29
  });
30
30
  const delegation = canDelegate
31
31
  ? boundedCoordination
32
- ? "Use list_bots or list_room_targets for the live reachable roster. Use coordinate_bots to ask actual teammates for advice or assign concrete work. Outside a room, everything you send a teammate continues your one standing conversation with them, using their own model and permissions, so they still have the context of your earlier assignments. Busy teammates queue. Give self-contained briefs, then end your turn; you resume automatically after their results return. Leads can coordinate their own specialists. Do not poll, send acknowledgements as new work, or substitute native helpers for named bots. On return, verify the requested outcome, resolve decisions within the user's scope, request concrete corrections with rework=true when necessary, and return one consolidated answer. Consultations are advice, not proof that work or tests ran. A refusal from coordinate_bots means nothing was sent: fix what it names (usually the id — copy it from list_bots or your roster; a unique teammate name also works) and retry, and never describe a handoff the tool did not accept."
32
+ ? "Use list_bots or list_room_targets for the live reachable roster. Use coordinate_bots to ask actual teammates for advice or assign concrete work. Outside a room, each distinct assignment starts a fresh thread for that teammate, using their own model and permissions. Include the context they need in each brief. Busy teammates queue. Give self-contained briefs, then end your turn; you resume automatically after their results return. Leads can coordinate their own specialists. Do not poll, send acknowledgements as new work, or substitute native helpers for named bots. On return, verify the requested outcome, resolve decisions within the user's scope, request concrete corrections with rework=true when necessary, and return one consolidated answer. Consultations are advice, not proof that work or tests ran. A refusal from coordinate_bots means nothing was sent: fix what it names (usually the id — copy it from list_bots or your roster; a unique teammate name also works) and retry, and never describe a handoff the tool did not accept."
33
33
  : [
34
34
  "Use list_bots to confirm the live roster and IDs. When assigning work to a teammate, use delegate_bot: it returns immediately, keeps you available to the user, and delivers the teammate's outcome back into this conversation automatically — success or failure. When the result arrives you are woken with it: report it to the user and act. If the teammate fails or stalls, tell the user plainly and decide the next step yourself.",
35
35
  "After delegate_bot accepts the task, acknowledge the handoff and continue with any independent work or end your turn. Do not call wait_delegation or repeatedly poll check_delegation in the same turn.",
@@ -1,19 +1,33 @@
1
1
  // Entry point of the OMB Cloud Pro home image (deploy/fly/Dockerfile).
2
2
  //
3
- // Starts as root only to hand a fresh Fly volume (mounted root-owned at
4
- // /data) to the unprivileged `maus` user, then drops privileges for good
5
- // and runs two children: the OpenMausBot server on 127.0.0.1:8799 (and its
6
- // webhook receiver on :8800) and the Caddy edge on 0.0.0.0:8080. The edge is
7
- // the only listener the network can reach, and it always forwards with
8
- // X-Forwarded-*, so request-auth.ts never grants a remote request loopback
9
- // trust. If either child exits, both stop and the machine restarts; the one
10
- // exception is the server asking to be started again after a restore.
3
+ // Starts as root, hands a fresh Fly volume (mounted root-owned at /data) to
4
+ // the unprivileged `maus` user, and stays a small root supervisor of two
5
+ // children that run as `maus`: the OpenMausBot server on 127.0.0.1:8799
6
+ // (and its webhook receiver on :8800) and the Caddy edge on 0.0.0.0:8080.
7
+ // The edge is the only listener the network can reach, and it always
8
+ // forwards with X-Forwarded-*, so request-auth.ts never grants a remote
9
+ // request loopback trust. If either child exits, both stop and the machine
10
+ // restarts; the one exception is the server asking to be started again
11
+ // after a restore.
12
+ //
13
+ // The machine's secrets (the signing secret, the relay tokens) arrive as
14
+ // this process's environment. No child's environment ever carries them:
15
+ // /proc/<pid>/environ keeps a process's starting environment, readable by
16
+ // anything running as the same user (an engine's shell). The server gets
17
+ // them over an inherited pipe it reads first thing and closes
18
+ // (cloud-secrets.ts), and an environment built from an allow-list, so a
19
+ // secret the platform adds later never reaches it either. This process stays
20
+ // root, so its own environment and memory are out of `maus`'s reach, and it
21
+ // runs and trusts only code `maus` cannot change: the image's, never the
22
+ // volume's (codeTrustProblem).
11
23
  import { spawn } from "node:child_process";
12
24
  import { chownSync, readFileSync, statSync } from "node:fs";
13
25
  import { dirname, join, posix, resolve } from "node:path";
14
26
  import { fileURLToPath, pathToFileURL } from "node:url";
15
- import { CLOUD_HOME_RESTART_EXIT_CODE, cloudHomeConfiguration, cloudHomeHost, prepareCloudHomeVolume, withoutIgnoredCloudKeys } from "./cloud-home.js";
27
+ import { CLOUD_HOME_RESTART_EXIT_CODE, CLOUD_SECRETS_FD_ENV, cloudHomeConfiguration, cloudHomeHost, cloudHomeSecrets, prepareCloudHomeVolume, withoutCloudSecrets, withoutIgnoredCloudKeys, } from "./cloud-home.js";
16
28
  const SERVICE_USER = "maus";
29
+ /** The descriptor the server reads its secrets from. */
30
+ const SECRETS_FD = 3;
17
31
  /** uid/gid from /etc/passwd; Node has no getpwnam. */
18
32
  export function passwdIds(passwd, name) {
19
33
  for (const line of passwd.split("\n")) {
@@ -23,22 +37,80 @@ export function passwdIds(passwd, name) {
23
37
  }
24
38
  return null;
25
39
  }
26
- /** The server child's environment: the operator's contract plus fixed
27
- * ports and paths (Linux paths inside the image, so POSIX joins on every
28
- * host that builds them, tests included), never a platform gateway's settings. The edge child gets
29
- * only what it needs to route. */
40
+ /** What the server child may be given from this process's environment, by
41
+ * name: the process basics, what the image sets, and the Cloud home's
42
+ * contract that is not secret. Anything else (a secret the platform adds
43
+ * later, a test's key, a platform gateway's settings) is left out. */
44
+ const SERVER_ENV_NAMES = new Set([
45
+ "PATH", "SHELL", "HOSTNAME", "LANG", "LANGUAGE", "TZ", "TERM", "TMPDIR", "NO_COLOR", "NODE_ENV", "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR",
46
+ "AGENT_BROWSER_EXECUTABLE_PATH", "OMB_STATIC_DIR", "OMB_DATA_DIR",
47
+ "OMB_CLOUD_ROLE", "OMB_CLOUD_MACHINE_ID", "OMB_CLOUD_ADMIN_URL", "OMB_CLOUD_IMAGE", "OMB_PUBLIC_URL", "OMB_WEBHOOK_PUBLIC_URL",
48
+ "OMB_CLOUD_BOAT_URL", "OMB_CLOUD_VOICE_URL", "OMB_CLOUD_DECIDER_URL", "OMB_TTS_DEFAULT_VOICE",
49
+ ]);
50
+ export function serverEnvironmentAllowed(name) {
51
+ return SERVER_ENV_NAMES.has(name) || name.startsWith("LC_");
52
+ }
53
+ /** The server child's environment: the allowed part of the operator's
54
+ * contract (serverEnvironmentAllowed) plus fixed ports and paths (Linux
55
+ * paths inside the image, so POSIX joins on every host that builds them,
56
+ * tests included), never a platform gateway's settings and never a secret
57
+ * (`secrets`, handed over the pipe instead). `dropped` names what was left
58
+ * out, for the log. The edge child gets only what it needs to route. */
30
59
  export function cloudHomeChildEnvironments(config, env, home) {
60
+ const offered = withoutCloudSecrets(withoutIgnoredCloudKeys(env));
61
+ const allowed = Object.fromEntries(Object.entries(offered).filter(([name, value]) => value !== undefined && serverEnvironmentAllowed(name)));
62
+ const dropped = Object.keys(offered).filter((name) => !serverEnvironmentAllowed(name) && name !== "HOME").sort();
31
63
  const server = {
32
- ...withoutIgnoredCloudKeys(env), HOME: home, OMB_DATA_DIR: env.OMB_DATA_DIR || posix.join(home, ".openmausbot"),
64
+ ...allowed, HOME: home, OMB_DATA_DIR: env.OMB_DATA_DIR || posix.join(home, ".openmausbot"),
33
65
  OMB_PORT: "8799", OMB_WEBHOOK_PORT: "8800", OMB_PUBLIC_URL: config.publicOrigin,
34
66
  OMB_WEBHOOK_PUBLIC_URL: env.OMB_WEBHOOK_PUBLIC_URL || config.publicOrigin,
67
+ [CLOUD_SECRETS_FD_ENV]: String(SECRETS_FD),
35
68
  };
36
69
  const edge = {
37
70
  PATH: env.PATH ?? "/usr/local/bin:/usr/bin:/bin", HOME: "/tmp/omb-edge",
38
71
  XDG_DATA_HOME: "/tmp/omb-edge/data", XDG_CONFIG_HOME: "/tmp/omb-edge/config",
39
72
  OMB_CLOUD_PUBLIC_HOST: cloudHomeHost(config),
40
73
  };
41
- return { server, edge };
74
+ return { server, edge, secrets: cloudHomeSecrets(env), dropped };
75
+ }
76
+ /** Why the root supervisor must not run or trust `files`, or null: each
77
+ * must be root's and not writable by anyone else (nor any folder above it),
78
+ * and none may live on the volume `home`, which `maus` owns. The image makes its code
79
+ * root's (deploy/fly/Dockerfile); a file `maus` could rewrite would run as
80
+ * root at the next start, or be handed the secrets. */
81
+ export function codeTrustProblem(files, home, stat = statSync) {
82
+ const volume = posix.join(home, "/");
83
+ for (const file of files) {
84
+ if (file === home || file.startsWith(volume))
85
+ return `${file} is on the volume`;
86
+ for (let path = file;; path = dirname(path)) {
87
+ let owner;
88
+ try {
89
+ owner = stat(path);
90
+ }
91
+ catch {
92
+ return `${path} cannot be checked`;
93
+ }
94
+ if (owner.uid !== 0)
95
+ return `${path} is not root's`;
96
+ // A folder with the sticky bit (/tmp) lets nobody replace root's files.
97
+ if (owner.mode & 0o022 && !(path !== file && owner.mode & 0o1000))
98
+ return `${path} is writable by others than root`;
99
+ if (dirname(path) === path)
100
+ break;
101
+ }
102
+ }
103
+ return null;
104
+ }
105
+ /** Start the server with its secrets on an inherited pipe (never its
106
+ * environment), as `ids` when given. The pipe is written and closed at once;
107
+ * nothing else is ever sent on it. */
108
+ export function spawnWithSecrets(command, args, env, secrets, ids) {
109
+ const child = spawn(command, args, { env, stdio: ["inherit", "inherit", "inherit", "pipe"], ...(ids ? { uid: ids.uid, gid: ids.gid } : {}) });
110
+ const pipe = child.stdio[SECRETS_FD];
111
+ pipe?.on("error", () => { });
112
+ pipe?.end(JSON.stringify(secrets));
113
+ return child;
42
114
  }
43
115
  /** What the launcher does when the server child exits: start it again only
44
116
  * when it asked to (a committed restore), and only a few times in a row. */
@@ -54,8 +126,11 @@ export function startCloudHome(env = process.env) {
54
126
  for (const warning of config.warnings)
55
127
  console.warn(`cloud home: ${warning}`);
56
128
  const home = env.HOME || "/data";
129
+ // Who the children run as: `maus` when this starts as root (the image),
130
+ // else whoever started it (a test, a dev machine).
131
+ let ids = null;
57
132
  if (process.getuid?.() === 0) {
58
- const ids = passwdIds(readFileSync("/etc/passwd", "utf8"), SERVICE_USER);
133
+ ids = passwdIds(readFileSync("/etc/passwd", "utf8"), SERVICE_USER);
59
134
  if (!ids)
60
135
  throw new Error(`The ${SERVICE_USER} user is missing from this image.`);
61
136
  // A new volume is a root-owned mount point. Only the mount point itself
@@ -64,14 +139,32 @@ export function startCloudHome(env = process.env) {
64
139
  if (stat.uid !== ids.uid || stat.gid !== ids.gid)
65
140
  chownSync(home, ids.uid, ids.gid);
66
141
  process.setgroups?.([]);
67
- process.setgid(ids.gid);
68
- process.setuid(ids.uid);
142
+ // The volume is prepared as `maus`, so what it creates is theirs.
143
+ process.setegid(ids.gid);
144
+ process.seteuid(ids.uid);
145
+ try {
146
+ prepareCloudHomeVolume(home, config.machineId);
147
+ }
148
+ finally {
149
+ process.seteuid(0);
150
+ process.setegid(0);
151
+ }
152
+ }
153
+ else {
154
+ prepareCloudHomeVolume(home, config.machineId);
69
155
  }
70
- if (process.getuid?.() === 0)
71
- throw new Error("The Cloud home must run as an unprivileged user.");
72
- prepareCloudHomeVolume(home, config.machineId);
73
156
  const here = dirname(fileURLToPath(import.meta.url));
74
- const { server, edge } = cloudHomeChildEnvironments(config, env, home);
157
+ const edgeBin = env.OMB_CLOUD_EDGE_BIN || "/usr/local/bin/caddy";
158
+ const edgeConfig = env.OMB_CLOUD_EDGE_CONFIG || "/app/cloud/Caddyfile";
159
+ if (ids) {
160
+ const problem = codeTrustProblem([process.execPath, fileURLToPath(import.meta.url), join(here, "index.js"), edgeBin, edgeConfig], home);
161
+ if (problem)
162
+ throw new Error(`This image's code is not safe to run as root: ${problem}. Rebuild it from deploy/fly/Dockerfile.`);
163
+ }
164
+ const { server, edge, secrets, dropped } = cloudHomeChildEnvironments(config, env, home);
165
+ // Names only, never values: what the server does not get from here.
166
+ if (dropped.length)
167
+ console.log(`cloud home: not passed to the server: ${dropped.join(", ")}`);
75
168
  const children = [];
76
169
  let stopping = false;
77
170
  const stop = (failed) => {
@@ -87,8 +180,7 @@ export function startCloudHome(env = process.env) {
87
180
  child.kill("SIGKILL"); }, 20_000);
88
181
  force.unref();
89
182
  };
90
- const run = (command, args, childEnv, again) => {
91
- const child = spawn(command, args, { env: childEnv, stdio: "inherit" });
183
+ const watch = (child, again) => {
92
184
  children.push(child);
93
185
  child.once("error", () => stop(true));
94
186
  child.once("exit", (code) => {
@@ -98,7 +190,7 @@ export function startCloudHome(env = process.env) {
98
190
  });
99
191
  };
100
192
  let restarts = 0;
101
- const runServer = () => run(process.execPath, [join(here, "index.js")], server, (code) => {
193
+ const runServer = () => watch(spawnWithSecrets(process.execPath, [join(here, "index.js")], server, secrets, ids), (code) => {
102
194
  if (serverExitAction(code, stopping, restarts) !== "restart")
103
195
  return false;
104
196
  restarts++;
@@ -106,7 +198,7 @@ export function startCloudHome(env = process.env) {
106
198
  return true;
107
199
  });
108
200
  runServer();
109
- run(env.OMB_CLOUD_EDGE_BIN || "/usr/local/bin/caddy", ["run", "--config", env.OMB_CLOUD_EDGE_CONFIG || "/app/cloud/Caddyfile", "--adapter", "caddyfile"], edge);
201
+ watch(spawn(edgeBin, ["run", "--config", edgeConfig, "--adapter", "caddyfile"], { env: edge, stdio: "inherit", ...(ids ? { uid: ids.uid, gid: ids.gid } : {}) }));
110
202
  process.once("SIGTERM", () => stop(false));
111
203
  process.once("SIGINT", () => stop(false));
112
204
  }
@@ -12,7 +12,10 @@
12
12
  // the machine's bootstrap secret (HMAC-SHA256 over the method, path, a
13
13
  // timestamp, a nonce and the body's hash); each valid request opens one
14
14
  // ordinary pairing window (sessions.ts: single use, at most ten minutes),
15
- // which the Admin hands to the person's signed-in desktop app.
15
+ // which the Admin hands to the person's signed-in desktop app. A request for
16
+ // a browser sign-in (the Cloud page's "Use in your browser") names the
17
+ // account that owns this Cloud and opens a window only this machine's web
18
+ // page redeems, for at most two minutes, after showing whose Cloud it is.
16
19
  //
17
20
  // Cloud Pro includes no AI. The person signs in on the machine with their own
18
21
  // Claude or ChatGPT account, or an API key, exactly as on any server; nothing
@@ -33,6 +36,10 @@ export const CLOUD_IGNORED_KEYS = ["OMB_HOSTED_MODEL_URL", "OMB_HOSTED_MODEL_TOK
33
36
  export const CLOUD_PAIRING_PATH = "/api/cloud/pairing";
34
37
  export const CLOUD_PAIRING_DEFAULT_TTL_S = 300;
35
38
  export const CLOUD_PAIRING_MAX_TTL_S = 600;
39
+ /** A browser sign-in's owner: printable ASCII with no space, `<` or `>`, and exactly one `@`. */
40
+ const OWNER_EMAIL = /^[!-;=?A-~]{1,64}@[!-;=?A-~]{1,189}$/;
41
+ /** A browser sign-in is redeemed the moment its tab loads. */
42
+ export const CLOUD_BROWSER_SIGN_IN_MAX_TTL_S = 120;
36
43
  /** How far a signed request's timestamp may be from this machine's clock. */
37
44
  export const CLOUD_PAIRING_SKEW_S = 300;
38
45
  /** How long a used nonce is refused. Longer than the whole accepted window. */
@@ -89,6 +96,9 @@ export function cloudHomeConfiguration(env = process.env) {
89
96
  warnings.push(`ignoring ${ignored.join(", ")}: Cloud Pro includes no AI; people sign in with their own Claude or ChatGPT account, or an API key`);
90
97
  return { machineId, adminOrigin, publicOrigin, bootstrapSecret, warnings };
91
98
  }
99
+ // The secrets and their pipe live in cloud-secrets.ts, which the server
100
+ // reads before anything else (cloud-secrets-boot.ts).
101
+ export { CLOUD_HOME_SECRET_KEYS, CLOUD_SECRETS_FD_ENV, cloudHomeSecrets, takeCloudSecrets, withoutCloudSecrets } from "./cloud-secrets.js";
92
102
  /** The environment without a platform gateway's settings (CLOUD_IGNORED_KEYS). */
93
103
  export function withoutIgnoredCloudKeys(env) {
94
104
  const kept = { ...env };
@@ -115,6 +125,18 @@ export function cloudHomePlaceRefusal(place) {
115
125
  export function boatNotConfiguredMessage(cloudHome) {
116
126
  return `Cloud Boat is not configured — add a Boat API key or choose ${cloudHome ? "Browser" : "Local VM"}`;
117
127
  }
128
+ /** The Cloud's setup checklist (docs/cloud-pro.md) has a "try something"
129
+ * step that is done once a bot's turn finishes on the machine itself. The
130
+ * server records when, once, in this Cloud's own onboarding record: that
131
+ * section never travels with Move to Cloud (workspace-backup-policy.ts), so a
132
+ * moved-in history of turns does not count. Null when there is nothing to
133
+ * record: not a Cloud home, already recorded, a failed or stopped turn, or a
134
+ * thread that is no bot's conversation or room. */
135
+ export function firstCloudTurnPatch(turn) {
136
+ if (!turn.cloudHome || turn.recorded || !turn.ok || !turn.known)
137
+ return null;
138
+ return { onboarding: { firstTurnAt: (turn.now ?? new Date()).toISOString() } };
139
+ }
118
140
  /** The Admin's side of the signature (openmaus-cloud cloudPairingSignature). */
119
141
  export function cloudPairingSignature(secret, timestamp, nonce, body) {
120
142
  const bodyHash = createHash("sha256").update(body).digest("base64url");
@@ -164,18 +186,32 @@ export function createCloudPairing(options) {
164
186
  }
165
187
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
166
188
  return { status: 400, body: { error: "invalid_body" } };
167
- const { label, ttlSeconds } = parsed;
189
+ const { label, ttlSeconds, purpose, owner } = parsed;
168
190
  // oxlint-disable-next-line no-control-regex
169
191
  if (label !== undefined && (typeof label !== "string" || label.length > 80 || /[\x00-\x1f\x7f]/.test(label)))
170
192
  return { status: 400, body: { error: "invalid_label" } };
171
193
  if (ttlSeconds !== undefined && (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 1))
172
194
  return { status: 400, body: { error: "invalid_ttl" } };
173
- const ttl = Math.min(ttlSeconds ?? CLOUD_PAIRING_DEFAULT_TTL_S, CLOUD_PAIRING_MAX_TTL_S);
195
+ if (purpose !== undefined && purpose !== "browser")
196
+ return { status: 400, body: { error: "invalid_purpose" } };
197
+ const browser = purpose === "browser";
198
+ // A browser sign-in names its Cloud's owner (the account's email), which the sign-in page shows before the
199
+ // person continues: one address in printable ASCII, as the Admin's email schemas allow, with no `<` or `>`.
200
+ if ((browser || owner !== undefined) && (typeof owner !== "string" || owner.length > 254 || !OWNER_EMAIL.test(owner))) {
201
+ return { status: 400, body: { error: "invalid_owner" } };
202
+ }
203
+ const ttl = Math.min(ttlSeconds ?? CLOUD_PAIRING_DEFAULT_TTL_S, browser ? CLOUD_BROWSER_SIGN_IN_MAX_TTL_S : CLOUD_PAIRING_MAX_TTL_S);
174
204
  const opened = sessions.openPairing({
175
205
  scopes: ["admin", "client"],
176
206
  label: typeof label === "string" && label.trim() ? label.trim() : "OMB Cloud",
177
207
  ttlMs: ttl * 1000,
208
+ browser,
209
+ ...(browser ? { owner: owner } : {}),
178
210
  });
211
+ // A browser sign-in has no code to type: only its credential redeems it, and saying `purpose` back tells the
212
+ // Admin this machine made one (a machine from before this ignores `purpose` and opens an ordinary window).
213
+ if (browser)
214
+ return { status: 200, body: { credential: opened.credential, expiresAt: opened.expiresAt, purpose: "browser" } };
179
215
  return { status: 200, body: { code: formatPairingCode(opened.code), credential: opened.credential, expiresAt: opened.expiresAt } };
180
216
  },
181
217
  };