openmausbot 0.1.71 → 0.1.73
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-BLJ7byG3.js +296 -0
- package/dist/assets/{index-CJtyBonr.js → index-BYbK7ber.js} +1 -1
- package/dist/assets/index-De2TRLxu.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/drivers/agents-proxy.js +2 -2
- package/dist-server/index.js +7603 -2509
- package/dist-server/local-computer.js +4 -0
- package/dist-server/openmausbot.js +1639 -581
- package/dist-server/pair-cli.js +1639 -581
- 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/agents-proxy.js +2 -2
- package/dist-server/server/drivers/claude.js +14 -7
- package/dist-server/server/drivers/codex.js +64 -16
- 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 +340 -15
- 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/vps-computer.js +81 -12
- 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
|
@@ -35,6 +35,17 @@ const MANAGED_VPS_CONTAINER_NAME = /^openmausbot-vps-[a-z0-9]{1,12}-[a-f0-9]{12}
|
|
|
35
35
|
const IMAGE_ID = /^sha256:[a-f0-9]{64}$/i;
|
|
36
36
|
const PIDS_LIMIT = 512;
|
|
37
37
|
const SCREENSHOT_PATH = "/tmp/openmausbot-vps-preview.png";
|
|
38
|
+
// The Cua XFCE base includes Pillow in its existing Python environment. Keep
|
|
39
|
+
// this panel-only conversion in the transfer exec: no extra SSH round trip,
|
|
40
|
+
// image rebuild, driver settings change, or second temporary image. Older
|
|
41
|
+
// containers without Pillow can still return the original PNG.
|
|
42
|
+
const SCREENSHOT_TRANSFER = `/opt/venv/bin/python -I -c 'import base64, io, sys
|
|
43
|
+
from PIL import Image
|
|
44
|
+
with Image.open(sys.argv[1]) as image:
|
|
45
|
+
image.thumbnail((1280, 1280))
|
|
46
|
+
output = io.BytesIO()
|
|
47
|
+
image.convert("RGB").save(output, format="JPEG", quality=70)
|
|
48
|
+
sys.stdout.write(base64.b64encode(output.getvalue()).decode("ascii"))' "$1" 2>/dev/null || { base64 < "$1" | tr -d "\\n"; }`;
|
|
38
49
|
const INTERNAL_VIEWER_PORT = 6901;
|
|
39
50
|
const VIEWER_VERSION = "1";
|
|
40
51
|
const lifecycleLocks = new Map();
|
|
@@ -60,6 +71,9 @@ const LOCK_ACQUIRE_TIMEOUT_MS = 5_000;
|
|
|
60
71
|
// pattern as container-computer's screenshotStatusCache, and the same TTL.
|
|
61
72
|
const STATUS_CACHE_TTL_MS = 10_000;
|
|
62
73
|
const statusCache = new Map();
|
|
74
|
+
const SCREENSHOT_BUDGET_MS = 45_000;
|
|
75
|
+
const SCREENSHOT_CLEANUP_BUDGET_MS = 10_000;
|
|
76
|
+
const pendingScreenshots = new Map();
|
|
63
77
|
const viewerConnections = new Map();
|
|
64
78
|
const desktopTunnels = new Map();
|
|
65
79
|
function containerNamePart(botId) {
|
|
@@ -494,6 +508,9 @@ export async function vpsComputerStatus(cfg, botId, runner = defaultRunner) {
|
|
|
494
508
|
const key = vpsLockKey(cfg, botId);
|
|
495
509
|
const cacheable = runner === defaultRunner && key !== null;
|
|
496
510
|
if (cacheable) {
|
|
511
|
+
// A capture already checks this exact target. Let it finish instead of
|
|
512
|
+
// opening another six SSH connections while its readiness check is cold.
|
|
513
|
+
await pendingScreenshots.get(key)?.catch(() => { });
|
|
497
514
|
const cached = statusCache.get(key);
|
|
498
515
|
if (cached && cached.expiresAt > Date.now())
|
|
499
516
|
return cached.status;
|
|
@@ -1042,22 +1059,56 @@ export async function vpsComputerScreenshot(cfg, botId, runner = defaultRunner)
|
|
|
1042
1059
|
if (!alias)
|
|
1043
1060
|
throw Object.assign(new Error("VPS is not configured"), { status: 409 });
|
|
1044
1061
|
const key = `${alias}:${vpsContainerName(botId)}`;
|
|
1062
|
+
const pending = pendingScreenshots.get(key);
|
|
1063
|
+
if (pending)
|
|
1064
|
+
return pending;
|
|
1045
1065
|
const cacheable = runner === defaultRunner;
|
|
1046
|
-
|
|
1066
|
+
const deadline = Date.now() + SCREENSHOT_BUDGET_MS;
|
|
1067
|
+
const workDeadline = deadline - SCREENSHOT_CLEANUP_BUDGET_MS;
|
|
1068
|
+
let budgetExpired = false;
|
|
1069
|
+
const timeoutError = () => Object.assign(new Error("The VPS screen preview timed out. Retry the preview when the connection recovers."), { status: 504 });
|
|
1070
|
+
// Clamp each command and wait through the runner's termination grace,
|
|
1071
|
+
// rather than racing its promise and releasing the lock before cleanup.
|
|
1072
|
+
const boundedRunner = async (args, options = {}) => {
|
|
1073
|
+
const remaining = workDeadline - Date.now() - COMMAND_TIMEOUT_KILL_GRACE_MS;
|
|
1074
|
+
if (remaining <= 0) {
|
|
1075
|
+
budgetExpired = true;
|
|
1076
|
+
throw timeoutError();
|
|
1077
|
+
}
|
|
1078
|
+
try {
|
|
1079
|
+
const result = await runner(args, { ...options, timeoutMs: Math.min(options.timeoutMs ?? 120_000, remaining) });
|
|
1080
|
+
if (Date.now() >= workDeadline) {
|
|
1081
|
+
budgetExpired = true;
|
|
1082
|
+
throw timeoutError();
|
|
1083
|
+
}
|
|
1084
|
+
return result;
|
|
1085
|
+
}
|
|
1086
|
+
catch (error) {
|
|
1087
|
+
if (Date.now() >= workDeadline - COMMAND_TIMEOUT_KILL_GRACE_MS)
|
|
1088
|
+
budgetExpired = true;
|
|
1089
|
+
throw budgetExpired ? timeoutError() : error;
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
const capture = withVpsLifecycleLock(key, async () => {
|
|
1047
1093
|
// Same shape as containerComputerScreenshot's screenshotStatusCache: the
|
|
1048
1094
|
// poller runs every few seconds, and re-verifying the whole container
|
|
1049
1095
|
// between frames multiplied every frame's SSH cost.
|
|
1050
1096
|
const cached = cacheable ? statusCache.get(key) : undefined;
|
|
1051
1097
|
const status = cached && cached.expiresAt > Date.now()
|
|
1052
1098
|
? cached.status
|
|
1053
|
-
: await computeVpsComputerStatus(cfg, botId,
|
|
1099
|
+
: await computeVpsComputerStatus(cfg, botId, boundedRunner);
|
|
1100
|
+
// Status converts transport errors into displayable state. A deadline is
|
|
1101
|
+
// still a timeout, not evidence that this container became incompatible.
|
|
1102
|
+
if (budgetExpired) {
|
|
1103
|
+
if (cacheable)
|
|
1104
|
+
statusCache.delete(key);
|
|
1105
|
+
throw timeoutError();
|
|
1106
|
+
}
|
|
1054
1107
|
if (!status.ready) {
|
|
1055
1108
|
if (cacheable)
|
|
1056
1109
|
statusCache.delete(key);
|
|
1057
1110
|
throw Object.assign(new Error(status.problem ?? "The VPS computer is not ready"), { status: 409 });
|
|
1058
1111
|
}
|
|
1059
|
-
if (cacheable)
|
|
1060
|
-
statusCache.set(key, { status, expiresAt: Date.now() + STATUS_CACHE_TTL_MS });
|
|
1061
1112
|
const containerRef = status.container_id ?? status.container_name;
|
|
1062
1113
|
// The ref goes straight into docker argv, and a cached status is one
|
|
1063
1114
|
// more step removed from the inspect that produced it — revalidate the
|
|
@@ -1065,23 +1116,26 @@ export async function vpsComputerScreenshot(cfg, botId, runner = defaultRunner)
|
|
|
1065
1116
|
if (!CONTAINER_ID.test(containerRef) && !CONTAINER_NAME.test(containerRef)) {
|
|
1066
1117
|
throw Object.assign(new Error("the VPS container reference is malformed"), { status: 409 });
|
|
1067
1118
|
}
|
|
1119
|
+
let frame;
|
|
1068
1120
|
try {
|
|
1069
|
-
await
|
|
1070
|
-
const encoded = (await
|
|
1121
|
+
await boundedRunner(vpsDockerArgs(alias, cuaExecArgs(["call", "get_desktop_state", "{}", "--socket", CUA_SOCKET, "--screenshot-out-file", SCREENSHOT_PATH], { container: containerRef })), { timeoutMs: 30_000 });
|
|
1122
|
+
const encoded = (await boundedRunner(vpsDockerArgs(alias, [
|
|
1071
1123
|
"exec",
|
|
1072
1124
|
"-u",
|
|
1073
1125
|
"cua",
|
|
1074
1126
|
"-e",
|
|
1075
1127
|
"HOME=/home/cua",
|
|
1076
1128
|
containerRef,
|
|
1077
|
-
"
|
|
1078
|
-
"-
|
|
1129
|
+
"sh",
|
|
1130
|
+
"-c",
|
|
1131
|
+
SCREENSHOT_TRANSFER,
|
|
1132
|
+
"openmausbot-preview",
|
|
1079
1133
|
SCREENSHOT_PATH,
|
|
1080
1134
|
]), { timeoutMs: 30_000 })).stdout.trim();
|
|
1081
1135
|
const checked = wholeScreenshot(Buffer.from(encoded, "base64"));
|
|
1082
1136
|
if (!checked.ok)
|
|
1083
1137
|
throw Object.assign(new Error("Cua Driver returned an incomplete VPS screenshot"), { status: 502 });
|
|
1084
|
-
|
|
1138
|
+
frame = { png: encoded, format: checked.mime === "image/jpeg" ? "jpeg" : "png" };
|
|
1085
1139
|
}
|
|
1086
1140
|
catch (error) {
|
|
1087
1141
|
// The failure may mean the world changed (container stopped, link
|
|
@@ -1091,9 +1145,24 @@ export async function vpsComputerScreenshot(cfg, botId, runner = defaultRunner)
|
|
|
1091
1145
|
throw error;
|
|
1092
1146
|
}
|
|
1093
1147
|
finally {
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1148
|
+
const cleanupMs = Math.min(5_000, deadline - Date.now() - COMMAND_TIMEOUT_KILL_GRACE_MS);
|
|
1149
|
+
if (cleanupMs > 0)
|
|
1150
|
+
await runner(vpsDockerArgs(alias, ["exec", "-u", "cua", containerRef, "rm", "-f", SCREENSHOT_PATH]), {
|
|
1151
|
+
timeoutMs: cleanupMs,
|
|
1152
|
+
}).catch(() => { });
|
|
1097
1153
|
}
|
|
1154
|
+
// Start the TTL after transfer and cleanup; a slow but healthy frame must
|
|
1155
|
+
// not return with its own readiness cache already expired.
|
|
1156
|
+
if (cacheable)
|
|
1157
|
+
statusCache.set(key, { status, expiresAt: Date.now() + STATUS_CACHE_TTL_MS });
|
|
1158
|
+
return frame;
|
|
1098
1159
|
});
|
|
1160
|
+
pendingScreenshots.set(key, capture);
|
|
1161
|
+
try {
|
|
1162
|
+
return await capture;
|
|
1163
|
+
}
|
|
1164
|
+
finally {
|
|
1165
|
+
if (pendingScreenshots.get(key) === capture)
|
|
1166
|
+
pendingScreenshots.delete(key);
|
|
1167
|
+
}
|
|
1099
1168
|
}
|
|
@@ -70,7 +70,7 @@ function eventName(req) {
|
|
|
70
70
|
header(req, "x-event-type") ??
|
|
71
71
|
header(req, "ce-type"))?.trim() || undefined;
|
|
72
72
|
}
|
|
73
|
-
export function createWebhookIngressHandler(manager) {
|
|
73
|
+
export function createWebhookIngressHandler(manager, claimRequest) {
|
|
74
74
|
return async (req, res) => {
|
|
75
75
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
76
76
|
if (req.method === "GET" && url.pathname === "/health") {
|
|
@@ -81,7 +81,9 @@ export function createWebhookIngressHandler(manager) {
|
|
|
81
81
|
return json(res, 404, { error: "Unknown webhook endpoint" });
|
|
82
82
|
if (req.method !== "POST")
|
|
83
83
|
return json(res, 405, { error: "Webhooks accept POST requests" });
|
|
84
|
+
let release;
|
|
84
85
|
try {
|
|
86
|
+
release = claimRequest?.();
|
|
85
87
|
const pathSecret = match[2] ? decodeURIComponent(match[2]) : "";
|
|
86
88
|
const secret = pathSecret || bearerSecret(req);
|
|
87
89
|
// Reject bad capability URLs before buffering or parsing attacker input.
|
|
@@ -121,6 +123,9 @@ export function createWebhookIngressHandler(manager) {
|
|
|
121
123
|
}
|
|
122
124
|
return json(res, status, { error: message });
|
|
123
125
|
}
|
|
126
|
+
finally {
|
|
127
|
+
release?.();
|
|
128
|
+
}
|
|
124
129
|
};
|
|
125
130
|
}
|
|
126
131
|
/** The base senders are told to use. Behind a proxy or tunnel the listening
|
|
@@ -142,7 +147,7 @@ export function advertisedWebhookBase(raw) {
|
|
|
142
147
|
export async function listenWebhookIngress(manager, options) {
|
|
143
148
|
const host = options.host ?? "127.0.0.1";
|
|
144
149
|
const advertised = options.publicBaseUrl === undefined ? undefined : advertisedWebhookBase(options.publicBaseUrl);
|
|
145
|
-
const server = createServer(createWebhookIngressHandler(manager));
|
|
150
|
+
const server = createServer(createWebhookIngressHandler(manager, options.claimRequest));
|
|
146
151
|
await new Promise((resolve, reject) => {
|
|
147
152
|
const onError = (error) => reject(error);
|
|
148
153
|
server.once("error", onError);
|
|
@@ -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
|
+
}
|