openmausbot 0.1.76 → 0.1.78

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 (64) hide show
  1. package/dist/assets/{index-C6blGSq5.js → index-BqDJqf2C.js} +1 -1
  2. package/dist/assets/index-DjwAroIZ.js +305 -0
  3. package/dist/assets/index-Pbb6Ao0s.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +5 -1
  6. package/dist-server/drivers/agents-proxy.js +838 -30
  7. package/dist-server/enterprise/server/index.js +387 -29
  8. package/dist-server/index.js +4097 -2063
  9. package/dist-server/openmausbot.js +730 -523
  10. package/dist-server/pair-cli.js +730 -523
  11. package/dist-server/server/agent-tool-policy.js +1 -0
  12. package/dist-server/server/auto-approve.js +16 -0
  13. package/dist-server/server/bot-overview.js +6 -2
  14. package/dist-server/server/bot-package.js +17 -3
  15. package/dist-server/server/browser-live.js +3 -0
  16. package/dist-server/server/chief-of-staff.js +2 -2
  17. package/dist-server/server/config.js +16 -0
  18. package/dist-server/server/drivers/acp/core.js +3 -1
  19. package/dist-server/server/drivers/agents-proxy.js +132 -27
  20. package/dist-server/server/drivers/boxagent.js +1 -1
  21. package/dist-server/server/drivers/claude.js +3 -2
  22. package/dist-server/server/drivers/codex.js +3 -1
  23. package/dist-server/server/drivers/pi.js +4 -0
  24. package/dist-server/server/enterprise.js +38 -0
  25. package/dist-server/server/environment.js +3 -0
  26. package/dist-server/server/fleet-agent.js +57 -13
  27. package/dist-server/server/fleet-cli.js +248 -31
  28. package/dist-server/server/fleet.js +88 -14
  29. package/dist-server/server/hosted-contract.js +16 -0
  30. package/dist-server/server/index.js +877 -197
  31. package/dist-server/server/message-file.js +4 -0
  32. package/dist-server/server/package-export.js +16 -14
  33. package/dist-server/server/peer-approval.js +1 -1
  34. package/dist-server/server/profile-requests.js +26 -4
  35. package/dist-server/server/provider-key-check.js +18 -1
  36. package/dist-server/server/request-auth.js +14 -5
  37. package/dist-server/server/room-handoffs.js +38 -1
  38. package/dist-server/server/routine-requests.js +86 -3
  39. package/dist-server/server/routines.js +31 -13
  40. package/dist-server/server/sessions.js +115 -8
  41. package/dist-server/server/setup-mode.js +11 -13
  42. package/dist-server/server/shared-computer-control.js +24 -0
  43. package/dist-server/server/shared-computers.js +131 -0
  44. package/dist-server/server/skill-learn.js +4 -4
  45. package/dist-server/server/skills.js +21 -0
  46. package/dist-server/server/steer-queue.js +2 -0
  47. package/dist-server/server/store.js +110 -8
  48. package/dist-server/server/system-prompt.js +7 -5
  49. package/dist-server/server/team-backup.js +6 -2
  50. package/dist-server/server/team-computers.js +108 -0
  51. package/dist-server/server/team-setup-requests.js +83 -31
  52. package/dist-server/server/tool-summary.js +53 -1
  53. package/dist-server/server/workspace-backup.js +1 -0
  54. package/dist-server/shared/approval-mode.js +4 -1
  55. package/dist-server/shared/cron-label.js +39 -0
  56. package/dist-server/shared/routine-schedule.js +89 -0
  57. package/dist-server/shared/team-backup.js +1 -1
  58. package/dist-server/shared/team-computer.js +1 -0
  59. package/dist-server/vps-container-mcp.js +5 -1
  60. package/enterprise/README.md +21 -3
  61. package/enterprise/server/index.js +387 -29
  62. package/package.json +1 -1
  63. package/dist/assets/index-BtT047dk.css +0 -1
  64. package/dist/assets/index-fKXFpB_J.js +0 -305
@@ -98,8 +98,9 @@ export function templateUnit(spec) {
98
98
  "# Written by `openmausbot fleet init`. One unit for every workspace: %i is the slug.",
99
99
  "[Unit]",
100
100
  "Description=OpenMausBot workspace %i",
101
- "After=network-online.target",
101
+ "After=network-online.target openmausbot-fence.service",
102
102
  "Wants=network-online.target",
103
+ "Requires=openmausbot-fence.service",
103
104
  "",
104
105
  "[Service]",
105
106
  "Type=simple",
@@ -131,7 +132,6 @@ export function fenceUnit(layout = fleetLayout()) {
131
132
  "# Written by `openmausbot fleet init`: keeps each workspace's loopback ports to its own user.",
132
133
  "[Unit]",
133
134
  "Description=OpenMausBot per-workspace loopback fence",
134
- "Before=openmausbot@.service",
135
135
  "",
136
136
  "[Service]",
137
137
  "Type=oneshot",
@@ -149,14 +149,17 @@ export function fenceUnit(layout = fleetLayout()) {
149
149
  export function fenceRules(workspaces) {
150
150
  const lines = ["#!/usr/sbin/nft -f", "# Written by `openmausbot fleet`; regenerated on every create, suspend, resume and delete.", "add table inet openmausbot", "flush table inet openmausbot", "table inet openmausbot {", "\tchain output {", "\t\ttype filter hook output priority 0; policy accept;"];
151
151
  for (const workspace of [...workspaces].sort((a, b) => a.slug.localeCompare(b.slug))) {
152
+ if (workspace.status !== "running" && workspace.status !== "suspended" && workspace.status !== "retained" && !workspace.accountCreated)
153
+ continue;
152
154
  lines.push(`\t\toif lo tcp dport { ${workspace.port}, ${workspace.webhookPort} } meta skuid != { ${fleetUser(workspace.slug)}, caddy, root } reject`);
153
155
  }
154
156
  lines.push("\t}", "}", "");
155
157
  return lines.join("\n");
156
158
  }
157
159
  export function caddySite(workspace) {
158
- if (workspace.status === "suspended") {
159
- return [`${workspace.host} {`, "\trespond \"This workspace is suspended.\" 503", "}", ""].join("\n");
160
+ if (workspace.status !== "running") {
161
+ const message = workspace.status === "suspended" ? "This workspace is suspended." : "This workspace is not available.";
162
+ return [`${workspace.host} {`, `\trespond "${message}" 503`, "}", ""].join("\n");
160
163
  }
161
164
  return [
162
165
  `${workspace.host} {`,
@@ -185,14 +188,29 @@ export function instanceEnv(input) {
185
188
  ];
186
189
  if (input.licenseKey)
187
190
  lines.push(`OMB_LICENSE_KEY=${input.licenseKey}`);
191
+ if (input.portalUrl)
192
+ lines.push(`OMB_ADMIN_URL=${input.portalUrl}`, `OMB_ADMIN_WORKSPACE=${input.workspace.slug}`);
188
193
  return lines.join("\n") + "\n";
189
194
  }
195
+ export const MANAGED_OPENROUTER = "omb-managed-openrouter";
196
+ /** The gateway validates assignments too; keep filesystem seeds bounded and literal. */
197
+ export function openRouterModelIds(value) {
198
+ if (!Array.isArray(value) || value.length > 100 || value.some((id) => typeof id !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._:/+-]{0,159}$/.test(id))) {
199
+ throw new Error("OpenRouter models must be an array of at most 100 valid model IDs");
200
+ }
201
+ return [...new Set(value)];
202
+ }
203
+ export function managedOpenRouterModels(models) {
204
+ return Object.fromEntries(openRouterModelIds(models).map((id) => [id, { name: id }]));
205
+ }
190
206
  /** The workspace's first config.json: who may sign in, the key it runs on,
191
207
  * and its cap. Everything else stays the server's defaults. */
192
208
  export function initialConfig(seed) {
193
209
  const config = { signIn: { admins: seed.admins, members: seed.members } };
194
210
  if (seed.anthropicKey)
195
- config.anthropic = { key: seed.anthropicKey };
211
+ config.anthropic = { key: seed.anthropicKey, ...(seed.anthropicUrl ? { url: seed.anthropicUrl } : {}) };
212
+ if (seed.openrouterDefault && seed.openrouterModels?.length)
213
+ config.defaultModelSelection = { instanceId: "opencodeGo", model: `${MANAGED_OPENROUTER}/${seed.openrouterModels[0]}` };
196
214
  if (seed.monthlyCapUsd !== undefined)
197
215
  config.budgets = { monthlyUsd: seed.monthlyCapUsd };
198
216
  return `${JSON.stringify(config, null, 2)}\n`;
@@ -238,6 +256,26 @@ export function initPlan(input) {
238
256
  }
239
257
  export function createPlan(input) {
240
258
  assertSlug(input.slug);
259
+ if (input.seed.portalUrl) {
260
+ const portal = new URL(input.seed.portalUrl);
261
+ if (portal.protocol !== "https:" || portal.origin !== input.seed.portalUrl)
262
+ throw new Error("portalUrl must be an HTTPS origin without a path or credentials");
263
+ if (portal.hostname === `${input.slug}.${input.registry.domain}`)
264
+ throw new Error("this workspace name is reserved for the admin portal");
265
+ if (input.seed.anthropicUrl && input.seed.anthropicUrl !== `${portal.origin}/api/gateway/${input.slug}/anthropic`)
266
+ throw new Error("managed provider URL must belong to this workspace's portal gateway");
267
+ }
268
+ else if (input.seed.anthropicUrl)
269
+ throw new Error("a managed provider URL requires portalUrl");
270
+ if (input.seed.openrouterKey !== undefined || input.seed.openrouterUrl !== undefined || input.seed.openrouterModels !== undefined || input.seed.openrouterDefault) {
271
+ if (!input.seed.portalUrl || input.seed.openrouterUrl !== `${input.seed.portalUrl}/api/gateway/${input.slug}/openrouter/v1`)
272
+ throw new Error("managed OpenRouter URL must belong to this workspace's portal gateway");
273
+ if (!input.seed.openrouterKey || !/^[!-~]+$/.test(input.seed.openrouterKey))
274
+ throw new Error("managed OpenRouter requires a non-empty workspace credential without whitespace");
275
+ const models = openRouterModelIds(input.seed.openrouterModels ?? []);
276
+ if (input.seed.openrouterDefault && !models.length)
277
+ throw new Error("a default OpenRouter model requires a model assignment");
278
+ }
241
279
  if (input.registry.workspaces[input.slug])
242
280
  throw new Error(`workspace "${input.slug}" already exists`);
243
281
  if (!input.seed.admins.length)
@@ -257,15 +295,32 @@ export function createPlan(input) {
257
295
  createdAt: (input.now ?? new Date()).toISOString(),
258
296
  };
259
297
  const registry = { ...input.registry, nextPort: next, workspaces: { ...input.registry.workspaces, [workspace.slug]: workspace } };
298
+ const reserved = (accountCreated) => ({
299
+ ...registry,
300
+ workspaces: { ...registry.workspaces, [workspace.slug]: { ...workspace, status: "provisioning", accountCreated } },
301
+ });
260
302
  const user = fleetUser(workspace.slug);
261
303
  const home = workspaceHome(layout, workspace.slug);
262
304
  const dataDir = workspaceDataDir(layout, workspace.slug);
263
305
  const steps = [
306
+ // Reserve the identity and ports before any external resource is created.
307
+ // A crash leaves an inspectable reservation, never a silently reusable home.
308
+ { kind: "write", path: layout.registryFile, content: `${JSON.stringify(reserved(false), null, 2)}\n`, mode: 0o600 },
264
309
  { kind: "run", argv: ["useradd", "--system", "--create-home", "--home-dir", home, "--shell", "/usr/sbin/nologin", "--user-group", user], why: `the workspace's own account` },
310
+ { kind: "write", path: layout.registryFile, content: `${JSON.stringify(reserved(true), null, 2)}\n`, mode: 0o600 },
265
311
  { kind: "mkdir", path: dataDir, mode: 0o700, owner: user },
266
312
  { kind: "write", path: join(dataDir, "config.json"), content: initialConfig(input.seed), mode: 0o600, owner: user },
313
+ ...(input.seed.openrouterKey ? [
314
+ { kind: "mkdir", path: join(home, ".config"), mode: 0o700, owner: user },
315
+ { kind: "mkdir", path: join(home, ".config", "opencode"), mode: 0o700, owner: user },
316
+ { kind: "write", path: join(home, ".config", "opencode", "opencode.json"), mode: 0o600, owner: user, content: `${JSON.stringify({ provider: { [MANAGED_OPENROUTER]: {
317
+ npm: "@ai-sdk/openai-compatible", name: "Managed OpenRouter",
318
+ options: { baseURL: input.seed.openrouterUrl, apiKey: input.seed.openrouterKey },
319
+ models: managedOpenRouterModels(input.seed.openrouterModels ?? []),
320
+ } } }, null, 2)}\n` },
321
+ ] : []),
267
322
  ...(input.seed.brandJson ? [{ kind: "write", path: join(dataDir, "brand.json"), content: input.seed.brandJson, mode: 0o600, owner: user }] : []),
268
- { kind: "write", path: join(layout.instancesDir, `${workspace.slug}.env`), content: instanceEnv({ workspace, dataDir, licenseKey: input.licenseKey }), mode: 0o600 },
323
+ { kind: "write", path: join(layout.instancesDir, `${workspace.slug}.env`), content: instanceEnv({ workspace, dataDir, licenseKey: input.licenseKey, portalUrl: input.seed.portalUrl }), mode: 0o600 },
269
324
  ...(input.memoryMax
270
325
  ? [
271
326
  { kind: "mkdir", path: join(layout.unitFile.replace(/openmausbot@\.service$/, ""), `openmausbot@${workspace.slug}.service.d`), mode: 0o755 },
@@ -284,11 +339,17 @@ export function createPlan(input) {
284
339
  ];
285
340
  return { steps, registry, workspace };
286
341
  }
342
+ /** Incomplete provisioning and retained homes need explicit operator recovery. */
343
+ export function assertManagedWorkspace(workspace) {
344
+ if (workspace.status !== "running" && workspace.status !== "suspended")
345
+ throw new Error(`workspace "${workspace.slug}" is ${workspace.status} and requires operator recovery`);
346
+ }
287
347
  function withStatus(registry, slug, status) {
288
348
  assertSlug(slug);
289
349
  const current = registry.workspaces[slug];
290
350
  if (!current)
291
351
  throw new Error(`no workspace "${slug}"`);
352
+ assertManagedWorkspace(current);
292
353
  const workspace = { ...current, status };
293
354
  return { registry: { ...registry, workspaces: { ...registry.workspaces, [slug]: workspace } }, workspace };
294
355
  }
@@ -325,8 +386,9 @@ export function deletePlan(input) {
325
386
  const workspace = input.registry.workspaces[input.slug];
326
387
  if (!workspace)
327
388
  throw new Error(`no workspace "${input.slug}"`);
389
+ assertManagedWorkspace(workspace);
328
390
  const { [input.slug]: _gone, ...rest } = input.registry.workspaces;
329
- const registry = { ...input.registry, workspaces: rest };
391
+ const registry = { ...input.registry, workspaces: input.keepData ? { ...rest, [input.slug]: { ...workspace, status: "retained" } } : rest };
330
392
  const unitsDir = layout.unitFile.replace(/openmausbot@\.service$/, "");
331
393
  return {
332
394
  registry,
@@ -337,8 +399,12 @@ export function deletePlan(input) {
337
399
  { kind: "remove", path: join(layout.instancesDir, `${workspace.slug}.env`) },
338
400
  { kind: "remove", path: join(unitsDir, `openmausbot@${workspace.slug}.service.d`, "limits.conf") },
339
401
  { kind: "write", path: layout.fenceFile, content: fenceRules(Object.values(registry.workspaces)), mode: 0o600 },
340
- { kind: "run", argv: ["nft", "-f", layout.fenceFile], why: "drop its fence rule" },
341
- { kind: "run", argv: input.keepData ? ["userdel", fleetUser(workspace.slug)] : ["userdel", "--remove", fleetUser(workspace.slug)], why: input.keepData ? "remove the account, keep its home and data" : "remove the account and everything it owned" },
402
+ { kind: "run", argv: ["nft", "-f", layout.fenceFile], why: input.keepData ? "keep the retained account's fence" : "drop its fence rule" },
403
+ // Retain the nologin account too: recycling its UID would give a new
404
+ // workspace ownership of the preserved home, even under a different slug.
405
+ ...(input.keepData
406
+ ? [{ kind: "note", text: "keeping the disabled workspace's account, home and name reserved; operator recovery is required before reuse" }]
407
+ : [{ kind: "run", argv: ["userdel", "--remove", fleetUser(workspace.slug)], why: "remove the account and everything it owned" }]),
342
408
  { kind: "write", path: layout.registryFile, content: `${JSON.stringify(registry, null, 2)}\n`, mode: 0o600 },
343
409
  ],
344
410
  };
@@ -368,26 +434,34 @@ export function applySignIn(configText, action, email, chatOnly) {
368
434
  if (action === "remove") {
369
435
  if (admins.length + members.length === list(current.admins).length + list(current.members).length)
370
436
  throw new Error(`${entry} is not on the list`);
371
- return { config: `${JSON.stringify({ ...raw, signIn: { admins, members } }, null, 2)}\n`, summary: `${entry} can no longer sign in; existing sessions stay until they expire or are revoked` };
437
+ if (!admins.length)
438
+ throw new Error("a workspace needs at least one admin; add another admin first");
439
+ return { config: `${JSON.stringify({ ...raw, signIn: { admins, members } }, null, 2)}\n`, summary: `${entry} was removed from the allow-list; existing email sessions are rechecked against the remaining entries` };
372
440
  }
373
441
  const next = chatOnly ? { admins, members: [...members, entry] } : { admins: [...admins, entry], members };
442
+ if (!next.admins.length)
443
+ throw new Error("a workspace needs at least one admin; add another admin first");
374
444
  return { config: `${JSON.stringify({ ...raw, signIn: next }, null, 2)}\n`, summary: `${entry} can sign in with an emailed code (${chatOnly ? "chat and approvals" : "full access"})` };
375
445
  }
376
446
  function shellQuote(value) {
377
447
  return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
378
448
  }
379
- /** The plan as lines an operator can read or paste into a root shell. */
449
+ /** Inspectable plan. Tenant I/O must use the executor's privilege drop. */
380
450
  export function describeSteps(steps) {
381
451
  const lines = [];
382
452
  for (const step of steps) {
383
453
  switch (step.kind) {
384
454
  case "mkdir":
385
- lines.push(`install -d -m ${step.mode.toString(8)}${step.owner ? ` -o ${step.owner} -g ${step.owner}` : ""} ${shellQuote(step.path)}`);
455
+ lines.push(step.owner
456
+ ? `# Create ${shellQuote(step.path)} mode ${step.mode.toString(8)} as ${step.owner} (use fleet --yes; never root chown on tenant paths)`
457
+ : `install -d -m ${step.mode.toString(8)} ${shellQuote(step.path)}`);
386
458
  break;
387
459
  case "write":
460
+ if (step.owner) {
461
+ lines.push(`# Write ${shellQuote(step.path)} mode ${step.mode.toString(8)} as ${step.owner} (use fleet --yes; tenant content omitted)`);
462
+ break;
463
+ }
388
464
  lines.push(`cat > ${shellQuote(step.path)} <<'OMB_EOF'`, step.content.replace(/\n$/, ""), "OMB_EOF", `chmod ${step.mode.toString(8)} ${shellQuote(step.path)}`);
389
- if (step.owner)
390
- lines.push(`chown ${step.owner}:${step.owner} ${shellQuote(step.path)}`);
391
465
  break;
392
466
  case "append-once":
393
467
  lines.push(`grep -qxF ${shellQuote(step.line)} ${shellQuote(step.path)} || printf '\\n%s\\n' ${shellQuote(step.line)} >> ${shellQuote(step.path)}`);
@@ -0,0 +1,16 @@
1
+ // This versions only the hosted consume/check wire contract, not the app release.
2
+ export const HOSTED_CONTRACT_VERSION = 1;
3
+ export const HOSTED_CONTRACT_HEADER = "x-omb-hosted-contract-version";
4
+ export const HOSTED_CONTRACT_METADATA = Object.freeze({
5
+ contractVersion: HOSTED_CONTRACT_VERSION,
6
+ supportedContractVersions: Object.freeze([HOSTED_CONTRACT_VERSION]),
7
+ legacyPolicy: "legacy-v1",
8
+ });
9
+ export const HOSTED_CONTRACT_MISMATCH = "HOSTED_CONTRACT_MISMATCH";
10
+ export const HOSTED_CONTRACT_ERROR = "Admin and workspace runtime versions are incompatible. Ask your administrator to update the deployment.";
11
+ /** legacy-v1 accepts an omitted version only for the existing v1 payload.
12
+ * Remove this transition at protocol v2; never retry an unsupported explicit
13
+ * version without its version field or infer compatibility from app versions. */
14
+ export function hostedContractCompatible(version) {
15
+ return version === undefined ? HOSTED_CONTRACT_VERSION === 1 : version === HOSTED_CONTRACT_VERSION;
16
+ }