openmausbot 0.1.70 → 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.
Files changed (82) hide show
  1. package/dist/assets/index-BFPYw6P-.js +296 -0
  2. package/dist/assets/{index-D1RGhLUK.js → index-Bpai86ua.js} +1 -1
  3. package/dist/assets/index-D1odUdTi.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/listener.js +12 -2
  6. package/dist-server/container-mcp.js +28 -2
  7. package/dist-server/drivers/agents-proxy.js +202 -16
  8. package/dist-server/drivers/pi-mcp-extension.ts +1 -1
  9. package/dist-server/index.js +9443 -3023
  10. package/dist-server/local-computer.js +6 -1
  11. package/dist-server/mcp-gate.js +543 -0
  12. package/dist-server/mcp-server.js +38 -0
  13. package/dist-server/openmausbot.js +1893 -583
  14. package/dist-server/pair-cli.js +1893 -583
  15. package/dist-server/proxy-paths.js +1 -0
  16. package/dist-server/server/agent-tool-policy.js +29 -0
  17. package/dist-server/server/bot-overview.js +28 -0
  18. package/dist-server/server/bot-package.js +55 -2
  19. package/dist-server/server/browser-engine.js +2 -1
  20. package/dist-server/server/browser-live.js +12 -7
  21. package/dist-server/server/calendar-calls.js +1 -0
  22. package/dist-server/server/cli.js +102 -1
  23. package/dist-server/server/config.js +94 -6
  24. package/dist-server/server/drivers/acp/core.js +2 -0
  25. package/dist-server/server/drivers/acp/cursor.js +1 -1
  26. package/dist-server/server/drivers/acp/grok.js +5 -1
  27. package/dist-server/server/drivers/agents-proxy.js +195 -16
  28. package/dist-server/server/drivers/boxagent.js +1 -1
  29. package/dist-server/server/drivers/claude.js +301 -23
  30. package/dist-server/server/drivers/codex.js +13 -4
  31. package/dist-server/server/drivers/pi.js +1 -1
  32. package/dist-server/server/fleet-agent.js +161 -0
  33. package/dist-server/server/fleet-cli.js +225 -0
  34. package/dist-server/server/fleet-client.js +45 -0
  35. package/dist-server/server/fleet.js +410 -0
  36. package/dist-server/server/harness/bus.js +1 -1
  37. package/dist-server/server/index.js +836 -201
  38. package/dist-server/server/local-computer.js +5 -1
  39. package/dist-server/server/mcp-gate-config.js +56 -0
  40. package/dist-server/server/mcp-gate.js +227 -0
  41. package/dist-server/server/mcp-registry.js +71 -3
  42. package/dist-server/server/mcp-trim.js +182 -0
  43. package/dist-server/server/memory-journal.js +440 -0
  44. package/dist-server/server/memory-store.js +308 -0
  45. package/dist-server/server/message-db.js +90 -3
  46. package/dist-server/server/package-export.js +25 -0
  47. package/dist-server/server/peer-approval.js +1 -1
  48. package/dist-server/server/prices.js +16 -0
  49. package/dist-server/server/provider-auth-sessions.js +3 -0
  50. package/dist-server/server/provider-key-check.js +66 -0
  51. package/dist-server/server/proxy-paths.js +1 -0
  52. package/dist-server/server/redact.js +15 -0
  53. package/dist-server/server/resume-recovery.js +46 -0
  54. package/dist-server/server/routines.js +1 -0
  55. package/dist-server/server/screen-frame-gate.js +9 -6
  56. package/dist-server/server/skill-learn.js +15 -1
  57. package/dist-server/server/skill-library.js +5 -5
  58. package/dist-server/server/spend.js +61 -0
  59. package/dist-server/server/store.js +7 -3
  60. package/dist-server/server/system-prompt.js +28 -6
  61. package/dist-server/server/tailscale.js +2 -2
  62. package/dist-server/server/team-backup.js +35 -0
  63. package/dist-server/server/tool-summary.js +39 -0
  64. package/dist-server/server/tts/speech-text.js +3 -1
  65. package/dist-server/server/turn-context.js +18 -0
  66. package/dist-server/server/usage-ledger.js +239 -0
  67. package/dist-server/server/webhook-ingress.js +7 -2
  68. package/dist-server/server/workspace-backup-http.js +264 -0
  69. package/dist-server/server/workspace-backup-maintenance.js +40 -0
  70. package/dist-server/server/workspace-backup-policy.js +28 -0
  71. package/dist-server/server/workspace-backup.js +1022 -0
  72. package/dist-server/server/workspace.js +321 -22
  73. package/dist-server/shared/approval-mode.js +1 -1
  74. package/dist-server/shared/learn-request.js +6 -0
  75. package/dist-server/shared/team-backup.js +13 -2
  76. package/dist-server/shared/workspace-backup-client.js +15 -0
  77. package/dist-server/shared/workspace-backup.js +1 -0
  78. package/dist-server/vps-container-mcp.js +28 -2
  79. package/package.json +1 -1
  80. package/skills/create-verification-skill/SKILL.md +2 -3
  81. package/dist/assets/index-B2F__IRS.css +0 -1
  82. package/dist/assets/index-CLj3t5Ba.js +0 -280
@@ -0,0 +1,239 @@
1
+ // The USAGE ledger: one row per settled turn, append-only, month by month
2
+ // under <data>/usage/, so "what did this workspace spend, by bot, by model,
3
+ // by person, by day" is answerable after a restart and exportable for an
4
+ // invoice. The per-task tally (store.ts addTaskUsage) is a running total
5
+ // that lives and dies with the task; this is the durable record.
6
+ //
7
+ // Same discipline as the decision log: 0600 files, serialized appends per
8
+ // directory, fire-and-forget. Bookkeeping must never fail the turn it is
9
+ // booking. Rows carry who asked and what it cost, never message text.
10
+ import { readFileSync } from "node:fs";
11
+ import { appendFile, mkdir } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { billableFor } from "./prices.js";
14
+ export const USAGE_GROUPINGS = ["bot", "model", "user", "day", "engine"];
15
+ const DIR = "usage";
16
+ const MAX_RANGE_DAYS = 366;
17
+ const DAY_MS = 24 * 60 * 60_000;
18
+ const writeQueues = new Map();
19
+ const clean = (value) => typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
20
+ const finiteOrNull = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
21
+ function monthKey(at) {
22
+ return `${at.getUTCFullYear()}-${String(at.getUTCMonth() + 1).padStart(2, "0")}`;
23
+ }
24
+ export function usageFileFor(dataDir, at) {
25
+ return join(dataDir, DIR, `${monthKey(at)}.jsonl`);
26
+ }
27
+ /** Append one settled turn. Fire-and-forget; see the module comment. */
28
+ export function appendUsage(dataDir, row) {
29
+ const record = {
30
+ ...row,
31
+ at: row.at ?? new Date().toISOString(),
32
+ input: clean(row.input),
33
+ output: clean(row.output),
34
+ ...(typeof row.cachedInput === "number" ? { cachedInput: clean(row.cachedInput) } : {}),
35
+ costUsd: finiteOrNull(row.costUsd),
36
+ };
37
+ const previous = writeQueues.get(dataDir) ?? Promise.resolve();
38
+ const queued = previous
39
+ .then(() => write(dataDir, record))
40
+ .catch(() => {
41
+ /* bookkeeping must never take down the turn */
42
+ });
43
+ writeQueues.set(dataDir, queued);
44
+ void queued.finally(() => {
45
+ if (writeQueues.get(dataDir) === queued)
46
+ writeQueues.delete(dataDir);
47
+ });
48
+ }
49
+ async function write(dataDir, record) {
50
+ await mkdir(join(dataDir, DIR), { recursive: true, mode: 0o700 });
51
+ await appendFile(usageFileFor(dataDir, new Date(record.at)), JSON.stringify(record) + "\n", { mode: 0o600 });
52
+ }
53
+ /** Test/shutdown seam: wait until everything queued for this directory is on disk. */
54
+ export async function flushUsageLedger(dataDir) {
55
+ await writeQueues.get(dataDir);
56
+ }
57
+ const isTrigger = (value) => typeof value === "object" && value !== null && typeof value.kind === "string";
58
+ const isUsageRow = (value) => typeof value === "object" &&
59
+ value !== null &&
60
+ typeof value.at === "string" &&
61
+ typeof value.botId === "string" &&
62
+ typeof value.model === "string" &&
63
+ typeof value.input === "number" &&
64
+ typeof value.output === "number" &&
65
+ isTrigger(value.trigger);
66
+ function monthsBetween(from, to) {
67
+ const keys = [];
68
+ const cursor = new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), 1));
69
+ while (cursor.getTime() <= to.getTime()) {
70
+ keys.push(monthKey(cursor));
71
+ cursor.setUTCMonth(cursor.getUTCMonth() + 1);
72
+ }
73
+ return keys;
74
+ }
75
+ /** Every row whose time falls inside the range, oldest first. */
76
+ export function readUsage(dataDir, range) {
77
+ const rows = [];
78
+ const from = range.from.getTime();
79
+ const to = range.to.getTime();
80
+ for (const key of monthsBetween(range.from, range.to)) {
81
+ let text;
82
+ try {
83
+ text = readFileSync(join(dataDir, DIR, `${key}.jsonl`), "utf8");
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ for (const line of text.split("\n")) {
89
+ if (!line)
90
+ continue;
91
+ try {
92
+ const value = JSON.parse(line);
93
+ if (!isUsageRow(value))
94
+ continue;
95
+ const at = Date.parse(value.at);
96
+ if (at >= from && at <= to)
97
+ rows.push(value);
98
+ }
99
+ catch {
100
+ /* a line torn mid-write: skip the fragment, keep the rest */
101
+ }
102
+ }
103
+ }
104
+ return rows;
105
+ }
106
+ /** A date range from query strings: `YYYY-MM-DD` each, `to` inclusive to
107
+ * the end of its day (UTC). Defaults to the current month to date. Null
108
+ * for anything malformed, reversed, or longer than a year. */
109
+ export function parseUsageRange(from, to, now = new Date()) {
110
+ const day = /^(\d{4})-(\d{2})-(\d{2})$/;
111
+ const parse = (value, endOfDay) => {
112
+ const match = day.exec(value);
113
+ if (!match)
114
+ return null;
115
+ const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]), endOfDay ? 23 : 0, endOfDay ? 59 : 0, endOfDay ? 59 : 0, endOfDay ? 999 : 0));
116
+ // Reject 2026-02-31 style rollovers.
117
+ return date.getUTCMonth() === Number(match[2]) - 1 && date.getUTCDate() === Number(match[3]) ? date : null;
118
+ };
119
+ const start = from ? parse(from, false) : new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
120
+ const end = to ? parse(to, true) : now;
121
+ if (!start || !end || start.getTime() > end.getTime())
122
+ return null;
123
+ if (end.getTime() - start.getTime() > MAX_RANGE_DAYS * DAY_MS)
124
+ return null;
125
+ return { from: start, to: end };
126
+ }
127
+ /** A stable key and a display label for who asked. Labels for the
128
+ * non-person kinds are English fallbacks; the app localizes by key. */
129
+ export function triggerKey(trigger) {
130
+ switch (trigger.kind) {
131
+ case "user":
132
+ return `user:${(trigger.email ?? trigger.label ?? "").toLowerCase() || "unknown"}`;
133
+ case "owner":
134
+ return "owner";
135
+ case "routine":
136
+ return `routine:${trigger.routineId ?? trigger.label ?? "unknown"}`;
137
+ default:
138
+ return "bot";
139
+ }
140
+ }
141
+ export function triggerLabel(trigger) {
142
+ switch (trigger.kind) {
143
+ case "user":
144
+ return trigger.email ?? trigger.label ?? "Signed-in user";
145
+ case "owner":
146
+ return "This computer";
147
+ case "routine":
148
+ return `Routine: ${trigger.label ?? trigger.routineId ?? "unknown"}`;
149
+ default:
150
+ return "Bot to bot";
151
+ }
152
+ }
153
+ function groupOf(row, groupBy) {
154
+ switch (groupBy) {
155
+ case "bot":
156
+ return { key: `bot:${row.botId}`, label: row.botName || row.botId };
157
+ case "model":
158
+ return { key: `model:${row.driverKind}/${row.model}`, label: row.model };
159
+ case "user":
160
+ return { key: triggerKey(row.trigger), label: triggerLabel(row.trigger) };
161
+ case "day":
162
+ return { key: `day:${row.at.slice(0, 10)}`, label: row.at.slice(0, 10) };
163
+ default:
164
+ return { key: `engine:${row.driverKind}`, label: row.driverKind };
165
+ }
166
+ }
167
+ function emptyGroup(key, label) {
168
+ return { key, label, turns: 0, input: 0, output: 0, cachedInput: 0, costUsd: null, unpriced: 0, billableUsd: null };
169
+ }
170
+ function add(group, row, prices) {
171
+ group.turns += 1;
172
+ group.input += clean(row.input);
173
+ group.output += clean(row.output);
174
+ group.cachedInput += clean(row.cachedInput);
175
+ const cost = finiteOrNull(row.costUsd);
176
+ if (cost === null)
177
+ group.unpriced += 1;
178
+ else
179
+ group.costUsd = (group.costUsd ?? 0) + cost;
180
+ const billable = prices ? billableFor(row, prices) : null;
181
+ if (billable !== null)
182
+ group.billableUsd = (group.billableUsd ?? 0) + billable;
183
+ }
184
+ /** Totals per group, money first then volume; days stay chronological. */
185
+ export function summarizeUsage(rows, groupBy, prices = null) {
186
+ const groups = new Map();
187
+ const total = emptyGroup("total", "total");
188
+ for (const row of rows) {
189
+ const { key, label } = groupOf(row, groupBy);
190
+ let group = groups.get(key);
191
+ if (!group) {
192
+ group = emptyGroup(key, label);
193
+ groups.set(key, group);
194
+ }
195
+ add(group, row, prices);
196
+ add(total, row, prices);
197
+ }
198
+ const ordered = [...groups.values()];
199
+ if (groupBy === "day")
200
+ ordered.sort((a, b) => a.key.localeCompare(b.key));
201
+ else {
202
+ ordered.sort((a, b) => {
203
+ const costA = a.costUsd ?? Number.NEGATIVE_INFINITY;
204
+ const costB = b.costUsd ?? Number.NEGATIVE_INFINITY;
205
+ return costB - costA || b.input + b.output - (a.input + a.output);
206
+ });
207
+ }
208
+ return { groups: ordered, total };
209
+ }
210
+ function csvCell(value) {
211
+ if (value === null)
212
+ return "";
213
+ const text = String(value);
214
+ // A leading formula character is neutralised so a spreadsheet never
215
+ // executes a bot name or an email address.
216
+ const safe = /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
217
+ return /[",\n\r]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
218
+ }
219
+ /** One line per turn, spreadsheet-ready; a billable column when a price list is given. */
220
+ export function usageCsv(rows, prices = null) {
221
+ const header = ["time", "bot", "model", "engine", "triggered_by", "input_tokens", "output_tokens", "cached_input_tokens", "cost_usd", ...(prices ? ["billable_usd"] : []), "thread"];
222
+ const lines = [header.join(",")];
223
+ for (const row of rows) {
224
+ lines.push([
225
+ row.at,
226
+ row.botName || row.botId,
227
+ row.model,
228
+ row.driverKind,
229
+ triggerLabel(row.trigger),
230
+ clean(row.input),
231
+ clean(row.output),
232
+ clean(row.cachedInput),
233
+ finiteOrNull(row.costUsd),
234
+ ...(prices ? [billableFor(row, prices)] : []),
235
+ row.threadId,
236
+ ].map(csvCell).join(","));
237
+ }
238
+ return lines.join("\n") + "\n";
239
+ }
@@ -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
+ }