openmausbot 0.1.75 → 0.1.77

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 (66) hide show
  1. package/dist/assets/{index-BTlyRwLl.js → index-DeK-z9ld.js} +1 -1
  2. package/dist/assets/index-XdHJ-9CF.css +1 -0
  3. package/dist/assets/index-lhqE1umn.js +305 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/routes.js +6 -2
  6. package/dist-server/container-mcp.js +5 -1
  7. package/dist-server/drivers/agents-proxy.js +184 -11
  8. package/dist-server/enterprise/server/index.js +387 -29
  9. package/dist-server/index.js +3786 -1135
  10. package/dist-server/mcp-gate.js +28 -18
  11. package/dist-server/openmausbot.js +927 -642
  12. package/dist-server/pair-cli.js +927 -642
  13. package/dist-server/server/agent-tool-policy.js +2 -0
  14. package/dist-server/server/atomic.js +37 -1
  15. package/dist-server/server/bot-overview.js +5 -1
  16. package/dist-server/server/browser-live.js +3 -0
  17. package/dist-server/server/chief-of-staff.js +17 -12
  18. package/dist-server/server/config.js +16 -0
  19. package/dist-server/server/delegations.js +177 -63
  20. package/dist-server/server/drivers/acp/core.js +3 -1
  21. package/dist-server/server/drivers/acp/hermes.js +21 -3
  22. package/dist-server/server/drivers/agents-proxy.js +169 -10
  23. package/dist-server/server/drivers/boxagent.js +1 -1
  24. package/dist-server/server/drivers/claude.js +34 -16
  25. package/dist-server/server/drivers/codex.js +3 -1
  26. package/dist-server/server/drivers/openai-chat.js +90 -67
  27. package/dist-server/server/drivers/openai-compat.js +9 -1
  28. package/dist-server/server/drivers/pi.js +22 -3
  29. package/dist-server/server/enterprise.js +38 -0
  30. package/dist-server/server/env-path.js +18 -17
  31. package/dist-server/server/environment.js +3 -0
  32. package/dist-server/server/fleet-agent.js +57 -13
  33. package/dist-server/server/fleet-cli.js +248 -31
  34. package/dist-server/server/fleet.js +88 -14
  35. package/dist-server/server/hosted-contract.js +16 -0
  36. package/dist-server/server/index.js +1777 -406
  37. package/dist-server/server/message-db.js +21 -0
  38. package/dist-server/server/message-file.js +4 -0
  39. package/dist-server/server/peer-roster.js +48 -9
  40. package/dist-server/server/procs.js +19 -3
  41. package/dist-server/server/provider-key-check.js +18 -1
  42. package/dist-server/server/request-auth.js +14 -5
  43. package/dist-server/server/room-handoffs.js +237 -0
  44. package/dist-server/server/section-context.js +64 -14
  45. package/dist-server/server/sessions.js +115 -8
  46. package/dist-server/server/shared-computer-control.js +24 -0
  47. package/dist-server/server/shared-computers.js +131 -0
  48. package/dist-server/server/steer-queue.js +2 -0
  49. package/dist-server/server/store.js +267 -15
  50. package/dist-server/server/surface.js +16 -5
  51. package/dist-server/server/team-backup.js +17 -1
  52. package/dist-server/server/team-computers.js +108 -0
  53. package/dist-server/server/team-setup-requests.js +252 -0
  54. package/dist-server/server/tool-summary.js +53 -1
  55. package/dist-server/server/turn-dispatch-guard.js +31 -3
  56. package/dist-server/server/workspace-backup.js +1 -0
  57. package/dist-server/shared/approval-mode.js +11 -1
  58. package/dist-server/shared/team-backup.js +1 -0
  59. package/dist-server/shared/team-computer.js +1 -0
  60. package/dist-server/shared/team-setup.js +1 -0
  61. package/dist-server/vps-container-mcp.js +5 -1
  62. package/enterprise/README.md +21 -3
  63. package/enterprise/server/index.js +387 -29
  64. package/package.json +1 -1
  65. package/dist/assets/index-CeMbvsgx.js +0 -305
  66. package/dist/assets/index-DH7Zax6L.css +0 -1
@@ -9,9 +9,10 @@
9
9
  // endpoint, because EventSource cannot set headers. Failed exchanges are
10
10
  // counted per source: five in a minute lock that source out for ten.
11
11
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
12
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, unlinkSync } from "node:fs";
13
13
  import { dirname } from "node:path";
14
14
  import { z } from "zod";
15
+ import { writeFileAtomic } from "./atomic.js";
15
16
  export const SCOPES = ["admin", "client"];
16
17
  export const PAIRING_CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
17
18
  export const PAIRING_CODE_LENGTH = 12;
@@ -69,6 +70,8 @@ const sessionSchema = z.object({
69
70
  /** Set when the session came from an account sign-in rather than a code. */
70
71
  userId: z.string().max(256).optional(),
71
72
  email: z.string().max(320).optional(),
73
+ /** Set only by the internal verified-portal issuance path, never by email or pairing input. */
74
+ membershipAuthority: z.literal("portal").optional(),
72
75
  });
73
76
  const fileSchema = z.object({ version: z.literal(1), sessions: z.array(sessionSchema) });
74
77
  const sha256 = (value) => createHash("sha256").update(value).digest("hex");
@@ -127,14 +130,29 @@ export class SessionRegistry {
127
130
  lastSeenWrites = new Map();
128
131
  now;
129
132
  options;
133
+ openMarker;
134
+ closed = false;
130
135
  // No parameter properties: the server runs this file under Node's
131
136
  // strip-only TypeScript mode, which only erases types.
132
137
  constructor(options) {
133
138
  this.options = options;
134
139
  this.now = options.now ?? Date.now;
135
- this.load();
140
+ this.openMarker = `${options.file}.open`;
141
+ const unclean = existsSync(this.openMarker);
142
+ try {
143
+ mkdirSync(dirname(options.file), { recursive: true, mode: 0o700 });
144
+ // Establish the guard before accepting any persisted bearer. Even if
145
+ // every subsequent write fails, an unclean restart will see this marker.
146
+ writeFileAtomic(this.openMarker, "Session registry is open.\n", { mode: 0o600 });
147
+ this.syncDirectory();
148
+ }
149
+ catch {
150
+ throw new Error("Could not establish safe session storage.");
151
+ }
152
+ this.load(!unclean);
153
+ this.revalidateEmailSessions();
136
154
  }
137
- load() {
155
+ load(restoreAccounts) {
138
156
  if (!existsSync(this.options.file))
139
157
  return;
140
158
  let raw;
@@ -146,18 +164,50 @@ export class SessionRegistry {
146
164
  }
147
165
  const parsed = fileSchema.safeParse(raw);
148
166
  if (parsed.success)
149
- this.sessions = parsed.data.sessions;
167
+ this.sessions = parsed.data.sessions.filter(session => restoreAccounts || (session.email === undefined && session.userId === undefined));
168
+ }
169
+ syncDirectory() {
170
+ // Windows does not expose directory fsync. The open marker still guards
171
+ // normal process crashes; no stronger power-loss guarantee is claimed.
172
+ if (process.platform === "win32")
173
+ return;
174
+ const fd = openSync(dirname(this.options.file), "r");
175
+ try {
176
+ fsyncSync(fd);
177
+ }
178
+ finally {
179
+ closeSync(fd);
180
+ }
150
181
  }
151
182
  /** Atomic write, owner-only, directory owner-only. */
152
183
  persist() {
184
+ if (this.closed)
185
+ throw new Error("Session registry is closed.");
153
186
  const dir = dirname(this.options.file);
154
187
  mkdirSync(dir, { recursive: true, mode: 0o700 });
155
- const tmp = `${this.options.file}.${process.pid}.tmp`;
156
- writeFileSync(tmp, JSON.stringify({ version: 1, sessions: this.sessions }, null, 2) + "\n", { mode: 0o600 });
157
- chmodSync(tmp, 0o600);
158
- renameSync(tmp, this.options.file);
188
+ writeFileAtomic(this.options.file, JSON.stringify({ version: 1, sessions: this.sessions }, null, 2) + "\n", { mode: 0o600 });
189
+ this.syncDirectory();
190
+ }
191
+ /** Call after HTTP/stream shutdown. A failed close leaves the open marker,
192
+ * so account sessions require fresh sign-in after restart. Paired devices
193
+ * remain durable. Never remove the marker before the reduced set is saved. */
194
+ close() {
195
+ if (this.closed)
196
+ return;
197
+ try {
198
+ this.revalidateEmailSessions();
199
+ this.persist();
200
+ unlinkSync(this.openMarker);
201
+ this.closed = true;
202
+ // A marker deletion lost on power failure causes extra sign-in only;
203
+ // the session file and its rename were fsynced before removal.
204
+ }
205
+ catch {
206
+ throw new Error("Could not safely close session storage.");
207
+ }
159
208
  }
160
209
  prune() {
210
+ this.revalidateEmailSessions();
161
211
  const now = this.now();
162
212
  this.pairings = this.pairings.filter((p) => p.expiresAt > now);
163
213
  this.replays = this.replays.filter((r) => r.expiresAt > now);
@@ -293,6 +343,16 @@ export class SessionRegistry {
293
343
  /** A session from a verified account sign-in (server/account-signin.ts)
294
344
  * rather than a pairing code: same token, same term, same gates. */
295
345
  issue(input) {
346
+ return this.issueAccount(input);
347
+ }
348
+ /** Internal hosted-bridge seam: call only after consuming a verified,
349
+ * workspace-bound PKCE grant. No HTTP route accepts this marker as input. */
350
+ issuePortal(input) {
351
+ if (!/^[A-Za-z0-9_-]{43}$/.test(input.grant) || !input.email)
352
+ throw new Error("A verified portal identity is required");
353
+ return this.issueAccount({ label: "Hosted workspace", email: input.email, userId: `portal:${input.grant}`, scopes: input.scopes }, "portal");
354
+ }
355
+ issueAccount(input, membershipAuthority) {
296
356
  this.prune();
297
357
  const now = this.now();
298
358
  const token = `omb_sess_${randomBytes(32).toString("base64url")}`;
@@ -309,6 +369,8 @@ export class SessionRegistry {
309
369
  record.userId = input.userId;
310
370
  if (input.email)
311
371
  record.email = input.email;
372
+ if (membershipAuthority)
373
+ record.membershipAuthority = membershipAuthority;
312
374
  this.sessions.push(record);
313
375
  this.lastSeenWrites.set(record.id, now);
314
376
  this.persist();
@@ -326,9 +388,53 @@ export class SessionRegistry {
326
388
  this.failures.delete(source);
327
389
  }
328
390
  // ── sessions ───────────────────────────────────────────────────────────
391
+ /** Email membership is live, not a thirty-day grant. Losing any issued
392
+ * scope revokes the token and its streams; signing in again obtains the
393
+ * new role. Promotions never widen existing credentials. Pairing sessions
394
+ * have no email and remain independent of the hosted sign-in list. */
395
+ revalidateEmailSessions() {
396
+ if (this.closed)
397
+ throw new Error("Session registry is closed.");
398
+ const eligible = this.sessions.filter(session => session.email !== undefined && !(this.options.portalMembership && session.membershipAuthority === "portal"));
399
+ if (!eligible.length)
400
+ return;
401
+ let resolveScopes = this.options.emailScopes;
402
+ try {
403
+ if (this.options.emailScopesSnapshot)
404
+ resolveScopes = this.options.emailScopesSnapshot();
405
+ }
406
+ catch {
407
+ resolveScopes = undefined; // Never fall back to stale membership after a failed snapshot.
408
+ }
409
+ const revoked = eligible.filter((session) => {
410
+ try {
411
+ const allowed = resolveScopes?.(session.email);
412
+ return !allowed || session.scopes.some((scope) => !allowed.includes(scope));
413
+ }
414
+ catch {
415
+ return true; // missing or unreadable membership must fail closed
416
+ }
417
+ });
418
+ if (!revoked.length)
419
+ return;
420
+ const ids = new Set(revoked.map((session) => session.id));
421
+ this.sessions = this.sessions.filter((session) => !ids.has(session.id));
422
+ for (const session of revoked)
423
+ this.forget(session.id);
424
+ try {
425
+ this.persist();
426
+ }
427
+ catch {
428
+ // Revocation stays effective in memory and streams close. The boot
429
+ // marker prevents old account bearers from reviving after an unclean
430
+ // restart, even if membership is later restored and all writes failed.
431
+ console.error("Could not persist email session revocation.");
432
+ }
433
+ }
329
434
  authenticate(token) {
330
435
  if (!token)
331
436
  return null;
437
+ this.revalidateEmailSessions();
332
438
  const hash = sha256(token);
333
439
  const now = this.now();
334
440
  const record = this.sessions.find((s) => sameDigest(s.tokenHash, hash));
@@ -349,6 +455,7 @@ export class SessionRegistry {
349
455
  * Nothing expired is revived. Writes at most once per half-term, so it
350
456
  * adds nothing to the last-seen traffic. Returns whether it renewed. */
351
457
  renew(sessionId) {
458
+ this.revalidateEmailSessions();
352
459
  const record = this.sessions.find((s) => s.id === sessionId);
353
460
  const now = this.now();
354
461
  if (!record || record.expiresAt <= now)
@@ -0,0 +1,24 @@
1
+ /** A local desktop call competes with ordinary bot turns for the same screen.
2
+ * Leases expire if Electron crashes; human takeover always wins renewal. */
3
+ export class SharedComputerControl {
4
+ leases = new Map();
5
+ resources;
6
+ held;
7
+ constructor(resources, held) { this.resources = resources; this.held = held; }
8
+ acquire(id) {
9
+ const owner = { threadId: `shared:${id}`, generation: id };
10
+ if (this.held() || !this.resources.claim("computer:host", owner)) {
11
+ this.release(id);
12
+ throw Object.assign(new Error("This computer is in use locally or held by a person. Wait, then observe it again before acting."), { status: 409 });
13
+ }
14
+ clearTimeout(this.leases.get(id));
15
+ this.leases.set(id, setTimeout(() => this.release(id), 40_000));
16
+ }
17
+ release(id) {
18
+ clearTimeout(this.leases.get(id));
19
+ this.leases.delete(id);
20
+ this.resources.release({ threadId: `shared:${id}`, generation: id });
21
+ }
22
+ close() { for (const id of this.leases.keys())
23
+ this.release(id); }
24
+ }
@@ -0,0 +1,131 @@
1
+ import { randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { z } from "zod";
3
+ const folder = z.object({ id: z.string().uuid(), name: z.string().min(1).max(120), write: z.boolean() }).strict();
4
+ export const sharedComputerRegistration = z.object({
5
+ id: z.string().uuid(), name: z.string().min(1).max(120), environmentId: z.string().uuid(),
6
+ folders: z.array(folder).max(20), terminal: z.boolean(), computer: z.boolean(),
7
+ }).strict();
8
+ export const sharedComputerOperation = z.object({
9
+ computer_id: z.string().uuid(),
10
+ action: z.enum(["list_files", "read_file", "write_file", "run_command", "computer_tools", "computer_call"]),
11
+ folder_id: z.string().uuid().optional(), path: z.string().max(2048).optional(),
12
+ content: z.string().max(350_000).optional(), encoding: z.enum(["utf8", "base64"]).optional(),
13
+ expected_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
14
+ command: z.string().max(8000).optional(), tool_name: z.string().max(100).optional(),
15
+ arguments: z.record(z.string(), z.unknown()).optional(),
16
+ }).strict();
17
+ const failure = (message, status = 409) => Object.assign(new Error(message), { status });
18
+ /** In-memory rendezvous only. Authority over local resources stays on the
19
+ * desktop. Restart/disconnect NEVER replays an operation with an unknown outcome. */
20
+ export class SharedComputers {
21
+ computers = new Map();
22
+ sessionLive;
23
+ constructor(sessionLive) { this.sessionLive = sessionLive; }
24
+ register(registration, owner, secret) {
25
+ if (!/^[a-f0-9]{64}$/.test(secret))
26
+ throw failure("Invalid computer credential", 400);
27
+ const old = this.computers.get(registration.id);
28
+ if (old) {
29
+ this.authorize(registration.id, owner, secret);
30
+ old.registration = registration;
31
+ old.seen = Date.now();
32
+ }
33
+ else {
34
+ // Reap dead devices before applying the bounded registration limit.
35
+ for (const [id, entry] of this.computers)
36
+ if (!this.online(entry) && entry.jobs.size === 0)
37
+ this.computers.delete(id);
38
+ if (this.computers.size >= 20)
39
+ throw failure("Too many shared computers");
40
+ this.computers.set(registration.id, { registration, owner, secret, seen: Date.now(), jobs: new Map() });
41
+ }
42
+ }
43
+ online(entry) { return this.sessionLive(entry.owner) && Date.now() - entry.seen < 40_000; }
44
+ authorize(id, owner, secret) {
45
+ const entry = this.computers.get(id);
46
+ if (!entry || entry.owner !== owner || !this.sessionLive(owner) || !/^[a-f0-9]{64}$/.test(secret) || !timingSafeEqual(Buffer.from(secret), Buffer.from(entry.secret)))
47
+ throw failure("Shared computer is disconnected or not authorized", 403);
48
+ return entry;
49
+ }
50
+ list() { return [...this.computers.values()].filter(entry => this.online(entry)).map(entry => entry.registration); }
51
+ async poll(id, owner, secret) {
52
+ const entry = this.authorize(id, owner, secret);
53
+ if (entry.wake)
54
+ throw failure("A computer poll is already running");
55
+ entry.seen = Date.now();
56
+ const next = () => {
57
+ for (const job of entry.jobs.values()) {
58
+ if (!job.active()) {
59
+ this.finish(entry, job, failure("The requesting turn ended"));
60
+ continue;
61
+ }
62
+ if (!job.sent) {
63
+ job.sent = true;
64
+ return { id: job.id, operation: job.operation };
65
+ }
66
+ }
67
+ return null;
68
+ };
69
+ const queued = next();
70
+ if (queued)
71
+ return queued;
72
+ await new Promise(resolve => {
73
+ const timer = setTimeout(() => { entry.wake = undefined; resolve(); }, 20_000);
74
+ entry.wake = () => { clearTimeout(timer); entry.wake = undefined; resolve(); };
75
+ });
76
+ this.authorize(id, owner, secret);
77
+ entry.seen = Date.now();
78
+ return next();
79
+ }
80
+ liveJob(id, owner, secret, jobId) {
81
+ const entry = this.authorize(id, owner, secret);
82
+ entry.seen = Date.now();
83
+ const job = entry.jobs.get(jobId);
84
+ return job?.sent === true && job.active();
85
+ }
86
+ complete(id, owner, secret, jobId, result) {
87
+ const entry = this.authorize(id, owner, secret);
88
+ const job = entry.jobs.get(jobId);
89
+ if (!job || !job.sent)
90
+ throw failure("This computer operation expired; it will not be replayed");
91
+ this.finish(entry, job, job.active() ? null : failure("The requesting turn ended"), result);
92
+ }
93
+ disconnect(id, owner, secret) {
94
+ const entry = this.authorize(id, owner, secret);
95
+ this.computers.delete(id);
96
+ entry.wake?.();
97
+ for (const job of entry.jobs.values())
98
+ this.finish(entry, job, failure("Computer disconnected. An in-flight action may have completed; inspect before retrying."));
99
+ }
100
+ request(operation, active) {
101
+ const entry = this.computers.get(operation.computer_id);
102
+ if (!entry || !this.online(entry))
103
+ return Promise.reject(failure("This shared computer is offline. Keep its desktop app open; do not substitute files on the server."));
104
+ if (entry.jobs.size >= 1)
105
+ return Promise.reject(failure("This shared computer is busy. Wait for its current action to finish."));
106
+ if (!active())
107
+ return Promise.reject(failure("The requesting turn ended", 401));
108
+ return new Promise((resolve, reject) => {
109
+ const id = randomUUID();
110
+ const job = { id, operation, active, sent: false, resolve, reject, timer: setTimeout(() => this.finish(entry, job, failure("Computer action timed out. Its outcome is unknown; inspect before retrying.")), 45_000) };
111
+ entry.jobs.set(id, job);
112
+ entry.wake?.();
113
+ });
114
+ }
115
+ close() {
116
+ for (const entry of this.computers.values()) {
117
+ entry.wake?.();
118
+ for (const job of entry.jobs.values())
119
+ this.finish(entry, job, failure("Workspace stopped. Inspect any in-flight action before retrying."));
120
+ }
121
+ this.computers.clear();
122
+ }
123
+ finish(entry, job, error, result) {
124
+ clearTimeout(job.timer);
125
+ entry.jobs.delete(job.id);
126
+ if (error)
127
+ job.reject(error);
128
+ else
129
+ job.resolve(result);
130
+ }
131
+ }
@@ -71,6 +71,7 @@ export function queueSteeredMessage(botId, threadId, text, options = {}) {
71
71
  sendId: options.sendId,
72
72
  reason: options.reason,
73
73
  unattended: options.unattended,
74
+ peerAsk: options.peerAsk,
74
75
  };
75
76
  saveChatFollowup({ id, kind: "bot", ownerId: botId, threadId, payload: item });
76
77
  entry.items.push(item);
@@ -132,6 +133,7 @@ export function drainSteeredMessages(store, run, isBlocked) {
132
133
  replyToId: item.replyToId,
133
134
  sendId: item.sendId,
134
135
  queueId: item.messageId,
136
+ peerAsk: item.peerAsk,
135
137
  }));
136
138
  }
137
139
  const last = appended.at(-1);