openmausbot 0.1.71 → 0.1.72
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-BFPYw6P-.js +296 -0
- package/dist/assets/{index-CJtyBonr.js → index-Bpai86ua.js} +1 -1
- package/dist/assets/index-D1odUdTi.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/companion/src/listener.js +12 -2
- package/dist-server/container-mcp.js +26 -2
- package/dist-server/index.js +7477 -2481
- package/dist-server/local-computer.js +4 -0
- package/dist-server/openmausbot.js +1589 -565
- package/dist-server/pair-cli.js +1589 -565
- package/dist-server/server/bot-overview.js +28 -0
- package/dist-server/server/browser-engine.js +2 -1
- package/dist-server/server/calendar-calls.js +1 -0
- package/dist-server/server/cli.js +102 -1
- package/dist-server/server/config.js +57 -5
- package/dist-server/server/drivers/claude.js +14 -7
- package/dist-server/server/fleet-agent.js +161 -0
- package/dist-server/server/fleet-cli.js +225 -0
- package/dist-server/server/fleet-client.js +45 -0
- package/dist-server/server/fleet.js +410 -0
- package/dist-server/server/index.js +316 -8
- package/dist-server/server/local-computer.js +4 -0
- package/dist-server/server/mcp-registry.js +71 -3
- package/dist-server/server/prices.js +16 -0
- package/dist-server/server/provider-auth-sessions.js +3 -0
- package/dist-server/server/provider-key-check.js +66 -0
- package/dist-server/server/routines.js +1 -0
- package/dist-server/server/skill-learn.js +15 -1
- package/dist-server/server/spend.js +61 -0
- package/dist-server/server/system-prompt.js +15 -5
- package/dist-server/server/tailscale.js +2 -2
- package/dist-server/server/usage-ledger.js +239 -0
- package/dist-server/server/webhook-ingress.js +7 -2
- package/dist-server/server/workspace-backup-http.js +264 -0
- package/dist-server/server/workspace-backup-maintenance.js +40 -0
- package/dist-server/server/workspace-backup-policy.js +28 -0
- package/dist-server/server/workspace-backup.js +1022 -0
- package/dist-server/shared/learn-request.js +6 -0
- package/dist-server/shared/workspace-backup-client.js +15 -0
- package/dist-server/shared/workspace-backup.js +1 -0
- package/dist-server/vps-container-mcp.js +26 -2
- package/package.json +1 -1
- package/dist/assets/index-CYLdYtYp.css +0 -1
- package/dist/assets/index-Di4ovwu5.js +0 -289
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { Transform } from "node:stream";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { WORKSPACE_BACKUP_CLIENT_KEYS } from "../shared/workspace-backup-client.js";
|
|
8
|
+
import { createWorkspaceBackup, stageWorkspaceBackup, commitPendingWorkspaceRestore, MAX_WORKSPACE_BACKUP_UPLOAD_BYTES, removeWorkspaceBackupJob, } from "./workspace-backup.js";
|
|
9
|
+
const PREFIX = "/api/workspace-backup";
|
|
10
|
+
const MAX_UPLOAD_BYTES = MAX_WORKSPACE_BACKUP_UPLOAD_BYTES;
|
|
11
|
+
const MAX_CLIENT_STATE_BYTES = 2 * 1024 ** 2;
|
|
12
|
+
const EXPIRES_MS = 60 * 60_000;
|
|
13
|
+
const passwordSchema = z.string().min(12, "Use a password of at least 12 characters.").max(1024);
|
|
14
|
+
const failure = (message, status = 400) => Object.assign(new Error(message), { status });
|
|
15
|
+
/** Session-only controls remain usable while the workspace is locked. Their
|
|
16
|
+
* excluded session file is not part of the snapshot or replacement. */
|
|
17
|
+
export function isWorkspaceBackupSessionControl(method, path) {
|
|
18
|
+
return (method === "GET" && path === "/api/auth/sessions") ||
|
|
19
|
+
(method === "POST" && path === "/api/auth/logout") ||
|
|
20
|
+
(method === "DELETE" && /^\/api\/auth\/sessions\/[\w-]+$/.test(path));
|
|
21
|
+
}
|
|
22
|
+
/** All routes remain admin-only in request-auth. Passwords and raw credential
|
|
23
|
+
* metadata never appear in URLs, responses, logs, or event broadcasts. */
|
|
24
|
+
export function createWorkspaceBackupRoutes(options) {
|
|
25
|
+
// ponytail: one bounded set per server; transfers expire after an hour.
|
|
26
|
+
const artifacts = new Map();
|
|
27
|
+
let operating = false;
|
|
28
|
+
const json = (res, status, body) => {
|
|
29
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
30
|
+
res.end(JSON.stringify(body));
|
|
31
|
+
};
|
|
32
|
+
const owner = (auth) => auth.kind === "session" ? auth.session.id : "local-owner";
|
|
33
|
+
const check = (req, auth) => {
|
|
34
|
+
if (!options.authorized(req, auth))
|
|
35
|
+
throw failure("Your session changed. Reopen Backups and try again.", 403);
|
|
36
|
+
};
|
|
37
|
+
function remove(id, artifact) {
|
|
38
|
+
if (artifact.path) {
|
|
39
|
+
try {
|
|
40
|
+
unlinkSync(artifact.path);
|
|
41
|
+
}
|
|
42
|
+
catch { }
|
|
43
|
+
}
|
|
44
|
+
if (artifact.kind !== "upload") {
|
|
45
|
+
try {
|
|
46
|
+
removeWorkspaceBackupJob(options.dataDir, id);
|
|
47
|
+
}
|
|
48
|
+
catch { /* Recovery jobs are deliberately protected. */ }
|
|
49
|
+
}
|
|
50
|
+
artifacts.delete(id);
|
|
51
|
+
}
|
|
52
|
+
// A crashed process has no in-memory transfer map. Reclaim only its old,
|
|
53
|
+
// app-named jobs; never safety copies or pending/committed recovery state.
|
|
54
|
+
function reclaimAbandoned() {
|
|
55
|
+
const root = join(options.dataDir, ".backups");
|
|
56
|
+
if (!existsSync(root) || lstatSync(root).isSymbolicLink())
|
|
57
|
+
return;
|
|
58
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
59
|
+
if (!entry.isDirectory() || !/^[a-f0-9-]{36}$/.test(entry.name))
|
|
60
|
+
continue;
|
|
61
|
+
if (statSync(join(root, entry.name)).mtimeMs + EXPIRES_MS > Date.now())
|
|
62
|
+
continue;
|
|
63
|
+
try {
|
|
64
|
+
removeWorkspaceBackupJob(options.dataDir, entry.name);
|
|
65
|
+
}
|
|
66
|
+
catch { /* May belong to a recoverable restore. */ }
|
|
67
|
+
}
|
|
68
|
+
const uploads = join(root, "uploads");
|
|
69
|
+
if (!existsSync(uploads) || lstatSync(uploads).isSymbolicLink())
|
|
70
|
+
return;
|
|
71
|
+
for (const entry of readdirSync(uploads, { withFileTypes: true })) {
|
|
72
|
+
if (!entry.isFile() || !/^[a-f0-9-]{36}\.ombbackup$/.test(entry.name))
|
|
73
|
+
continue;
|
|
74
|
+
const file = join(uploads, entry.name);
|
|
75
|
+
if (statSync(file).mtimeMs + EXPIRES_MS <= Date.now()) {
|
|
76
|
+
try {
|
|
77
|
+
unlinkSync(file);
|
|
78
|
+
}
|
|
79
|
+
catch { }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
reclaimAbandoned();
|
|
85
|
+
}
|
|
86
|
+
catch { /* Temporary-file cleanup must not prevent normal server startup. */ }
|
|
87
|
+
function expire() {
|
|
88
|
+
for (const [id, artifact] of artifacts) {
|
|
89
|
+
if (artifact.expires > Date.now())
|
|
90
|
+
continue;
|
|
91
|
+
remove(id, artifact);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function replacePrevious(auth, kinds) {
|
|
95
|
+
expire();
|
|
96
|
+
for (const [id, artifact] of artifacts)
|
|
97
|
+
if (artifact.owner === owner(auth) && kinds.includes(artifact.kind))
|
|
98
|
+
remove(id, artifact);
|
|
99
|
+
}
|
|
100
|
+
const get = (id, auth, kind) => {
|
|
101
|
+
expire();
|
|
102
|
+
const artifact = typeof id === "string" ? artifacts.get(id) : undefined;
|
|
103
|
+
if (!artifact || artifact.owner !== owner(auth) || artifact.kind !== kind) {
|
|
104
|
+
throw failure("This backup has expired or belongs to another session. Select the file again.", 404);
|
|
105
|
+
}
|
|
106
|
+
return artifact;
|
|
107
|
+
};
|
|
108
|
+
async function operate(work) {
|
|
109
|
+
if (operating)
|
|
110
|
+
throw failure("Another backup operation is in progress. Wait for it to finish.", 409);
|
|
111
|
+
operating = true;
|
|
112
|
+
try {
|
|
113
|
+
return await work();
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
operating = false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return async (req, res, path, auth) => {
|
|
120
|
+
if (!path.startsWith(`${PREFIX}/`))
|
|
121
|
+
return false;
|
|
122
|
+
res.setHeader("cache-control", "no-store");
|
|
123
|
+
const method = req.method;
|
|
124
|
+
try {
|
|
125
|
+
check(req, auth);
|
|
126
|
+
if (method === "GET" && path === `${PREFIX}/status`) {
|
|
127
|
+
json(res, 200, {
|
|
128
|
+
...options.status(),
|
|
129
|
+
lastRestoreId: options.restored.restored ? options.restored.id : null,
|
|
130
|
+
safetyCopyPath: options.restored.safetyCopyPath ?? null,
|
|
131
|
+
});
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
if (method === "POST" && path === `${PREFIX}/client-state`) {
|
|
135
|
+
const body = z.object({ restoreId: z.string().uuid() }).parse(await options.readBody(req));
|
|
136
|
+
check(req, auth);
|
|
137
|
+
if (!options.restored.restored || body.restoreId !== options.restored.id)
|
|
138
|
+
throw failure("This restore has not completed. Restart OpenMausBot first.", 409);
|
|
139
|
+
json(res, 200, { clientState: options.restored.clientState ?? {} });
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
if (method === "POST" && path === `${PREFIX}/export`) {
|
|
143
|
+
// Allow the full preference budget plus the JSON envelope/password.
|
|
144
|
+
const body = z.object({ password: passwordSchema, clientState: z.record(z.string(), z.string()).default({}) }).parse(await options.readBody(req, MAX_CLIENT_STATE_BYTES + 8 * 1024));
|
|
145
|
+
if (Buffer.byteLength(JSON.stringify(body.clientState)) > MAX_CLIENT_STATE_BYTES)
|
|
146
|
+
throw failure("Saved drafts exceed the 2 MB backup preference limit.", 413);
|
|
147
|
+
const clientState = Object.fromEntries(Object.entries(body.clientState).filter(([key]) => WORKSPACE_BACKUP_CLIENT_KEYS.includes(key)));
|
|
148
|
+
const result = await operate(() => options.exclusive(async () => {
|
|
149
|
+
check(req, auth);
|
|
150
|
+
replacePrevious(auth, ["download"]);
|
|
151
|
+
if (artifacts.size >= 4)
|
|
152
|
+
throw failure("There are already four backup files in progress. Restart or wait an hour before creating another.", 409);
|
|
153
|
+
return createWorkspaceBackup(options.dataDir, { password: body.password, clientState, appVersion: options.appVersion });
|
|
154
|
+
}));
|
|
155
|
+
try {
|
|
156
|
+
check(req, auth);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
removeWorkspaceBackupJob(options.dataDir, result.id);
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
artifacts.set(result.id, { owner: owner(auth), kind: "download", path: result.path, summary: result.summary, expires: Date.now() + EXPIRES_MS });
|
|
163
|
+
json(res, 200, { id: result.id, filename: `OpenMausBot-${result.summary.createdAt.slice(0, 10)}.ombbackup`, bytes: statSync(result.path).size, summary: result.summary });
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
const download = /^\/api\/workspace-backup\/download\/([\w-]+)$/.exec(path);
|
|
167
|
+
if (method === "GET" && download) {
|
|
168
|
+
const artifact = get(download[1], auth, "download");
|
|
169
|
+
const file = artifact.path;
|
|
170
|
+
res.writeHead(200, {
|
|
171
|
+
"content-type": "application/octet-stream",
|
|
172
|
+
"content-length": statSync(file).size,
|
|
173
|
+
"content-disposition": `attachment; filename="OpenMausBot-${artifact.summary.createdAt.slice(0, 10)}.ombbackup"`,
|
|
174
|
+
"x-content-type-options": "nosniff",
|
|
175
|
+
});
|
|
176
|
+
await pipeline(createReadStream(file), res);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
if (method === "POST" && path === `${PREFIX}/upload`) {
|
|
180
|
+
await operate(async () => {
|
|
181
|
+
const length = Number(req.headers["content-length"]);
|
|
182
|
+
if (!Number.isSafeInteger(length) || length <= 0 || length > MAX_UPLOAD_BYTES)
|
|
183
|
+
throw failure("Select a backup containing no more than 10 GB of workspace data.", 413);
|
|
184
|
+
replacePrevious(auth, ["upload", "staged"]);
|
|
185
|
+
if (artifacts.size >= 4)
|
|
186
|
+
throw failure("There are already four backup files in progress. Restart or wait an hour before uploading another.", 409);
|
|
187
|
+
const root = join(options.dataDir, ".backups");
|
|
188
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
189
|
+
if (lstatSync(root).isSymbolicLink())
|
|
190
|
+
throw failure("Backup storage must not be a symbolic link.");
|
|
191
|
+
const directory = join(root, "uploads");
|
|
192
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
193
|
+
if (lstatSync(directory).isSymbolicLink())
|
|
194
|
+
throw failure("Backup storage must not be a symbolic link.");
|
|
195
|
+
const id = randomUUID();
|
|
196
|
+
const file = join(directory, `${id}.ombbackup`);
|
|
197
|
+
let bytes = 0;
|
|
198
|
+
try {
|
|
199
|
+
const limit = new Transform({ transform(chunk, _encoding, callback) {
|
|
200
|
+
bytes += chunk.length;
|
|
201
|
+
callback(bytes > MAX_UPLOAD_BYTES ? failure("The backup is too large.", 413) : null, chunk);
|
|
202
|
+
} });
|
|
203
|
+
await pipeline(req.iterator({ destroyOnReturn: false }), limit, createWriteStream(file, { flags: "wx", mode: 0o600 }));
|
|
204
|
+
if (bytes !== length)
|
|
205
|
+
throw failure("The upload was incomplete. Select the file again.");
|
|
206
|
+
check(req, auth);
|
|
207
|
+
artifacts.set(id, { owner: owner(auth), kind: "upload", path: file, expires: Date.now() + EXPIRES_MS });
|
|
208
|
+
json(res, 200, { id });
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
try {
|
|
212
|
+
unlinkSync(file);
|
|
213
|
+
}
|
|
214
|
+
catch { }
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
if (method === "POST" && path === `${PREFIX}/preview`) {
|
|
221
|
+
const body = z.object({ id: z.string().uuid(), password: passwordSchema }).parse(await options.readBody(req));
|
|
222
|
+
const artifact = get(body.id, auth, "upload");
|
|
223
|
+
const result = await operate(() => stageWorkspaceBackup(options.dataDir, artifact.path, { password: body.password, currentAppVersion: options.appVersion }));
|
|
224
|
+
try {
|
|
225
|
+
check(req, auth);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
removeWorkspaceBackupJob(options.dataDir, result.id);
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
unlinkSync(artifact.path);
|
|
233
|
+
}
|
|
234
|
+
catch { }
|
|
235
|
+
artifacts.delete(body.id);
|
|
236
|
+
artifacts.set(result.id, { owner: owner(auth), kind: "staged", summary: result.summary, expires: Date.now() + EXPIRES_MS });
|
|
237
|
+
json(res, 200, { id: result.id, summary: result.summary });
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
if (method === "POST" && path === `${PREFIX}/restore`) {
|
|
241
|
+
const body = z.object({ id: z.string().uuid(), confirmation: z.literal("REPLACE") }).parse(await options.readBody(req));
|
|
242
|
+
get(body.id, auth, "staged");
|
|
243
|
+
const result = await operate(() => options.exclusive(async () => {
|
|
244
|
+
check(req, auth);
|
|
245
|
+
return commitPendingWorkspaceRestore(options.dataDir, body.id);
|
|
246
|
+
}, true));
|
|
247
|
+
artifacts.delete(body.id);
|
|
248
|
+
json(res, 200, { ...result, restoreId: body.id });
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
json(res, 404, { error: "Unknown backup operation." });
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (res.headersSent) {
|
|
255
|
+
res.destroy();
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
json(res, error instanceof z.ZodError ? 400 : error.status ?? 400, {
|
|
259
|
+
error: error instanceof z.ZodError ? error.issues[0]?.message ?? "Invalid backup request." : error instanceof Error ? error.message : "Backup failed.",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return true;
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** A backup owns one quiet workspace. Never interrupt a turn to obtain it. */
|
|
2
|
+
export class WorkspaceBackupMaintenance {
|
|
3
|
+
active = false;
|
|
4
|
+
pendingRestore = false;
|
|
5
|
+
requests = 0;
|
|
6
|
+
assertAvailable() {
|
|
7
|
+
if (this.active)
|
|
8
|
+
throw Object.assign(new Error(this.pendingRestore
|
|
9
|
+
? "A workspace restore is ready. Fully quit and reopen OpenMausBot (or restart the hosted server) to finish."
|
|
10
|
+
: "A workspace backup is in progress. Try again when it finishes."), { status: 503 });
|
|
11
|
+
}
|
|
12
|
+
request() {
|
|
13
|
+
this.assertAvailable();
|
|
14
|
+
this.requests++;
|
|
15
|
+
return () => { this.requests--; };
|
|
16
|
+
}
|
|
17
|
+
async run(work, hooks, keepLocked = false) {
|
|
18
|
+
this.assertAvailable();
|
|
19
|
+
if (this.requests || !hooks.idle())
|
|
20
|
+
throw Object.assign(new Error("Wait for bot turns, approvals, sign-ins, and computer actions to finish before backing up or restoring."), { status: 409 });
|
|
21
|
+
this.active = true;
|
|
22
|
+
let committed = false;
|
|
23
|
+
try {
|
|
24
|
+
hooks.pause();
|
|
25
|
+
await hooks.flush();
|
|
26
|
+
if (!hooks.idle())
|
|
27
|
+
throw Object.assign(new Error("The workspace is still finishing background work. Try again shortly."), { status: 409 });
|
|
28
|
+
const result = await work();
|
|
29
|
+
committed = keepLocked;
|
|
30
|
+
this.pendingRestore = keepLocked;
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
if (!committed) {
|
|
35
|
+
this.active = false;
|
|
36
|
+
hooks.resume();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parseStoredConfig } from "./config.js";
|
|
2
|
+
// Connection sections deliberately stay on the destination as a whole: a
|
|
3
|
+
// restored URL must never redirect an API key retained from that device.
|
|
4
|
+
// Allowlisting ordinary settings also keeps future/unknown auth fields out.
|
|
5
|
+
const PORTABLE_CONFIG_KEYS = [
|
|
6
|
+
"profile", "language", "budgets", "billing", "rooms", "threads",
|
|
7
|
+
"localVm", "features", "browserProfiles",
|
|
8
|
+
];
|
|
9
|
+
export function portableWorkspaceConfig(value) {
|
|
10
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
11
|
+
throw new Error("Invalid workspace configuration in backup.");
|
|
12
|
+
const config = parseStoredConfig(value);
|
|
13
|
+
return Object.fromEntries(PORTABLE_CONFIG_KEYS.filter((key) => config[key] !== undefined).map((key) => [key, config[key]]));
|
|
14
|
+
}
|
|
15
|
+
export function restoredWorkspaceConfig(portable, destination) {
|
|
16
|
+
if (!destination || typeof destination !== "object" || Array.isArray(destination))
|
|
17
|
+
throw new Error("Invalid destination workspace configuration; connections were not changed.");
|
|
18
|
+
const merged = { ...destination };
|
|
19
|
+
for (const key of PORTABLE_CONFIG_KEYS)
|
|
20
|
+
delete merged[key];
|
|
21
|
+
return { ...merged, ...portableWorkspaceConfig(portable) };
|
|
22
|
+
}
|
|
23
|
+
/** Exact app-owned authentication paths, not a scan of user document text. */
|
|
24
|
+
export function excludedWorkspaceAuthPath(path) {
|
|
25
|
+
return /^(?:(?:providers|caddy|chrome-profile|\.agent-browser)(?:\/|$)|workspace-credentials\.json$|browser-engine-key$)/.test(path) ||
|
|
26
|
+
/^(?:config\.json|webhooks\.json|workspace-credentials\.json|browser-engine-key|sessions\.json|tunnel-account\.json)\.\d+(?:\.[0-9a-f-]+)?\.tmp$/.test(path) ||
|
|
27
|
+
/^(?:vm-home|vm-homes\/[^/]+)\/\.browser-profiles(?:\/|$)/.test(path);
|
|
28
|
+
}
|