openmausbot 0.1.66 → 0.1.67

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 (42) hide show
  1. package/dist/assets/index-A3CBTxwl.css +1 -0
  2. package/dist/assets/{index-kW5L1QeO.js → index-BwpNqgCm.js} +1 -1
  3. package/dist/assets/index-CDuyoVPJ.js +278 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/computer-proxy.js +14 -3
  6. package/dist-server/container-mcp.js +42 -19
  7. package/dist-server/drivers/agents-proxy.js +31 -0
  8. package/dist-server/index.js +2416 -993
  9. package/dist-server/local-computer-proxy.js +433 -0
  10. package/dist-server/local-computer.js +48 -5
  11. package/dist-server/mcp-server.js +38 -17
  12. package/dist-server/openmausbot.js +1346 -726
  13. package/dist-server/pair-cli.js +1346 -726
  14. package/dist-server/proxy-paths.js +1 -0
  15. package/dist-server/server/account-signin.js +103 -0
  16. package/dist-server/server/caddy.js +203 -0
  17. package/dist-server/server/cli.js +163 -22
  18. package/dist-server/server/computer-proxy.js +11 -2
  19. package/dist-server/server/config.js +15 -0
  20. package/dist-server/server/control-client.js +7 -1
  21. package/dist-server/server/drivers/agents-proxy.js +34 -0
  22. package/dist-server/server/drivers/claude-login-auth.js +334 -0
  23. package/dist-server/server/drivers/claude.js +8 -0
  24. package/dist-server/server/environment.js +1 -0
  25. package/dist-server/server/harness/registry.js +7 -1
  26. package/dist-server/server/index.js +778 -310
  27. package/dist-server/server/local-computer-proxy.js +28 -0
  28. package/dist-server/server/local-computer.js +17 -0
  29. package/dist-server/server/mcp-bridge.js +27 -11
  30. package/dist-server/server/peer-approval.js +19 -6
  31. package/dist-server/server/proxy-paths.js +1 -0
  32. package/dist-server/server/sessions.js +42 -1
  33. package/dist-server/server/steer-queue.js +10 -8
  34. package/dist-server/server/store.js +234 -34
  35. package/dist-server/server/team-backup.js +1 -0
  36. package/dist-server/server/tunnel.js +27 -1
  37. package/dist-server/server/turn-resources.js +50 -0
  38. package/dist-server/server/workspace.js +53 -3
  39. package/dist-server/vps-container-mcp.js +42 -19
  40. package/package.json +1 -1
  41. package/dist/assets/index-BftlaHAF.css +0 -1
  42. package/dist/assets/index-DuR3H-Hf.js +0 -278
@@ -0,0 +1,28 @@
1
+ // Near-side gate for the host CUA process. The descriptor and turn token
2
+ // travel in environment variables, never command-line arguments or logs.
3
+ import { runMcpBridge } from "./mcp-bridge.js";
4
+ import { augmentedPath } from "./env-path.js";
5
+ const { OMB_CUA_COMMAND: command, OMB_CUA_ARGS: encodedArgs, OMB_CONTROL_URL: url, OMB_CONTROL_TOKEN: token, ...childEnv } = process.env;
6
+ let args;
7
+ try {
8
+ const parsed = JSON.parse(encodedArgs ?? "");
9
+ const endpoint = new URL(url ?? "");
10
+ if (!command?.trim() || command.includes("\0") ||
11
+ !Array.isArray(parsed) || !parsed.every((arg) => typeof arg === "string" && !arg.includes("\0")) ||
12
+ !token || !["http:", "https:"].includes(endpoint.protocol) ||
13
+ !["127.0.0.1", "localhost", "[::1]"].includes(endpoint.hostname) || endpoint.username || endpoint.password) {
14
+ throw new Error("invalid connection");
15
+ }
16
+ args = parsed;
17
+ }
18
+ catch {
19
+ process.stderr.write("invalid local computer proxy connection\n");
20
+ process.exit(2);
21
+ }
22
+ runMcpBridge({
23
+ command: command,
24
+ args,
25
+ env: { ...childEnv, PATH: augmentedPath() },
26
+ label: "Local Cua Driver",
27
+ gate: { url: url, token: token },
28
+ });
@@ -1,6 +1,7 @@
1
1
  import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join } from "node:path";
4
+ import { SPAWNED_PROXIES } from "./proxy-paths.js";
4
5
  export const REQUIRED_LINUX_TOOLS = ["click", "get_window_state", "list_apps", "type_text"];
5
6
  // Keep this exact field set synchronized with DRIVER_FILE_IDENTITY_KEYS in
6
7
  // electron/cua-linux.cjs; Electron publishes it and the server revalidates it.
@@ -14,6 +15,22 @@ export const DRIVER_FILE_IDENTITY_KEYS = [
14
15
  "mtimeNs",
15
16
  "ctimeNs",
16
17
  ];
18
+ /** Keep the platform descriptor intact, but acquire computer authority only
19
+ * when the first actual tool call reaches the shared stdio gate. */
20
+ export function gatedLocalComputer(connection, control) {
21
+ return {
22
+ ...connection,
23
+ command: process.execPath,
24
+ args: ["--experimental-strip-types", SPAWNED_PROXIES.localComputer],
25
+ env: {
26
+ ...connection.env,
27
+ OMB_CUA_COMMAND: connection.command,
28
+ OMB_CUA_ARGS: JSON.stringify(connection.args),
29
+ OMB_CONTROL_URL: control.url,
30
+ OMB_CONTROL_TOKEN: control.token,
31
+ },
32
+ };
33
+ }
17
34
  function exactKeys(value, keys) {
18
35
  const expected = new Set(keys);
19
36
  return Object.keys(value).length === expected.size && Object.keys(value).every((key) => expected.has(key));
@@ -1,5 +1,4 @@
1
- // The one stdio bridge behind both MCP entry points
2
- // (container-mcp.ts for the Local VM, vps-container-mcp.ts for the BYO VPS).
1
+ // The shared stdio bridge for the host computer, Local VM, and BYO VPS.
3
2
  //
4
3
  // It is almost transparent — bytes in, bytes out — with two deliberate
5
4
  // near-side exceptions:
@@ -152,7 +151,7 @@ export function createGateInterceptor(options) {
152
151
  options.forward(line);
153
152
  return;
154
153
  }
155
- const held = await options.isHeld().catch(() => false);
154
+ const held = await options.isHeld().catch(() => true);
156
155
  if (!held) {
157
156
  options.forward(line);
158
157
  return;
@@ -160,9 +159,10 @@ export function createGateInterceptor(options) {
160
159
  options.refuse(JSON.stringify({
161
160
  jsonrpc: "2.0",
162
161
  id: frame.id ?? null,
163
- result: { content: [{ type: "text", text: refusalText }], isError: true },
162
+ result: { content: [{ type: "text", text: options.getRefusalReason?.() ?? refusalText }], isError: true },
164
163
  }));
165
164
  });
165
+ return queue;
166
166
  };
167
167
  }
168
168
  /** The near-side MCP method filter. `ping` is answered here so the bundled
@@ -175,8 +175,9 @@ export function createMcpBridgeInterceptor(options) {
175
175
  forward: options.forward,
176
176
  refuse: options.answer,
177
177
  refusalText: options.gate.refusalText,
178
+ getRefusalReason: options.gate.getRefusalReason,
178
179
  })
179
- : options.forward;
180
+ : (line) => { options.forward(line); };
180
181
  return (line) => {
181
182
  let frame = null;
182
183
  try {
@@ -192,13 +193,13 @@ export function createMcpBridgeInterceptor(options) {
192
193
  }
193
194
  return;
194
195
  }
195
- afterPing(line);
196
+ return afterPing(line);
196
197
  };
197
198
  }
198
199
  export function runMcpBridge(options) {
199
200
  const child = spawn(options.command, options.args, {
200
201
  shell: false,
201
- env: { ...process.env, PATH: augmentedPath() },
202
+ env: options.env ?? { ...process.env, PATH: augmentedPath() },
202
203
  stdio: ["pipe", "pipe", "pipe"],
203
204
  });
204
205
  // docker may exit before it drains stdin; pipe() leaves this error unhandled.
@@ -207,24 +208,39 @@ export function runMcpBridge(options) {
207
208
  const client = options.gate
208
209
  ? createControlClient({ url: options.gate.url, token: options.gate.token })
209
210
  : null;
211
+ let refusalReason;
210
212
  const answer = (line) => process.stdout.write(line + "\n");
211
213
  const forward = (line) => child.stdin.write(line + "\n");
212
- const inbound = createLineSplitter(createMcpBridgeInterceptor({
214
+ const intercept = createMcpBridgeInterceptor({
213
215
  answer,
214
216
  forward,
215
217
  ...(options.gate
216
218
  ? {
217
219
  gate: {
218
- isHeld: async () => (await client.state(true)).held,
220
+ isHeld: async () => {
221
+ refusalReason = undefined;
222
+ const state = await client.state(true);
223
+ refusalReason = state.blockedReason;
224
+ return state.held;
225
+ },
226
+ getRefusalReason: () => refusalReason,
219
227
  },
220
228
  }
221
229
  : {}),
222
- }));
230
+ });
231
+ let pendingInput = Promise.resolve();
232
+ const inbound = createLineSplitter((line) => {
233
+ const completion = intercept(line);
234
+ if (completion)
235
+ pendingInput = completion;
236
+ });
223
237
  const onStdin = (chunk) => inbound.push(chunk);
224
238
  process.stdin.on("data", onStdin);
225
239
  process.stdin.on("end", () => {
226
240
  inbound.flush();
227
- child.stdin.end();
241
+ // A final tools/call can still be awaiting the control endpoint. Do not
242
+ // close the far end before the serialized gate has forwarded it.
243
+ void pendingInput.finally(() => child.stdin.end());
228
244
  });
229
245
  // Injected responses and refusals must never land inside one of the
230
246
  // child's half-written frames, so the child's stdout is re-emitted at
@@ -31,9 +31,17 @@ function settleCard(pending, behavior, source) {
31
31
  });
32
32
  // answered (by whoever): the turn is working again — the same hand-back
33
33
  // the request.resolved fold does for a provider's card
34
- const waiting = pending.bus.store.bot(pending.fromBotId);
35
- if (waiting?.activity === "waiting-on-you")
36
- pending.bus.store.setActivity(waiting.id, "working");
34
+ const store = pending.bus.store;
35
+ const task = store.projectBotForTask(pending.fromBotId, pending.threadId);
36
+ if (task) {
37
+ if (task.activity === "waiting-on-you")
38
+ store.setTaskActivity(task.id, pending.threadId, "working");
39
+ }
40
+ else if (store.groupByThread(pending.threadId)) {
41
+ const waiting = store.bot(pending.fromBotId);
42
+ if (waiting?.activity === "waiting-on-you")
43
+ store.setActivity(waiting.id, "working");
44
+ }
37
45
  }
38
46
  /** requestId → pending ask. Lives only in memory — restarting the
39
47
  * server cancels every in-flight approval, like provider permissions do. */
@@ -79,10 +87,15 @@ function pushApprovalCard(bus, from, target, message, action, requestId, sourceT
79
87
  function announceCard(bus, from, card, sourceThreadId) {
80
88
  // the bot is not working now — it is waiting on a person. A delegation
81
89
  // drained after its turn has already settled is idle, and stays so.
82
- const live = bus.store.bot(from.id);
83
- if (live?.busy)
84
- bus.store.setActivity(from.id, "waiting-on-you");
90
+ const task = bus.store.projectBotForTask(from.id, sourceThreadId);
85
91
  const room = bus.store.groupByThread(sourceThreadId);
92
+ if (task) {
93
+ if (task.busy)
94
+ bus.store.setTaskActivity(from.id, sourceThreadId, "waiting-on-you");
95
+ }
96
+ else if (room && bus.store.bot(from.id)?.busy) {
97
+ bus.store.setActivity(from.id, "waiting-on-you");
98
+ }
86
99
  const group = room && !room.dm ? { id: room.id, name: room.name } : undefined;
87
100
  const title = card.card?.title ?? "";
88
101
  const detail = card.card?.subtitle ? `${title} — ${card.card.subtitle}` : title;
@@ -33,6 +33,7 @@ export function resolveProxy(relative) {
33
33
  export const SPAWNED_PROXIES = {
34
34
  browser: resolveProxy("browser-proxy"),
35
35
  computer: resolveProxy("computer-proxy"),
36
+ localComputer: resolveProxy("local-computer-proxy"),
36
37
  permission: resolveProxy("permission-proxy"),
37
38
  containerMcp: resolveProxy("container-mcp"),
38
39
  vpsContainerMcp: resolveProxy("vps-container-mcp"),
@@ -66,6 +66,9 @@ const sessionSchema = z.object({
66
66
  createdAt: z.number(),
67
67
  lastSeenAt: z.number(),
68
68
  expiresAt: z.number(),
69
+ /** Set when the session came from an account sign-in rather than a code. */
70
+ userId: z.string().max(256).optional(),
71
+ email: z.string().max(320).optional(),
69
72
  });
70
73
  const fileSchema = z.object({ version: z.literal(1), sessions: z.array(sessionSchema) });
71
74
  const sha256 = (value) => createHash("sha256").update(value).digest("hex");
@@ -102,7 +105,7 @@ export function formatPairingCode(code) {
102
105
  return code.match(/.{1,4}/g)?.join("-") ?? code;
103
106
  }
104
107
  function publicSession(record) {
105
- return {
108
+ const view = {
106
109
  id: record.id,
107
110
  label: record.label,
108
111
  scopes: [...record.scopes],
@@ -110,6 +113,9 @@ function publicSession(record) {
110
113
  lastSeenAt: record.lastSeenAt,
111
114
  expiresAt: record.expiresAt,
112
115
  };
116
+ if (record.email)
117
+ view.email = record.email;
118
+ return view;
113
119
  }
114
120
  export class SessionRegistry {
115
121
  sessions = [];
@@ -284,6 +290,41 @@ export class SessionRegistry {
284
290
  this.replays.push({ codeHash: presented, attemptId, result, expiresAt: now + EXCHANGE_REPLAY_MS });
285
291
  return result;
286
292
  }
293
+ /** A session from a verified account sign-in (server/account-signin.ts)
294
+ * rather than a pairing code: same token, same term, same gates. */
295
+ issue(input) {
296
+ this.prune();
297
+ const now = this.now();
298
+ const token = `omb_sess_${randomBytes(32).toString("base64url")}`;
299
+ const record = {
300
+ id: randomUUID(),
301
+ tokenHash: sha256(token),
302
+ label: (input.label.trim() || "Unnamed device").slice(0, 80),
303
+ scopes: [...new Set(input.scopes)],
304
+ createdAt: now,
305
+ lastSeenAt: now,
306
+ expiresAt: now + Math.min(SESSION_TTL_MS, SESSION_MAX_AGE_MS),
307
+ };
308
+ if (input.userId)
309
+ record.userId = input.userId;
310
+ if (input.email)
311
+ record.email = input.email;
312
+ this.sessions.push(record);
313
+ this.lastSeenWrites.set(record.id, now);
314
+ this.persist();
315
+ return { token, session: publicSession(record) };
316
+ }
317
+ /** The pairing lockout, for other code-like exchanges on the same source. */
318
+ attemptAllowed(source) {
319
+ const lock = this.lockState(source);
320
+ return lock.locked ? { ok: false, retryAfterMs: lock.retryAfterMs } : { ok: true };
321
+ }
322
+ noteFailure(source) {
323
+ this.recordFailure(source);
324
+ }
325
+ clearFailures(source) {
326
+ this.failures.delete(source);
327
+ }
287
328
  // ── sessions ───────────────────────────────────────────────────────────
288
329
  authenticate(token) {
289
330
  if (!token)
@@ -36,24 +36,26 @@ export function queueSteeredMessage(botId, threadId, text, options = {}) {
36
36
  queues.set(threadId, entry);
37
37
  return { id };
38
38
  }
39
- /** Drain every queue whose bot is idle: append the held lines (leaf is now
39
+ /** Drain every queue whose task is idle: append the held lines (leaf is now
40
40
  * the finished turn's last item), then one run per thread whose prompt is
41
41
  * the texts separated by a blank line. `userMessage` is the last appended line
42
42
  * so startTurn does not duplicate it; `excludeIds` is every drained line
43
43
  * so transcript-replay adapters do not also see earlier queued texts.
44
44
  * Entries leave the map BEFORE running so a settle racing another settle
45
45
  * can never fire the same queue twice. */
46
- export function drainSteeredMessages(store, run) {
46
+ export function drainSteeredMessages(store, run, isBlocked) {
47
47
  // deleting only the entry being visited is safe under Map iteration
48
48
  for (const [threadId, entry] of queues) {
49
- const bot = store.bot(entry.botId);
49
+ const bot = store.projectBotForTask
50
+ ? store.projectBotForTask(entry.botId, threadId)
51
+ : store.bot(entry.botId);
50
52
  if (!bot) {
51
- // the bot was deleted while messages waited — nothing left to steer
53
+ // the bot or task was deleted while messages waited
52
54
  queues.delete(threadId);
53
55
  continue;
54
56
  }
55
- if (bot.busy)
56
- continue; // still working — the next settle tries again
57
+ if (bot.busy || isBlocked?.(entry.botId, threadId))
58
+ continue;
57
59
  // committed to draining: the entry leaves the map before anything runs,
58
60
  // so a settle racing another settle can never fire the same queue twice
59
61
  queues.delete(threadId);
@@ -93,9 +95,9 @@ export function queuedSteeredMessage(botId, threadId, sendId) {
93
95
  * is stable even if the bot switches away from the task while the request is
94
96
  * in flight. Returns false when it was already drained, belongs to another
95
97
  * bot, or a restart lost the in-memory auto-run intent. */
96
- export function cancelSteeredMessage(botId, messageId) {
98
+ export function cancelSteeredMessage(botId, messageId, expectedThreadId) {
97
99
  for (const [threadId, entry] of queues) {
98
- if (entry.botId !== botId)
100
+ if (entry.botId !== botId || (expectedThreadId !== undefined && threadId !== expectedThreadId))
99
101
  continue;
100
102
  const items = entry.items.filter((item) => item.messageId !== messageId);
101
103
  if (items.length === entry.items.length)