openmausbot 0.1.65 → 0.1.66

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.
@@ -8,6 +8,7 @@ function resolveProxy(relative) {
8
8
  return existsSync(source) ? source : join(SERVER_ROOT, `${relative}.js`);
9
9
  }
10
10
  var SPAWNED_PROXIES = {
11
+ browser: resolveProxy("browser-proxy"),
11
12
  computer: resolveProxy("computer-proxy"),
12
13
  permission: resolveProxy("permission-proxy"),
13
14
  containerMcp: resolveProxy("container-mcp"),
@@ -27,6 +27,26 @@ const activePartials = new Set();
27
27
  const uploadLocks = new Map();
28
28
  let reservedAttachmentBytes = 0;
29
29
  const STREAM_RESERVATION_INCREMENT_BYTES = 1024 * 1024;
30
+ /** Cached total of on-disk bytes that count against the quota: committed
31
+ * attachments plus any partial file this process did not itself write (a
32
+ * crash leftover, until it is cleaned up). `null` means a fresh scan is needed
33
+ * after cold start or a cleanup failure. Every normal commit
34
+ * and delete after that updates it in place instead of rescanning, which is
35
+ * what makes reservation growth O(1) rather than O(directory size).
36
+ *
37
+ * This is single-process, in-memory state: it does not see files another
38
+ * process adds or removes in ATTACHMENTS_DIR. That is an accepted limit
39
+ * (the app runs one server per data dir; uploadLocks already assumes the
40
+ * same), not a design gap this fix covers. */
41
+ let committedAttachmentBytes = null;
42
+ // An async link can finish while another upload invalidates or refreshes the
43
+ // cache. Its bytes must not be added again if a scan may already include it.
44
+ let attachmentAccountingVersion = 0;
45
+ /** Force a fresh disk total and invalidate any in-flight commit's snapshot. */
46
+ function invalidateAttachmentAccounting() {
47
+ committedAttachmentBytes = null;
48
+ attachmentAccountingVersion += 1;
49
+ }
30
50
  /** Mimes the endpoint accepts, mapped to the extension stored on disk.
31
51
  * Sniffing is not attempted — a lie here only changes the filename. */
32
52
  const IMAGE_MIMES = {
@@ -88,7 +108,10 @@ export function validateAttachmentUploadId(value) {
88
108
  }
89
109
  return normalized;
90
110
  }
91
- function attachmentUsageAndCleanup(now = Date.now()) {
111
+ /** The one full directory walk. Run from cold or invalidated accounting,
112
+ * or from an explicit cleanup sweep — never
113
+ * from the per-reservation hot path, which is what made PERF-03 quadratic. */
114
+ function scanAttachmentUsageAndCleanup(now = Date.now()) {
92
115
  ensureAttachmentsDir();
93
116
  let bytes = 0;
94
117
  let removed = 0;
@@ -117,10 +140,62 @@ function attachmentUsageAndCleanup(now = Date.now()) {
117
140
  }
118
141
  return { bytes, removed };
119
142
  }
120
- /** Remove only abandoned temporary uploads. Active partials are protected
121
- * even if a very slow request crosses the age threshold. */
143
+ /** Return cached usage, initializing it from disk after cold start or
144
+ * invalidation. Reservations must use this rather than trusting a cold cache. */
145
+ function committedBytes() {
146
+ if (committedAttachmentBytes === null) {
147
+ committedAttachmentBytes = scanAttachmentUsageAndCleanup().bytes;
148
+ attachmentAccountingVersion += 1;
149
+ }
150
+ return committedAttachmentBytes;
151
+ }
152
+ /** A newly committed file (saveFile/saveImage success path only — never the
153
+ * idempotent-retry branches, which reuse bytes already counted) adds to the
154
+ * cache. If accounting changed during an async link, a scan may already have
155
+ * counted that file. Let the next reservation rescan instead of adding twice. */
156
+ function addCommittedBytes(delta, version = attachmentAccountingVersion) {
157
+ if (version !== attachmentAccountingVersion || committedAttachmentBytes === null) {
158
+ invalidateAttachmentAccounting();
159
+ return;
160
+ }
161
+ committedAttachmentBytes += delta;
162
+ }
163
+ /** Remove only abandoned temporary uploads, and refresh the cached usage
164
+ * total from the same fresh scan. Outside accounting recovery, this is the
165
+ * only full directory walk after start-up; nothing on the upload path calls it
166
+ * per byte or per chunk anymore, so callers that want stale partials
167
+ * reclaimed promptly should invoke it on their own schedule. */
122
168
  export function cleanupStaleAttachmentPartials(now = Date.now()) {
123
- return attachmentUsageAndCleanup(now).removed;
169
+ const { bytes, removed } = scanAttachmentUsageAndCleanup(now);
170
+ committedAttachmentBytes = bytes;
171
+ attachmentAccountingVersion += 1;
172
+ return removed;
173
+ }
174
+ /** Delete a committed attachment (e.g. a generated image whose owning
175
+ * message/turn was retired) and keep the quota cache in sync. Every caller
176
+ * outside this module that removes a file saveImage/saveFile created must
177
+ * route through here instead of a raw unlink, or the cache will overcount
178
+ * forever and eventually reject uploads that should fit. */
179
+ export function deleteAttachment(path) {
180
+ try {
181
+ // Only bother sizing it if the cache is already warm — if it isn't, the
182
+ // eventual first scan just won't see this file, which is correct.
183
+ const size = committedAttachmentBytes !== null ? statSync(path).size : 0;
184
+ unlinkSync(path);
185
+ if (committedAttachmentBytes !== null) {
186
+ committedAttachmentBytes = Math.max(0, committedAttachmentBytes - size);
187
+ }
188
+ }
189
+ catch {
190
+ // Already gone, or never existed — nothing to reconcile.
191
+ }
192
+ }
193
+ /** Test-only: force the next quota check to rescan the directory instead of
194
+ * trusting the cache. Needed because tests mutate ATTACHMENTS_DIR directly
195
+ * (truncateSync, rmSync, writeFileSync) to simulate quota states, which the
196
+ * cache can't observe. */
197
+ export function __resetAttachmentAccountingForTests() {
198
+ invalidateAttachmentAccounting();
124
199
  }
125
200
  /** A retry owns the same UUID namespace as the interrupted attempt. Once it
126
201
  * holds that UUID's in-process lock, any inactive partial with the prefix is
@@ -136,7 +211,14 @@ function cleanupAttachmentPartialsForUpload(uploadId) {
136
211
  if (activePartials.has(path))
137
212
  continue;
138
213
  try {
214
+ // Only bother sizing it if the cache is already warm — if it isn't,
215
+ // the eventual first scan runs after this unlink and simply never
216
+ // sees the file, so there is nothing to reconcile.
217
+ const size = committedAttachmentBytes !== null ? statSync(path).size : 0;
139
218
  unlinkSync(path);
219
+ if (committedAttachmentBytes !== null) {
220
+ committedAttachmentBytes = Math.max(0, committedAttachmentBytes - size);
221
+ }
140
222
  }
141
223
  catch {
142
224
  // A concurrent cleanup already removed the abandoned attempt.
@@ -152,7 +234,7 @@ class AttachmentReservation {
152
234
  this.reserveAtLeast(bytes, bytes);
153
235
  }
154
236
  reserveAtLeast(required, preferred) {
155
- const used = attachmentUsageAndCleanup().bytes;
237
+ const used = committedBytes();
156
238
  const available = ATTACHMENTS_MAX_BYTES - used - reservedAttachmentBytes;
157
239
  if (available < required) {
158
240
  throw statusError(507, `attachments storage is full (limit ${ATTACHMENTS_MAX_BYTES} bytes)`);
@@ -199,15 +281,34 @@ async function withUploadLock(uploadId, operation) {
199
281
  uploadLocks.delete(uploadId);
200
282
  }
201
283
  }
284
+ /** Every committed attachment name is `${uuid}${extension}` for one of these
285
+ * fixed extensions — saveFile/saveImage never write any other suffix — so
286
+ * whether a given upload ID is already committed, and under which content
287
+ * type, can be answered with a handful of direct stats instead of a scan
288
+ * over every file in the directory. */
289
+ const KNOWN_ATTACHMENT_EXTENSIONS = Array.from(new Set([...Object.values(IMAGE_MIMES), ...Object.values(FILE_MIMES)]));
290
+ /** Missing or unreadable candidates count as absent here; the atomic link
291
+ * still detects a collision if a file exists when an upload commits. */
292
+ function statSizeIfFile(path) {
293
+ try {
294
+ const stat = statSync(path);
295
+ return stat.isFile() ? stat.size : null;
296
+ }
297
+ catch {
298
+ return null;
299
+ }
300
+ }
301
+ /** Find an idempotent retry without listing the directory, rejecting an ID
302
+ * already committed under a different accepted content type. */
202
303
  function committedPathForUpload(uploadId, extension) {
203
304
  ensureAttachmentsDir();
204
- const exact = `${uploadId}${extension}`;
205
- for (const entry of readdirSync(ATTACHMENTS_DIR, { withFileTypes: true })) {
206
- if (!entry.isFile() || PARTIAL_NAME.test(entry.name))
305
+ const exactPath = join(ATTACHMENTS_DIR, `${uploadId}${extension}`);
306
+ if (statSizeIfFile(exactPath) !== null)
307
+ return exactPath;
308
+ for (const other of KNOWN_ATTACHMENT_EXTENSIONS) {
309
+ if (other === extension)
207
310
  continue;
208
- if (entry.name === exact)
209
- return join(ATTACHMENTS_DIR, entry.name);
210
- if (entry.name.startsWith(`${uploadId}.`)) {
311
+ if (statSizeIfFile(join(ATTACHMENTS_DIR, `${uploadId}${other}`)) !== null) {
211
312
  throw statusError(409, "uploadId was already used for another content type");
212
313
  }
213
314
  }
@@ -317,6 +418,7 @@ export async function saveFile(chunks, originalName, mime, options = {}) {
317
418
  let file;
318
419
  let bytes = 0;
319
420
  let closed = false;
421
+ let partialCleanupFailed = false;
320
422
  try {
321
423
  file = await open(partialPath, "wx", 0o600);
322
424
  for await (const value of chunks) {
@@ -343,7 +445,9 @@ export async function saveFile(chunks, originalName, mime, options = {}) {
343
445
  await file.close();
344
446
  closed = true;
345
447
  try {
448
+ const accountingVersion = attachmentAccountingVersion;
346
449
  await link(partialPath, path);
450
+ addCommittedBytes(bytes, accountingVersion);
347
451
  await unlink(partialPath);
348
452
  }
349
453
  catch (error) {
@@ -359,11 +463,15 @@ export async function saveFile(chunks, originalName, mime, options = {}) {
359
463
  catch (error) {
360
464
  if (file && !closed)
361
465
  await file.close().catch(() => undefined);
362
- await unlink(partialPath).catch(() => undefined);
466
+ await unlink(partialPath).catch(() => { partialCleanupFailed = true; });
363
467
  throw error;
364
468
  }
365
469
  finally {
366
470
  activePartials.delete(partialPath);
471
+ // A leftover partial was excluded from the cache while active. Rescan
472
+ // after releasing it so a retry cannot subtract bytes never counted.
473
+ if (partialCleanupFailed)
474
+ invalidateAttachmentAccounting();
367
475
  reservation.release();
368
476
  }
369
477
  });
@@ -397,10 +505,12 @@ export function saveImage(bytes, mime, requestedUploadId) {
397
505
  const path = join(ATTACHMENTS_DIR, name);
398
506
  const partialPath = join(ATTACHMENTS_DIR, `.openmaus-upload-${id}-${randomUUID()}.partial`);
399
507
  activePartials.add(partialPath);
508
+ let partialCleanupFailed = false;
400
509
  try {
401
510
  writeFileSync(partialPath, bytes, { mode: 0o600, flag: "wx" });
402
511
  try {
403
512
  linkSync(partialPath, path);
513
+ addCommittedBytes(bytes.byteLength);
404
514
  }
405
515
  catch (error) {
406
516
  if (error.code !== "EEXIST")
@@ -418,12 +528,14 @@ export function saveImage(bytes, mime, requestedUploadId) {
418
528
  unlinkSync(partialPath);
419
529
  }
420
530
  catch {
421
- // The successful path already removed it, or the write never created it.
531
+ partialCleanupFailed = true;
422
532
  }
423
533
  throw error;
424
534
  }
425
535
  finally {
426
536
  activePartials.delete(partialPath);
537
+ if (partialCleanupFailed)
538
+ invalidateAttachmentAccounting();
427
539
  reservation.release();
428
540
  }
429
541
  }
@@ -1,37 +1,39 @@
1
1
  // The pinned agent-browser release the harness downloads for the bots'
2
2
  // browser (docs/plans/browser-engine.md). Digests were computed from the
3
- // official GitHub assets on 2026-09-06, except the explicitly versioned Windows
3
+ // official GitHub assets on 2026-09-08, except the explicitly versioned Windows
4
4
  // vendor build below. Update pins through a reviewed native end-to-end run.
5
5
  // The Dockerfile retains the official Linux version.
6
- export const AGENT_BROWSER_VERSION = "0.36.0";
6
+ export const AGENT_BROWSER_VERSION = "0.37.0";
7
7
  const RELEASES = new Map([
8
8
  [
9
9
  "darwin-arm64",
10
- { target: "darwin-arm64", asset: "agent-browser-darwin-arm64", sha256: "b2106ab39db0838e7b1772f7f26f760518de56d09053150c56f9dddf15af997d", bytes: 12363200 },
10
+ { target: "darwin-arm64", asset: "agent-browser-darwin-arm64", sha256: "da5a2b4ef7be8ba279b1258c542c33877f480b1951d0d94de607fc1528edd380", bytes: 12429360 },
11
11
  ],
12
12
  [
13
13
  "darwin-x64",
14
- { target: "darwin-x64", asset: "agent-browser-darwin-x64", sha256: "45d9ac061a7d72e61eaff905326e2e19365f4dadb12142ea2f2d76d84689c708", bytes: 13510280 },
14
+ { target: "darwin-x64", asset: "agent-browser-darwin-x64", sha256: "f402c96350ffd2adc68d0e5e4d83aa49cf7735fef29882a8866be36cc2fc67e9", bytes: 13588296 },
15
15
  ],
16
16
  [
17
17
  "linux-arm64",
18
- { target: "linux-arm64", asset: "agent-browser-linux-arm64", sha256: "aeb556addca3903601a433de1acad3ace1c9c61d170084bf58d875884599a990", bytes: 12442720 },
18
+ { target: "linux-arm64", asset: "agent-browser-linux-arm64", sha256: "0315a8c4f7bf167cc5fd5eeaea79009b0e6b5d2ae8b3057bf6f96be978483426", bytes: 12507840 },
19
19
  ],
20
20
  [
21
21
  "linux-musl-arm64",
22
- { target: "linux-musl-arm64", asset: "agent-browser-linux-musl-arm64", sha256: "1ca7e003c9cb185f174fc81e51a609db27c77e3bfe00a0edff60688f8cd14f88", bytes: 12297000 },
22
+ { target: "linux-musl-arm64", asset: "agent-browser-linux-musl-arm64", sha256: "db67c0e84e0668c052c2cbdca325c57f896e309c3723755dcdd65b700f8a2b52", bytes: 12369352 },
23
23
  ],
24
24
  [
25
25
  "linux-musl-x64",
26
- { target: "linux-musl-x64", asset: "agent-browser-linux-musl-x64", sha256: "a20cc2a5202a48f5820372803dedbcd5f556dff7a89421f1b0f2612962b10718", bytes: 13995728 },
26
+ { target: "linux-musl-x64", asset: "agent-browser-linux-musl-x64", sha256: "5cff3bc7b2486867aba2901f92c9ce6029bd2a37951630d9feec1c5b3fee402e", bytes: 14092888 },
27
27
  ],
28
28
  [
29
29
  "linux-x64",
30
- { target: "linux-x64", asset: "agent-browser-linux-x64", sha256: "56d15181e51e00213f907fcf39707cfc76bfa804ff20f5a9373661c73f96de5e", bytes: 14156776 },
30
+ { target: "linux-x64", asset: "agent-browser-linux-x64", sha256: "78e0c5a14a7fa1f3d1ae2acdbdcc94a047b435b998a8c505fcc49d7fa4935a49", bytes: 14253776 },
31
31
  ],
32
32
  [
33
33
  "win32-x64",
34
34
  {
35
+ // Upstream 0.37.0 still lacks PR #1781's Windows cold-start fix.
36
+ // Retain the native-verified revision until its replacement is tested.
35
37
  target: "win32-x64", version: "0.36.0-omb.1",
36
38
  asset: "agent-browser-win32-x64-0.36.0-omb.1.exe",
37
39
  url: "https://github.com/milind-soni/OpenMausBot/releases/download/browser-engine-v0.36.0-omb.1/agent-browser-win32-x64-0.36.0-omb.1.exe",
@@ -10,16 +10,182 @@
10
10
  // reason a person can act on, never a silently browserless bot.
11
11
  import { spawn } from "node:child_process";
12
12
  import { createHash, randomBytes, randomUUID } from "node:crypto";
13
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
14
- import { tmpdir } from "node:os";
15
- import { delimiter, join, resolve } from "node:path";
13
+ import { chmodSync, constants, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
14
+ import { homedir, tmpdir } from "node:os";
15
+ import { delimiter, dirname, join, resolve } from "node:path";
16
16
  import { writeFileAtomic } from "./atomic.js";
17
17
  import { DATA_DIR } from "./config.js";
18
18
  import { browserBundlePaths } from "./browser-bundle-release.js";
19
+ import { browserRuntimeEnv } from "./browser-runtime.js";
19
20
  import { AGENT_BROWSER_VERSION, agentBrowserReleaseUrl, agentBrowserReleaseVersion, resolveAgentBrowserReleaseAsset, } from "./browser-engine-release.js";
20
21
  const ENGINE_DIR = "tools/agent-browser";
21
22
  const KEY_FILE = "browser-engine-key";
22
23
  const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
24
+ /** Native restore looks up a filename prefix. Fixed-length keys prevent a
25
+ * legacy profile named work from accidentally restoring work-client. */
26
+ export function browserRestoreKey(session) {
27
+ if (!/^[A-Za-z0-9_-]{1,96}$/.test(session))
28
+ throw new Error("Invalid browser session.");
29
+ return `omb-${createHash("sha256").update(session).digest("hex")}`;
30
+ }
31
+ function browserSessionsDirectory(env) {
32
+ const namespace = env.AGENT_BROWSER_NAMESPACE;
33
+ if (namespace && !/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(namespace))
34
+ throw new Error("Invalid browser namespace.");
35
+ const home = (process.platform === "win32" ? env.USERPROFILE : env.HOME) || homedir();
36
+ const stateRoot = namespace ? join(home, ".agent-browser", "namespaces", namespace, "state") : join(home, ".agent-browser");
37
+ return join(stateRoot, "sessions");
38
+ }
39
+ function managedBrowserConfigPath(env) {
40
+ return join(browserSessionsDirectory(env), "..", "omb-managed-config.json");
41
+ }
42
+ function ensureManagedBrowserConfig(env) {
43
+ const path = managedBrowserConfigPath(env);
44
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
45
+ try {
46
+ writeFileSync(path, "{}\n", { flag: "wx", mode: 0o600 });
47
+ }
48
+ catch (error) {
49
+ if (error.code !== "EEXIST")
50
+ throw error;
51
+ if (readFileSync(path, "utf8").trim() !== "{}")
52
+ throw new Error("The managed browser configuration was changed. Restore it to an empty JSON object before connecting.");
53
+ }
54
+ return path;
55
+ }
56
+ /** Close exactly one daemon without the CLI's implicit launch envelope. */
57
+ export async function closeBrowserSession(binaryPath, env, timeoutMs = 15_000) {
58
+ if (!env.AGENT_BROWSER_SESSION || !/^[A-Za-z0-9_-]{1,96}$/.test(env.AGENT_BROWSER_SESSION))
59
+ return false;
60
+ // Native CLI prepends a launch even to `close` when launch flags are set.
61
+ // Remove them, and bypass external project/user config, so closing a
62
+ // missing profile cannot launch and restore another legacy prefix match.
63
+ let configPath;
64
+ try {
65
+ configPath = ensureManagedBrowserConfig(env);
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ const closeEnv = Object.fromEntries(Object.entries(env).filter(([key]) => !key.startsWith("AGENT_BROWSER_") || ["AGENT_BROWSER_SESSION", "AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_NAMESPACE", "AGENT_BROWSER_ENCRYPTION_KEY", "AGENT_BROWSER_RESTORE", "AGENT_BROWSER_RESTORE_SAVE"].includes(key)));
71
+ closeEnv.AGENT_BROWSER_CONFIG = configPath;
72
+ const deadline = Date.now() + timeoutMs;
73
+ const run = (args, capture = false) => new Promise((done) => {
74
+ let settled = false;
75
+ let output = "";
76
+ const finish = (ok) => { if (!settled) {
77
+ settled = true;
78
+ done({ ok, output });
79
+ } };
80
+ let child;
81
+ try {
82
+ child = spawn(binaryPath, args, { env: closeEnv, stdio: capture ? ["ignore", "pipe", "ignore"] : "ignore", windowsHide: true });
83
+ }
84
+ catch {
85
+ return finish(false);
86
+ }
87
+ const timer = setTimeout(() => { child.kill(); finish(false); }, Math.max(1, deadline - Date.now()));
88
+ timer.unref?.();
89
+ child.stdout?.on("data", (chunk) => {
90
+ output += String(chunk);
91
+ if (output.length > 262_144) {
92
+ clearTimeout(timer);
93
+ child.kill();
94
+ finish(false);
95
+ }
96
+ });
97
+ child.on("error", () => { clearTimeout(timer); finish(false); });
98
+ // exit can precede the last piped stdout chunk; close follows stdio.
99
+ child.on("close", (code) => { clearTimeout(timer); finish(code === 0); });
100
+ });
101
+ if (!(await run(["close"])).ok)
102
+ return false;
103
+ // Native close acknowledges before the daemon exits. Observe its actual
104
+ // removal through the launch-free inventory command before reconnecting.
105
+ while (Date.now() < deadline) {
106
+ const status = await run(["session", "list", "--json"], true);
107
+ if (!status.ok)
108
+ return false;
109
+ try {
110
+ const result = JSON.parse(status.output);
111
+ const sessions = result?.data?.sessions;
112
+ if (result?.success !== true || !Array.isArray(sessions) || !sessions.every((name) => typeof name === "string"))
113
+ return false;
114
+ if (!sessions.includes(env.AGENT_BROWSER_SESSION))
115
+ return true;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(1, deadline - Date.now()))));
121
+ }
122
+ return false;
123
+ }
124
+ const preparedBrowserSessions = new Map();
125
+ /** Preserve only this profile's exact old file before adopting a collision-
126
+ * proof restore key. Originals stay intact; a reset removes both spellings.
127
+ * Closing first flushes even an old daemon's not-yet-autosaved login. */
128
+ export async function prepareBrowserSessionState(binaryPath, session, options = {}) {
129
+ const restoreKey = browserRestoreKey(session);
130
+ const check = () => { if (options.isCurrent?.() === false)
131
+ throw new Error("Browser access changed while connecting."); };
132
+ check();
133
+ const env = browserRuntimeEnv({ ...options.env, AGENT_BROWSER_SESSION: session, AGENT_BROWSER_HEADLESS: "1" });
134
+ ensureManagedBrowserConfig(env);
135
+ if (options.persistent === false)
136
+ return;
137
+ const directory = browserSessionsDirectory(env);
138
+ const key = join(directory, session);
139
+ let pending = preparedBrowserSessions.get(key);
140
+ if (!pending) {
141
+ pending = (async () => {
142
+ const targetBase = join(directory, `${restoreKey}-${session}`);
143
+ const hasTarget = () => existsSync(`${targetBase}.json.enc`) || existsSync(`${targetBase}.json`);
144
+ if (hasTarget())
145
+ return;
146
+ check();
147
+ // Retain the old key while flushing, never the new empty destination.
148
+ // A newer daemon flushes its own key before a key change; close itself
149
+ // never launches a browser, and we check for that new file afterward.
150
+ if (!await closeBrowserSession(binaryPath, { ...env, AGENT_BROWSER_RESTORE: session }, options.timeoutMs)) {
151
+ throw new Error("Could not safely prepare saved browser logins. Close this browser and try again.");
152
+ }
153
+ check();
154
+ if (hasTarget())
155
+ return; // a current daemon may just have flushed it.
156
+ const candidates = [".json.enc", ".json"].flatMap((suffix) => {
157
+ const path = join(directory, `${session}-${session}${suffix}`);
158
+ try {
159
+ const stat = statSync(path);
160
+ if (!stat.isFile())
161
+ throw new Error("Saved browser state is not a file.");
162
+ return [{ path, suffix, modified: stat.mtimeMs }];
163
+ }
164
+ catch (error) {
165
+ if (error.code === "ENOENT")
166
+ return [];
167
+ throw error;
168
+ }
169
+ }).sort((a, b) => b.modified - a.modified);
170
+ const source = candidates[0];
171
+ if (!source)
172
+ return;
173
+ try {
174
+ copyFileSync(source.path, targetBase + source.suffix, constants.COPYFILE_EXCL);
175
+ chmodSync(targetBase + source.suffix, 0o600);
176
+ }
177
+ catch (error) {
178
+ if (error.code !== "EEXIST")
179
+ throw error;
180
+ }
181
+ })();
182
+ preparedBrowserSessions.set(key, pending);
183
+ void pending.catch(() => { if (preparedBrowserSessions.get(key) === pending)
184
+ preparedBrowserSessions.delete(key); });
185
+ }
186
+ await pending;
187
+ check();
188
+ }
23
189
  function executableName(platform = process.platform) {
24
190
  return platform === "win32" ? "agent-browser.exe" : "agent-browser";
25
191
  }
@@ -181,21 +347,27 @@ export function browserEngineStatus(options = {}) {
181
347
  * auto-restored session per browser profile (or per bot), page-provided
182
348
  * WebMCP tools off, and only the core tool set. */
183
349
  export function agentBrowserIntegration(input) {
350
+ const sourceEnv = input.env ?? process.env;
184
351
  const env = {
185
352
  AGENT_BROWSER_SESSION: input.session,
186
353
  // The MCP server invokes child CLI commands without forwarding its own
187
354
  // global flags. The environment keeps page-provided tools disabled in
188
355
  // those commands too, and avoids changing browser launch settings later.
189
356
  AGENT_BROWSER_NO_WEBMCP: "1",
190
- // This is a restore *name*, not a boolean. "1" would give every bot
191
- // the same saved cookies despite using different daemon sessions.
192
- AGENT_BROWSER_RESTORE: input.session,
357
+ // This is a restore *name*, not a boolean. Use a fixed-length identity:
358
+ // upstream looks up name prefixes, not an exact daemon-session filename.
359
+ AGENT_BROWSER_RESTORE: browserRestoreKey(input.session),
193
360
  AGENT_BROWSER_RESTORE_SAVE: input.persistent === false ? "never" : "auto",
194
361
  AGENT_BROWSER_ENCRYPTION_KEY: input.encryptionKey,
362
+ // OMB owns launch settings. A user's unrelated native CLI config must not
363
+ // inject a shared Chrome profile or a different saved-state path.
364
+ AGENT_BROWSER_CONFIG: managedBrowserConfigPath(browserRuntimeEnv({
365
+ ...(sourceEnv.HOME ? { HOME: sourceEnv.HOME } : {}),
366
+ ...(sourceEnv.USERPROFILE ? { USERPROFILE: sourceEnv.USERPROFILE } : {}),
367
+ })),
195
368
  };
196
369
  if (input.headless !== false)
197
370
  env.AGENT_BROWSER_HEADLESS = "1";
198
- const sourceEnv = input.env ?? process.env;
199
371
  // MCP clients may filter the parent environment. Carry the configured
200
372
  // Chrome path explicitly without forwarding unrelated secrets or flags.
201
373
  for (const name of ["PATH", "AGENT_BROWSER_EXECUTABLE_PATH"]) {
@@ -226,7 +398,7 @@ export function agentBrowserFrame(input) {
226
398
  const file = join(tmpdir(), `openmausbot-browser-${randomUUID()}.png`);
227
399
  return new Promise((settle, fail) => {
228
400
  const child = spawn(input.binaryPath, ["screenshot", file], {
229
- env: { ...process.env, ...input.env },
401
+ env: browserRuntimeEnv(input.env),
230
402
  stdio: ["ignore", "ignore", "pipe"],
231
403
  windowsHide: true,
232
404
  });
@@ -289,27 +461,55 @@ export const BUILT_IN_BROWSER_SYSTEM_PROMPT = " You have your own web browser th
289
461
  * profile is deleted. Best effort with a bound: a missing engine or an
290
462
  * already-empty session are both "done". */
291
463
  export async function clearBrowserSessionState(binaryPath, session, options = {}) {
292
- const env = { ...(options.env ?? process.env), AGENT_BROWSER_SESSION: session, AGENT_BROWSER_HEADLESS: "1" };
464
+ // The native state-clear CLI ignores the daemon session, and even its named
465
+ // form currently drops that name before dispatch. Never invoke it here.
466
+ if (!/^[A-Za-z0-9_-]{1,96}$/.test(session))
467
+ return false;
468
+ const env = browserRuntimeEnv({ ...options.env, AGENT_BROWSER_SESSION: session, AGENT_BROWSER_HEADLESS: "1" });
469
+ let directory;
470
+ try {
471
+ directory = browserSessionsDirectory(env);
472
+ }
473
+ catch {
474
+ return false;
475
+ }
293
476
  if (options.encryptionKey)
294
477
  env.AGENT_BROWSER_ENCRYPTION_KEY = options.encryptionKey;
295
- const run = (args) => new Promise((done) => {
296
- let settled = false;
297
- const finish = (ok) => { if (!settled) {
298
- settled = true;
299
- done(ok);
300
- } };
301
- let child;
478
+ // A failed close can still autosave later. Keep its state until it is safe
479
+ // to remove, rather than reporting a reset that silently comes back.
480
+ if (!await closeBrowserSession(binaryPath, env, options.timeoutMs))
481
+ return false;
482
+ const bases = [`${session}-${session}`, `${browserRestoreKey(session)}-${session}`];
483
+ const remove = (path) => {
302
484
  try {
303
- child = spawn(binaryPath, args, { env, stdio: "ignore", windowsHide: true });
485
+ unlinkSync(path);
304
486
  }
305
- catch {
306
- return finish(false);
487
+ catch (error) {
488
+ if (error.code !== "ENOENT")
489
+ throw error;
307
490
  }
308
- const timer = setTimeout(() => { child.kill(); finish(false); }, options.timeoutMs ?? 15_000);
309
- timer.unref?.();
310
- child.on("error", () => { clearTimeout(timer); finish(false); });
311
- child.on("exit", (code) => { clearTimeout(timer); finish(code === 0); });
312
- });
313
- await run(["close"]);
314
- return run(["state", "clear", "--all"]);
491
+ };
492
+ try {
493
+ for (const base of bases)
494
+ for (const suffix of [".json", ".json.enc", ".json.previous", ".json.enc.previous"])
495
+ remove(join(directory, base + suffix));
496
+ const pendingDirectory = join(directory, ".tmp");
497
+ let pendingFiles = [];
498
+ try {
499
+ pendingFiles = readdirSync(pendingDirectory);
500
+ }
501
+ catch (error) {
502
+ if (error.code !== "ENOENT")
503
+ throw error;
504
+ }
505
+ const candidates = bases.map((base) => new RegExp(`^${base}-candidate-[0-9]+\\.json(?:\\.enc)?$`));
506
+ for (const name of pendingFiles)
507
+ if (candidates.some((candidate) => candidate.test(name)))
508
+ remove(join(pendingDirectory, name));
509
+ preparedBrowserSessions.delete(join(directory, session));
510
+ return true;
511
+ }
512
+ catch {
513
+ return false;
514
+ }
315
515
  }