openmausbot 0.1.63 → 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.
- package/dist/assets/index-BftlaHAF.css +1 -0
- package/dist/assets/index-DuR3H-Hf.js +278 -0
- package/dist/assets/{index-C-SoIGdn.js → index-kW5L1QeO.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/browser-proxy.js +121 -0
- package/dist-server/container-mcp.js +4 -1
- package/dist-server/index.js +17996 -15876
- package/dist-server/openmausbot.js +752 -245
- package/dist-server/pair-cli.js +752 -245
- package/dist-server/prepare-cloudflared.js +10 -2
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/attachments.js +140 -16
- package/dist-server/server/browser-engine-release.js +10 -8
- package/dist-server/server/browser-engine.js +226 -26
- package/dist-server/server/browser-live.js +567 -0
- package/dist-server/server/browser-proxy.js +129 -0
- package/dist-server/server/browser-runtime.js +370 -0
- package/dist-server/server/claude-accounts.js +103 -0
- package/dist-server/server/config.js +31 -20
- package/dist-server/server/custom-domain.js +196 -0
- package/dist-server/server/drivers/claude.js +77 -19
- package/dist-server/server/drivers/codex-device-auth.js +234 -0
- package/dist-server/server/drivers/codex.js +10 -0
- package/dist-server/server/harness/bus.js +9 -4
- package/dist-server/server/harness/registry.js +15 -0
- package/dist-server/server/index.js +446 -91
- package/dist-server/server/provider-auth-sessions.js +118 -0
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/sse-fanout.js +53 -0
- package/dist-server/src/lib/composer-attachments.js +23 -6
- package/dist-server/vps-container-mcp.js +4 -1
- package/package.json +1 -1
- package/dist/assets/index-BuntoOSX.js +0 -264
- package/dist/assets/index-oPMv6a_k.css +0 -1
|
@@ -35,6 +35,12 @@ var CLOUDFLARED_ASSETS = Object.freeze({
|
|
|
35
35
|
binarySha256: "fcfb02b575a52ca1af2e3267af4e1517bcdeb30ac48c834c69abaed3c0576ad2",
|
|
36
36
|
archive: false
|
|
37
37
|
}),
|
|
38
|
+
"linux-arm64": Object.freeze({
|
|
39
|
+
name: "cloudflared-linux-arm64",
|
|
40
|
+
sha256: "7747d94570fb390cf47dcb4f9555c193c6355cda9793f0d878d9049e5d6a7790",
|
|
41
|
+
binarySha256: "7747d94570fb390cf47dcb4f9555c193c6355cda9793f0d878d9049e5d6a7790",
|
|
42
|
+
archive: false
|
|
43
|
+
}),
|
|
38
44
|
"win32-x64": Object.freeze({
|
|
39
45
|
name: "cloudflared-windows-amd64.exe",
|
|
40
46
|
sha256: "c29eee2b121f5436a642eed69fd9767da7e7b8c510fa50aaa130337f931357b5",
|
|
@@ -97,8 +103,10 @@ function executableTarget(value) {
|
|
|
97
103
|
if (cpu === 16777223) return "darwin-x64";
|
|
98
104
|
throw new Error(`unsupported cloudflared Mach-O CPU type 0x${cpu.toString(16)}`);
|
|
99
105
|
}
|
|
100
|
-
if (bytes.length >= 20 && bytes.subarray(0, 4).equals(Buffer.from([127, 69, 76, 70])) && bytes[4] === 2 && bytes[5] === 1
|
|
101
|
-
|
|
106
|
+
if (bytes.length >= 20 && bytes.subarray(0, 4).equals(Buffer.from([127, 69, 76, 70])) && bytes[4] === 2 && bytes[5] === 1) {
|
|
107
|
+
const machine = bytes.readUInt16LE(18);
|
|
108
|
+
if (machine === 62) return "linux-x64";
|
|
109
|
+
if (machine === 183) return "linux-arm64";
|
|
102
110
|
}
|
|
103
111
|
if (bytes.length >= 64 && bytes[0] === 77 && bytes[1] === 90) {
|
|
104
112
|
const pe = bytes.readUInt32LE(60);
|
|
@@ -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 = {
|
|
@@ -35,12 +55,24 @@ const IMAGE_MIMES = {
|
|
|
35
55
|
"image/gif": ".gif",
|
|
36
56
|
"image/webp": ".webp",
|
|
37
57
|
};
|
|
38
|
-
/** Useful document formats accepted by the
|
|
58
|
+
/** Useful document and audio formats accepted by the upload endpoint.
|
|
39
59
|
* Generic archives, binaries, HTML, SVG, and executable/script mimes stay
|
|
40
60
|
* out. Office/OpenDocument packages are allowed because they are documents,
|
|
41
61
|
* despite using ZIP internally. The claimed mime determines the extension;
|
|
42
62
|
* an attacker-controlled filename never does. */
|
|
43
63
|
const FILE_MIMES = {
|
|
64
|
+
"audio/opus": ".opus",
|
|
65
|
+
"audio/ogg": ".ogg",
|
|
66
|
+
"audio/mpeg": ".mp3",
|
|
67
|
+
"audio/mp4": ".m4a",
|
|
68
|
+
"audio/x-m4a": ".m4a",
|
|
69
|
+
"audio/aac": ".aac",
|
|
70
|
+
"audio/wav": ".wav",
|
|
71
|
+
"audio/x-wav": ".wav",
|
|
72
|
+
"audio/wave": ".wav",
|
|
73
|
+
"audio/flac": ".flac",
|
|
74
|
+
"audio/x-flac": ".flac",
|
|
75
|
+
"audio/webm": ".webm",
|
|
44
76
|
"text/plain": ".txt",
|
|
45
77
|
"text/markdown": ".md",
|
|
46
78
|
"text/csv": ".csv",
|
|
@@ -76,7 +108,10 @@ export function validateAttachmentUploadId(value) {
|
|
|
76
108
|
}
|
|
77
109
|
return normalized;
|
|
78
110
|
}
|
|
79
|
-
|
|
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()) {
|
|
80
115
|
ensureAttachmentsDir();
|
|
81
116
|
let bytes = 0;
|
|
82
117
|
let removed = 0;
|
|
@@ -105,10 +140,62 @@ function attachmentUsageAndCleanup(now = Date.now()) {
|
|
|
105
140
|
}
|
|
106
141
|
return { bytes, removed };
|
|
107
142
|
}
|
|
108
|
-
/**
|
|
109
|
-
*
|
|
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. */
|
|
110
168
|
export function cleanupStaleAttachmentPartials(now = Date.now()) {
|
|
111
|
-
|
|
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();
|
|
112
199
|
}
|
|
113
200
|
/** A retry owns the same UUID namespace as the interrupted attempt. Once it
|
|
114
201
|
* holds that UUID's in-process lock, any inactive partial with the prefix is
|
|
@@ -124,7 +211,14 @@ function cleanupAttachmentPartialsForUpload(uploadId) {
|
|
|
124
211
|
if (activePartials.has(path))
|
|
125
212
|
continue;
|
|
126
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;
|
|
127
218
|
unlinkSync(path);
|
|
219
|
+
if (committedAttachmentBytes !== null) {
|
|
220
|
+
committedAttachmentBytes = Math.max(0, committedAttachmentBytes - size);
|
|
221
|
+
}
|
|
128
222
|
}
|
|
129
223
|
catch {
|
|
130
224
|
// A concurrent cleanup already removed the abandoned attempt.
|
|
@@ -140,7 +234,7 @@ class AttachmentReservation {
|
|
|
140
234
|
this.reserveAtLeast(bytes, bytes);
|
|
141
235
|
}
|
|
142
236
|
reserveAtLeast(required, preferred) {
|
|
143
|
-
const used =
|
|
237
|
+
const used = committedBytes();
|
|
144
238
|
const available = ATTACHMENTS_MAX_BYTES - used - reservedAttachmentBytes;
|
|
145
239
|
if (available < required) {
|
|
146
240
|
throw statusError(507, `attachments storage is full (limit ${ATTACHMENTS_MAX_BYTES} bytes)`);
|
|
@@ -187,15 +281,34 @@ async function withUploadLock(uploadId, operation) {
|
|
|
187
281
|
uploadLocks.delete(uploadId);
|
|
188
282
|
}
|
|
189
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. */
|
|
190
303
|
function committedPathForUpload(uploadId, extension) {
|
|
191
304
|
ensureAttachmentsDir();
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
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)
|
|
195
310
|
continue;
|
|
196
|
-
if (
|
|
197
|
-
return join(ATTACHMENTS_DIR, entry.name);
|
|
198
|
-
if (entry.name.startsWith(`${uploadId}.`)) {
|
|
311
|
+
if (statSizeIfFile(join(ATTACHMENTS_DIR, `${uploadId}${other}`)) !== null) {
|
|
199
312
|
throw statusError(409, "uploadId was already used for another content type");
|
|
200
313
|
}
|
|
201
314
|
}
|
|
@@ -225,7 +338,7 @@ export function extensionForFileMime(mime) {
|
|
|
225
338
|
export function sanitizeSharedFileName(name, mime) {
|
|
226
339
|
const extension = extensionForFileMime(mime);
|
|
227
340
|
if (!extension)
|
|
228
|
-
throw statusError(400, "content-type must be a supported document type");
|
|
341
|
+
throw statusError(400, "content-type must be a supported document or audio type");
|
|
229
342
|
const normalized = name.normalize("NFKC").trim();
|
|
230
343
|
if (!normalized)
|
|
231
344
|
throw statusError(400, "name is required");
|
|
@@ -256,7 +369,7 @@ export function sanitizeSharedFileName(name, mime) {
|
|
|
256
369
|
export async function saveFile(chunks, originalName, mime, options = {}) {
|
|
257
370
|
const normalized = normalizedMime(mime);
|
|
258
371
|
if (!normalized || !extensionForFileMime(normalized)) {
|
|
259
|
-
throw statusError(400, "content-type must be a supported document type");
|
|
372
|
+
throw statusError(400, "content-type must be a supported document or audio type");
|
|
260
373
|
}
|
|
261
374
|
const name = sanitizeSharedFileName(originalName, normalized);
|
|
262
375
|
const extension = extensionForFileMime(normalized);
|
|
@@ -305,6 +418,7 @@ export async function saveFile(chunks, originalName, mime, options = {}) {
|
|
|
305
418
|
let file;
|
|
306
419
|
let bytes = 0;
|
|
307
420
|
let closed = false;
|
|
421
|
+
let partialCleanupFailed = false;
|
|
308
422
|
try {
|
|
309
423
|
file = await open(partialPath, "wx", 0o600);
|
|
310
424
|
for await (const value of chunks) {
|
|
@@ -331,7 +445,9 @@ export async function saveFile(chunks, originalName, mime, options = {}) {
|
|
|
331
445
|
await file.close();
|
|
332
446
|
closed = true;
|
|
333
447
|
try {
|
|
448
|
+
const accountingVersion = attachmentAccountingVersion;
|
|
334
449
|
await link(partialPath, path);
|
|
450
|
+
addCommittedBytes(bytes, accountingVersion);
|
|
335
451
|
await unlink(partialPath);
|
|
336
452
|
}
|
|
337
453
|
catch (error) {
|
|
@@ -347,11 +463,15 @@ export async function saveFile(chunks, originalName, mime, options = {}) {
|
|
|
347
463
|
catch (error) {
|
|
348
464
|
if (file && !closed)
|
|
349
465
|
await file.close().catch(() => undefined);
|
|
350
|
-
await unlink(partialPath).catch(() =>
|
|
466
|
+
await unlink(partialPath).catch(() => { partialCleanupFailed = true; });
|
|
351
467
|
throw error;
|
|
352
468
|
}
|
|
353
469
|
finally {
|
|
354
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();
|
|
355
475
|
reservation.release();
|
|
356
476
|
}
|
|
357
477
|
});
|
|
@@ -385,10 +505,12 @@ export function saveImage(bytes, mime, requestedUploadId) {
|
|
|
385
505
|
const path = join(ATTACHMENTS_DIR, name);
|
|
386
506
|
const partialPath = join(ATTACHMENTS_DIR, `.openmaus-upload-${id}-${randomUUID()}.partial`);
|
|
387
507
|
activePartials.add(partialPath);
|
|
508
|
+
let partialCleanupFailed = false;
|
|
388
509
|
try {
|
|
389
510
|
writeFileSync(partialPath, bytes, { mode: 0o600, flag: "wx" });
|
|
390
511
|
try {
|
|
391
512
|
linkSync(partialPath, path);
|
|
513
|
+
addCommittedBytes(bytes.byteLength);
|
|
392
514
|
}
|
|
393
515
|
catch (error) {
|
|
394
516
|
if (error.code !== "EEXIST")
|
|
@@ -406,12 +528,14 @@ export function saveImage(bytes, mime, requestedUploadId) {
|
|
|
406
528
|
unlinkSync(partialPath);
|
|
407
529
|
}
|
|
408
530
|
catch {
|
|
409
|
-
|
|
531
|
+
partialCleanupFailed = true;
|
|
410
532
|
}
|
|
411
533
|
throw error;
|
|
412
534
|
}
|
|
413
535
|
finally {
|
|
414
536
|
activePartials.delete(partialPath);
|
|
537
|
+
if (partialCleanupFailed)
|
|
538
|
+
invalidateAttachmentAccounting();
|
|
415
539
|
reservation.release();
|
|
416
540
|
}
|
|
417
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-
|
|
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.
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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.
|
|
191
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
485
|
+
unlinkSync(path);
|
|
304
486
|
}
|
|
305
|
-
catch {
|
|
306
|
-
|
|
487
|
+
catch (error) {
|
|
488
|
+
if (error.code !== "ENOENT")
|
|
489
|
+
throw error;
|
|
307
490
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
}
|