openmausbot 0.1.76 → 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 (45) hide show
  1. package/dist/assets/{index-C6blGSq5.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/container-mcp.js +5 -1
  6. package/dist-server/drivers/agents-proxy.js +52 -1
  7. package/dist-server/enterprise/server/index.js +387 -29
  8. package/dist-server/index.js +1377 -459
  9. package/dist-server/openmausbot.js +725 -522
  10. package/dist-server/pair-cli.js +725 -522
  11. package/dist-server/server/agent-tool-policy.js +1 -0
  12. package/dist-server/server/browser-live.js +3 -0
  13. package/dist-server/server/config.js +16 -0
  14. package/dist-server/server/drivers/acp/core.js +3 -1
  15. package/dist-server/server/drivers/agents-proxy.js +58 -2
  16. package/dist-server/server/drivers/boxagent.js +1 -1
  17. package/dist-server/server/drivers/claude.js +3 -2
  18. package/dist-server/server/drivers/codex.js +3 -1
  19. package/dist-server/server/drivers/pi.js +4 -0
  20. package/dist-server/server/enterprise.js +38 -0
  21. package/dist-server/server/environment.js +3 -0
  22. package/dist-server/server/fleet-agent.js +57 -13
  23. package/dist-server/server/fleet-cli.js +248 -31
  24. package/dist-server/server/fleet.js +88 -14
  25. package/dist-server/server/hosted-contract.js +16 -0
  26. package/dist-server/server/index.js +671 -100
  27. package/dist-server/server/message-file.js +4 -0
  28. package/dist-server/server/provider-key-check.js +18 -1
  29. package/dist-server/server/request-auth.js +14 -5
  30. package/dist-server/server/sessions.js +115 -8
  31. package/dist-server/server/shared-computer-control.js +24 -0
  32. package/dist-server/server/shared-computers.js +131 -0
  33. package/dist-server/server/steer-queue.js +2 -0
  34. package/dist-server/server/store.js +20 -7
  35. package/dist-server/server/team-computers.js +108 -0
  36. package/dist-server/server/tool-summary.js +53 -1
  37. package/dist-server/server/workspace-backup.js +1 -0
  38. package/dist-server/shared/approval-mode.js +4 -1
  39. package/dist-server/shared/team-computer.js +1 -0
  40. package/dist-server/vps-container-mcp.js +5 -1
  41. package/enterprise/README.md +21 -3
  42. package/enterprise/server/index.js +387 -29
  43. package/package.json +1 -1
  44. package/dist/assets/index-BtT047dk.css +0 -1
  45. package/dist/assets/index-fKXFpB_J.js +0 -305
@@ -7,6 +7,7 @@
7
7
  export const READ_ONLY_AGENT_TOOL_NAMES = new Set([
8
8
  "list_bots",
9
9
  "list_team_setup",
10
+ "list_shared_computers",
10
11
  "list_rooms",
11
12
  "list_threads",
12
13
  "check_delegation",
@@ -564,6 +564,9 @@ export class BrowserLive {
564
564
  closeForSession(session) { for (const viewer of this.viewers.values())
565
565
  if (viewer.session === session)
566
566
  this.close(viewer); }
567
+ closeForOwner(owner) { for (const viewer of this.viewers.values())
568
+ if (viewer.owner === owner)
569
+ this.close(viewer); }
567
570
  closeForBot(botId) { for (const viewer of this.viewers.values())
568
571
  if (viewer.botId === botId)
569
572
  this.close(viewer); }
@@ -208,6 +208,10 @@ const featureConfigSchema = z.object({
208
208
  /** Experimental built-in browser. Off until explicitly enabled; each bot
209
209
  * also has its own switch. */
210
210
  browser: z.boolean().optional(),
211
+ /** Opt-in computer sharing (a desktop lending folders, a terminal or
212
+ * computer control to a workspace). Off until explicitly enabled; there is
213
+ * no Settings toggle — see sharedComputersEnabled. */
214
+ sharedComputers: z.boolean().optional(),
211
215
  });
212
216
  /** First-run progress. Kept in the workspace config rather than a browser so
213
217
  * it survives cleared site data and is shared by every paired client. Hint
@@ -435,6 +439,18 @@ export function showToolCallsEnabled(cfg) {
435
439
  export function builtInBrowserEnabled(cfg) {
436
440
  return cfg.features?.browser === true;
437
441
  }
442
+ /** Opt-in computer sharing: the routes, the agent tools, the advertised
443
+ * capability and the desktop connector. Off unless an explicit `true` turns
444
+ * it on, because the reviewed feature still has open security holes (a
445
+ * read-only folder grant could be escalated to a shell).
446
+ *
447
+ * Deliberately NOT a Settings toggle: this is a maintainer-only escape hatch
448
+ * for an unfinished feature, not a user preference. Someone who needs it
449
+ * enables it by hand in `~/.openmausbot/config.json`
450
+ * (`{"features": {"sharedComputers": true}}`) and restarts the server. */
451
+ export function sharedComputersEnabled(cfg) {
452
+ return cfg.features?.sharedComputers === true;
453
+ }
438
454
  /** Config sections no provider driver reads. A write that touches only
439
455
  * these must not rebuild the fleet: rebuilding disposes every engine child
440
456
  * and reloads it, seconds of work that would also interrupt in-flight
@@ -35,7 +35,7 @@ import { supportsApprovalMode } from "../../../shared/approval-mode.js";
35
35
  // packaged server dir entirely. See server/proxy-paths.ts.
36
36
  const COMPUTER_PROXY_PATH = SPAWNED_PROXIES.computer;
37
37
  import { appendNative } from "../native.js";
38
- import { commandSummary } from "../../tool-summary.js";
38
+ import { commandSummary, toolDetailPreview } from "../../tool-summary.js";
39
39
  import { SPAWNED_PROXIES } from "../../proxy-paths.js";
40
40
  const envOr = (key, fallback) => Number(process.env[key] ?? fallback);
41
41
  const INIT_TIMEOUT = envOr("OPENMAUS_ACP_INIT_TIMEOUT_MS", 300_000);
@@ -620,6 +620,7 @@ export function createAcpDriver(support) {
620
620
  itemId: u.toolCallId,
621
621
  title: String(u.rawInput?.command ?? u.title ?? "tool").slice(0, 80),
622
622
  summary: commandSummary(u.rawInput),
623
+ input: toolDetailPreview(u.rawInput),
623
624
  });
624
625
  break;
625
626
  }
@@ -631,6 +632,7 @@ export function createAcpDriver(support) {
631
632
  itemType: "tool",
632
633
  itemId: u.toolCallId,
633
634
  ok: u.status !== "failed",
635
+ output: toolDetailPreview(u.rawOutput ?? u.content),
634
636
  });
635
637
  }
636
638
  break;
@@ -43,6 +43,9 @@ const THREAD_ID = process.env.OMB_THREAD_ID ?? "";
43
43
  const TOKEN = process.env.OMB_COMMS_TOKEN ?? "";
44
44
  const DEPTH = Number(process.env.OMB_TURN_DEPTH ?? "0") || 0;
45
45
  const SKILL_AUTHORING_ENABLED = process.env.OMB_SKILL_AUTHORING_ENABLED === "1";
46
+ // Opt-in computer sharing (server features.sharedComputers). Off unless the
47
+ // harness says "1", the same way skill authoring is gated above.
48
+ const SHARED_COMPUTERS_ENABLED = process.env.OMB_SHARED_COMPUTERS_ENABLED === "1";
46
49
  const MAX_CREATED_PER_TURN = 4;
47
50
  let createdThisTurn = 0;
48
51
  // Same spirit as MAX_CREATED_PER_TURN above and MAX_QUEUED_PER_THREAD in
@@ -343,6 +346,20 @@ const ROUTINE_FIELDS_SCHEMA = {
343
346
  },
344
347
  };
345
348
  const TOOLS = [
349
+ {
350
+ name: "list_shared_computers",
351
+ description: "List online desktop computers explicitly shared with this workspace, and their allowed folders/capabilities. These are the user's computers, not this server. An offline or unshared computer cannot be accessed. Folder paths use opaque folder IDs and relative paths.",
352
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
353
+ },
354
+ {
355
+ name: "shared_computer",
356
+ description: "Use a desktop explicitly shared by the user. Discover computer_id and folder_id with list_shared_computers. list_files/read_file/write_file are confined to chosen folders; paths are relative. read_file returns sha256; overwriting requires expected_sha256. Binary files support base64 encoding. run_command requires a SEPARATE unrestricted terminal grant. computer_tools lists the native computer-control tools; computer_call invokes one with arguments and needs a SEPARATE computer-control grant. Never substitute the server's filesystem when this desktop is offline. Actions are not retried automatically; inspect an uncertain outcome before retrying.",
357
+ inputSchema: { type: "object", additionalProperties: false, properties: {
358
+ computer_id: { type: "string" }, action: { type: "string", enum: ["list_files", "read_file", "write_file", "run_command", "computer_tools", "computer_call"] },
359
+ folder_id: { type: "string" }, path: { type: "string" }, content: { type: "string" }, encoding: { type: "string", enum: ["utf8", "base64"] }, expected_sha256: { type: "string" },
360
+ command: { type: "string" }, tool_name: { type: "string" }, arguments: { type: "object", additionalProperties: true },
361
+ }, required: ["computer_id", "action"] },
362
+ },
346
363
  {
347
364
  name: "list_room_targets",
348
365
  description: "Discover actual OpenMausBot teammates and rooms in your allowed teams. Works in a normal bot conversation too; no room is required. Returns bot and room IDs, roles and working folders, never other conversations' history. Use these bots, not native coding helpers with similar names, when the user asks their team to work together.",
@@ -753,15 +770,30 @@ const SKILL_TOOL_NAMES = new Set(["skills_list", "skill_manage"]);
753
770
  const AUTHORING_TOOLS = SKILL_AUTHORING_ENABLED
754
771
  ? TOOLS
755
772
  : TOOLS.filter((tool) => !SKILL_TOOL_NAMES.has(tool.name));
773
+ // A workspace with computer sharing off refuses the routes behind these two,
774
+ // so they must not be advertised at all: a model that sees a tool it cannot
775
+ // use spends turns discovering that.
776
+ const SHARED_COMPUTER_TOOL_NAMES = new Set(["list_shared_computers", "shared_computer"]);
777
+ const SHAREABLE_TOOLS = SHARED_COMPUTERS_ENABLED
778
+ ? AUTHORING_TOOLS
779
+ : AUTHORING_TOOLS.filter((tool) => !SHARED_COMPUTER_TOOL_NAMES.has(tool.name));
756
780
  // One teamwork path in room turns; keep all unrelated integrations available.
757
781
  // Ordinary direct chats use this same bounded coordinator. Goal-owned turns
758
782
  // retain their independent loop and cannot start a second coordinator.
759
783
  const ROOM_ONLY_TOOLS = new Set(["list_room_targets", "coordinate_bots"]);
760
784
  const ROOM_REPLACED_TOOLS = new Set(["ask_bot", "delegate_bot", "check_delegation", "wait_delegation", "start_thread", "send_to_thread", "wait_thread"]);
761
785
  const COORDINATING = process.env.OMB_ROOM_TURN === "1";
786
+ const OWN_THREAD_CREATION = process.env.OMB_OWN_THREAD_CREATION === "1";
762
787
  const AVAILABLE_TOOLS = COORDINATING
763
- ? AUTHORING_TOOLS.filter(tool => !ROOM_REPLACED_TOOLS.has(tool.name))
764
- : AUTHORING_TOOLS.filter(tool => !ROOM_ONLY_TOOLS.has(tool.name));
788
+ ? SHAREABLE_TOOLS.filter(tool => !ROOM_REPLACED_TOOLS.has(tool.name) || (tool.name === "start_thread" && OWN_THREAD_CREATION))
789
+ .map(tool => tool.name === "start_thread" ? {
790
+ ...tool,
791
+ description: "Open a separate job on yourself with its own history and run, without switching the person's selected conversation. Use only when the user requests independent jobs (for example one review per pull request). Give a short specific title and complete instructions; you can open at most five per turn. This is not a teammate handoff: use coordinate_bots for teammates and their automatic replies. Self-opened jobs cannot recursively open more jobs. If refused, do not retry; explain what remains.",
792
+ inputSchema: { ...tool.inputSchema, properties: { ...tool.inputSchema.properties,
793
+ bot_id: { type: "string", enum: [BOT_ID], description: "Leave out, or use your own bot ID. For teammates use coordinate_bots." },
794
+ } },
795
+ } : tool)
796
+ : SHAREABLE_TOOLS.filter(tool => !ROOM_ONLY_TOOLS.has(tool.name));
765
797
  const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
766
798
  const ok = (id, result) => send({ jsonrpc: "2.0", id, result });
767
799
  const rpcErr = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
@@ -1089,6 +1121,9 @@ async function callTool(name, args) {
1089
1121
  };
1090
1122
  }
1091
1123
  const toBotId = typeof args.bot_id === "string" ? args.bot_id.trim() : "";
1124
+ if (COORDINATING && toBotId && toBotId !== BOT_ID) {
1125
+ return { text: "Use coordinate_bots for teammates; start_thread only opens a separate job on yourself.", isError: true };
1126
+ }
1092
1127
  const folder = typeof args.folder === "string" ? args.folder.trim() : "";
1093
1128
  const body = { fromBotId: BOT_ID, fromThreadId: THREAD_ID, title, message, depth: DEPTH };
1094
1129
  if (toBotId)
@@ -1550,6 +1585,27 @@ async function handle(msg) {
1550
1585
  if (!AVAILABLE_TOOLS.some((t) => t.name === name))
1551
1586
  return rpcErr(id, -32602, `Unknown tool: ${name}`);
1552
1587
  try {
1588
+ // Second lock. With sharing off the tool is not in AVAILABLE_TOOLS, so
1589
+ // a call is already refused above as an unknown tool — the same answer
1590
+ // a build without the feature gives. This keeps the handler itself
1591
+ // refusing if that list is ever assembled differently.
1592
+ if (SHARED_COMPUTER_TOOL_NAMES.has(name) && !SHARED_COMPUTERS_ENABLED) {
1593
+ textResult(id, "Computer sharing is turned off in this workspace. There are no shared computers to use.", true);
1594
+ return;
1595
+ }
1596
+ if (name === "list_shared_computers") {
1597
+ textResult(id, JSON.stringify(await api("/api/internal/shared-computers")));
1598
+ return;
1599
+ }
1600
+ if (name === "shared_computer") {
1601
+ const response = await api("/api/internal/shared-computers", { method: "POST", body: JSON.stringify(params.arguments ?? {}) });
1602
+ const result = response.result;
1603
+ if (Array.isArray(result?.content))
1604
+ ok(id, result);
1605
+ else
1606
+ textResult(id, JSON.stringify(result));
1607
+ return;
1608
+ }
1553
1609
  const { text, isError } = await callTool(name, (params.arguments ?? {}));
1554
1610
  textResult(id, text, isError);
1555
1611
  }
@@ -64,7 +64,7 @@ export const BoxAgentDriver = {
64
64
  const model = turn.model || MODELS.default;
65
65
  const prompt = [
66
66
  turn.system,
67
- "You are working on your own cloud computer — its desktop, Chrome, and shell are yours.",
67
+ "You are working on the assigned cloud computer — use its desktop, Chrome, and shell within the access described above.",
68
68
  "",
69
69
  turn.text,
70
70
  ]
@@ -21,7 +21,7 @@ import { ClaudeLoginController } from "./claude-login-auth.js";
21
21
  import { computerProxyEnv } from "../container-computer.js";
22
22
  import { gateServer, resultBudget } from "../mcp-gate-config.js";
23
23
  import { newEventId, newId } from "../contracts.js";
24
- import { askInputSummary, commandSummary } from "../tool-summary.js";
24
+ import { askInputSummary, commandSummary, toolDetailPreview } from "../tool-summary.js";
25
25
  import { classifyError, computeBackoff, interruptibleDelay, RETRY_MAX_ATTEMPTS } from "./retry.js";
26
26
  import { applyClaudeInject, decodeInjectId, mergeLocalInject, probeLocalInjects, resolveInjectId, } from "./local-inject.js";
27
27
  import { appendNative } from "./native.js";
@@ -1396,6 +1396,7 @@ export const ClaudeDriver = {
1396
1396
  itemId: b.id,
1397
1397
  title: b.name,
1398
1398
  summary: commandSummary(b.input),
1399
+ input: toolDetailPreview(b.input),
1399
1400
  });
1400
1401
  }
1401
1402
  }
@@ -1415,7 +1416,7 @@ export const ClaudeDriver = {
1415
1416
  case "user":
1416
1417
  for (const b of Array.isArray(o.message?.content) ? o.message.content : []) {
1417
1418
  if (b.type === "tool_result") {
1418
- emit({ ...base(threadId, currentTurnId()), type: "item.completed", itemType: "tool", itemId: b.tool_use_id, ok: !b.is_error });
1419
+ emit({ ...base(threadId, currentTurnId()), type: "item.completed", itemType: "tool", itemId: b.tool_use_id, ok: !b.is_error, output: toolDetailPreview(b.content) });
1419
1420
  }
1420
1421
  }
1421
1422
  break;
@@ -22,7 +22,7 @@ import { codexLocalProviderArgs } from "./local-inject.js";
22
22
  import { augmentedPath, splitCliString } from "../env-path.js";
23
23
  import { classifyError, computeBackoff, RETRY_MAX_ATTEMPTS } from "./retry.js";
24
24
  import { appendNative } from "./native.js";
25
- import { commandSummary } from "../tool-summary.js";
25
+ import { commandSummary, toolDetailPreview } from "../tool-summary.js";
26
26
  import { codexDeveloperInstructions, syncCodexInstructions } from "./codex-instructions.js";
27
27
  import { CodexDeviceAuthController } from "./codex-device-auth.js";
28
28
  import { codexAccountEmail } from "./codex-identity.js";
@@ -799,6 +799,7 @@ export const CodexDriver = {
799
799
  itemId: item.id,
800
800
  title,
801
801
  summary: item.type === "commandExecution" ? commandSummary({ command: item.command }) : undefined,
802
+ input: toolDetailPreview(item.type === "commandExecution" ? { command: item.command, cwd: item.cwd } : item.type === "mcpToolCall" ? item.arguments : item.type === "fileChange" ? item.changes : item.query),
802
803
  });
803
804
  }
804
805
  break;
@@ -839,6 +840,7 @@ export const CodexDriver = {
839
840
  itemType: "tool",
840
841
  itemId: item.id,
841
842
  ok: item.status !== "failed" && item.status !== "declined",
843
+ output: toolDetailPreview(item.type === "commandExecution" ? { output: item.aggregatedOutput, exitCode: item.exitCode } : item.type === "mcpToolCall" ? item.error ?? item.result : item.type === "fileChange" ? item.changes : item.action),
842
844
  });
843
845
  }
844
846
  else if (item.type === "reasoning") {
@@ -28,6 +28,7 @@ import { computerProxyEnv } from "../container-computer.js";
28
28
  import { augmentedPath } from "../env-path.js";
29
29
  import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../procs.js";
30
30
  import { SPAWNED_PROXIES } from "../proxy-paths.js";
31
+ import { commandSummary, toolDetailPreview } from "../tool-summary.js";
31
32
  import { EFFORT_LEVELS, newEventId, newId } from "../contracts.js";
32
33
  import { decodeInjectId, encodeInjectId, hostApiKey, localHost, mergeLocalInject, } from "./local-inject.js";
33
34
  import { appendNative } from "./native.js";
@@ -610,6 +611,8 @@ export const PiDriver = {
610
611
  itemType: "tool",
611
612
  itemId: evt.toolCallId,
612
613
  title: String(evt.toolName ?? "tool").slice(0, 80),
614
+ summary: commandSummary(evt.args),
615
+ input: toolDetailPreview(evt.args),
613
616
  });
614
617
  return;
615
618
  }
@@ -620,6 +623,7 @@ export const PiDriver = {
620
623
  itemType: "tool",
621
624
  itemId: evt.toolCallId,
622
625
  ok: !evt.isError,
626
+ output: toolDetailPreview(evt.result),
623
627
  });
624
628
  return;
625
629
  }
@@ -27,6 +27,40 @@ const layerSchema = z.object({
27
27
  });
28
28
  const DEFAULT_DIR = join(SERVER_ROOT, "..", "enterprise");
29
29
  let current = { edition: "oss", features: [] };
30
+ let workspaceAccessFactory;
31
+ export function hostedWorkspaceConfigured(env = process.env) {
32
+ return env.OMB_ADMIN_URL !== undefined || env.OMB_ADMIN_WORKSPACE !== undefined || env.OMB_ADMIN_MEMBERSHIP !== undefined;
33
+ }
34
+ /** Validate the operator's complete hosted configuration before any session
35
+ * can delegate membership authority. A partial setting is never standalone. */
36
+ export function hostedWorkspaceConfiguration(env = process.env) {
37
+ try {
38
+ const origin = (raw) => {
39
+ if (!raw || raw !== raw.trim())
40
+ throw new Error("missing origin");
41
+ const url = new URL(raw);
42
+ if (url.protocol !== "https:" || (raw !== url.origin && raw !== `${url.origin}/`))
43
+ throw new Error("invalid origin");
44
+ return url;
45
+ };
46
+ if (!/^[a-z][a-z0-9-]{1,30}$/.test(env.OMB_ADMIN_WORKSPACE ?? ""))
47
+ return null;
48
+ if (env.OMB_ADMIN_MEMBERSHIP !== undefined && !["local", "portal"].includes(env.OMB_ADMIN_MEMBERSHIP))
49
+ return null;
50
+ return { admin: origin(env.OMB_ADMIN_URL), tenant: origin(env.OMB_PUBLIC_URL), workspace: env.OMB_ADMIN_WORKSPACE, portalMembership: env.OMB_ADMIN_MEMBERSHIP === "portal" };
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ /** No enterprise import or portal dependency enters the core bundle. A
57
+ * configured server without this hook must refuse hosted access, not fall
58
+ * back to legacy email or QR credentials. */
59
+ export function createWorkspaceAccess(options) {
60
+ if (!hostedWorkspaceConfigured(options.env))
61
+ return null;
62
+ return workspaceAccessFactory?.({ ...options, entitled: () => entitled("admin") }) ?? null;
63
+ }
30
64
  function oss(notice) {
31
65
  current = notice ? { edition: "oss", features: [], notice } : { edition: "oss", features: [] };
32
66
  return current;
@@ -34,6 +68,7 @@ function oss(notice) {
34
68
  /** Resolve the edition once at startup. Never throws: a broken layer or key
35
69
  * degrades to the open-source edition with a notice that says what to fix. */
36
70
  export async function loadEnterpriseLayer(options = {}) {
71
+ workspaceAccessFactory = undefined;
37
72
  const dir = options.dir ?? process.env.OMB_ENTERPRISE_DIR ?? DEFAULT_DIR;
38
73
  const licenseKey = options.licenseKey ?? process.env.OMB_LICENSE_KEY;
39
74
  // Source in a checkout, a compiled bundle in an image: same convention as proxy-paths.ts.
@@ -43,6 +78,9 @@ export async function loadEnterpriseLayer(options = {}) {
43
78
  }
44
79
  try {
45
80
  const loaded = await import(__rewriteRelativeImportExtension(pathToFileURL(entry).href));
81
+ const access = Reflect.get(Object(loaded), "createWorkspaceAccess");
82
+ if (typeof access === "function")
83
+ workspaceAccessFactory = access;
46
84
  const register = Reflect.get(Object(loaded), "register");
47
85
  if (typeof register !== "function")
48
86
  throw new Error(`${entry} does not export register()`);
@@ -99,6 +99,9 @@ export function environmentDescriptor(input) {
99
99
  version: serverVersion(),
100
100
  capabilities: {
101
101
  remoteSessions: true,
102
+ // Never advertise a protocol this server would refuse: the routes are
103
+ // gone unless features.sharedComputers is on, so the capability is too.
104
+ ...(input.sharedComputers === true ? { sharedComputers: true } : {}),
102
105
  selfUpdate: input.desktopManaged ? "desktop-managed" : "operator",
103
106
  emailSignIn: input.emailSignIn === true,
104
107
  },
@@ -6,9 +6,8 @@
6
6
  import { appendFileSync, chmodSync, existsSync, mkdirSync, rmSync } from "node:fs";
7
7
  import { createServer } from "node:http";
8
8
  import { dirname } from "node:path";
9
- import { defaultFleetDeps, executePlan, loadRegistry, planFleetAction } from "./fleet-cli.js";
10
- import { assertSlug, fleetLayout, workspaceDataDir } from "./fleet.js";
11
- import { readUsage, summarizeUsage } from "./usage-ledger.js";
9
+ import { defaultFleetDeps, executePlan, loadRegistry, markProvisioningFailed, planFleetAction } from "./fleet-cli.js";
10
+ import { assertSlug, fleetLayout, fleetUser, openRouterModelIds, workspaceDataDir } from "./fleet.js";
12
11
  const MAX_BODY = 256 * 1024;
13
12
  function readBody(req) {
14
13
  return new Promise((resolve, reject) => {
@@ -35,11 +34,23 @@ function readBody(req) {
35
34
  const strings = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : []);
36
35
  const text = (value) => (typeof value === "string" && value.trim() ? value.trim() : undefined);
37
36
  const number = (value) => (typeof value === "number" && Number.isFinite(value) ? value : undefined);
38
- /** This month's totals from a workspace's own ledger, read as root. */
39
- export function workspaceUsage(layout, slug, now) {
40
- const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
41
- const total = summarizeUsage(readUsage(workspaceDataDir(layout, slug), { from, to: now }), "bot").total;
42
- return { month: now.toISOString().slice(0, 7), turns: total.turns, costUsd: total.costUsd, billableUsd: total.billableUsd };
37
+ const models = (value) => {
38
+ try {
39
+ return openRouterModelIds(value);
40
+ }
41
+ catch (error) {
42
+ throw Object.assign(error, { status: 400 });
43
+ }
44
+ };
45
+ /** Only bounded, unprivileged aggregates cross back into the root process. */
46
+ export function workspaceUsage(layout, slug, now, deps) {
47
+ const month = now.toISOString().slice(0, 7);
48
+ try {
49
+ return { month, ...deps.usage(workspaceDataDir(layout, slug), fleetUser(slug), now) };
50
+ }
51
+ catch {
52
+ return { month, turns: null, costUsd: null, billableUsd: null, unavailable: true };
53
+ }
43
54
  }
44
55
  export function createFleetAgent(options) {
45
56
  const deps = options.deps ?? defaultFleetDeps();
@@ -59,7 +70,7 @@ export function createFleetAgent(options) {
59
70
  /* the audit line must never fail the action it records */
60
71
  }
61
72
  };
62
- const act = async (input) => {
73
+ const runAction = async (input) => {
63
74
  const log = [];
64
75
  const errors = [];
65
76
  const io = { log: (line) => log.push(line), error: (line) => errors.push(line) };
@@ -71,10 +82,32 @@ export function createFleetAgent(options) {
71
82
  audit({ action: input.action, slug: input.slug, ok: false, error: error instanceof Error ? error.message : String(error) });
72
83
  return { status: 400, body: { error: error instanceof Error ? error.message : String(error) } };
73
84
  }
74
- const code = await executePlan(steps, deps, io);
85
+ let code = 1;
86
+ try {
87
+ code = await executePlan(steps, deps, io);
88
+ }
89
+ catch (error) {
90
+ errors.push(error instanceof Error ? error.message : String(error));
91
+ }
92
+ if (code !== 0) {
93
+ try {
94
+ markProvisioningFailed(input, deps);
95
+ }
96
+ catch {
97
+ errors.push("could not record the failed provisioning; inspect the reserved workspace before recovery");
98
+ }
99
+ }
75
100
  audit({ action: input.action, slug: input.slug, ok: code === 0, ...(input.email ? { email: input.email } : {}) });
76
101
  return code === 0 ? { status: 200, body: { ok: true, log } } : { status: 500, body: { error: errors[0] ?? "the step failed", log: [...log, ...errors] } };
77
102
  };
103
+ // Planning reads the registry too: serialize the entire read/modify/execute
104
+ // sequence, not just the subprocesses, so ports and updates cannot collide.
105
+ let mutationTail = Promise.resolve();
106
+ const act = (input) => {
107
+ const result = mutationTail.then(() => runAction(input));
108
+ mutationTail = result.catch(() => { });
109
+ return result;
110
+ };
78
111
  const handle = async (req, res) => {
79
112
  const url = new URL(req.url ?? "/", "http://fleet");
80
113
  const method = req.method ?? "GET";
@@ -87,10 +120,13 @@ export function createFleetAgent(options) {
87
120
  return send(200, { ok: true });
88
121
  if (method === "GET" && url.pathname === "/workspaces") {
89
122
  const registry = loadRegistry(layout, deps);
123
+ const statusOnly = url.searchParams.get("statusOnly") === "true";
90
124
  const workspaces = [];
91
125
  for (const workspace of Object.values(registry.workspaces).sort((a, b) => a.slug.localeCompare(b.slug))) {
92
- const live = (await deps.run(["systemctl", "is-active", `openmausbot@${workspace.slug}.service`])).output.trim() || "unknown";
93
- workspaces.push({ ...workspace, live, usage: workspaceUsage(layout, workspace.slug, now()) });
126
+ const live = workspace.status === "running" || workspace.status === "suspended"
127
+ ? (await deps.run(["systemctl", "is-active", `openmausbot@${workspace.slug}.service`])).output.trim() || "unknown"
128
+ : workspace.status;
129
+ workspaces.push(statusOnly ? { slug: workspace.slug, live } : { ...workspace, live, usage: workspaceUsage(layout, workspace.slug, now(), deps) });
94
130
  }
95
131
  return send(200, { domain: registry.domain, operator: registry.operator ?? null, workspaces });
96
132
  }
@@ -99,6 +135,9 @@ export function createFleetAgent(options) {
99
135
  const result = await act({
100
136
  ...base(), action: "create", slug: text(body.slug), admins: strings(body.admins), members: strings(body.members),
101
137
  cap: number(body.cap), memory: text(body.memory), brandJson: text(body.brandJson), anthropicKey: text(body.anthropicKey),
138
+ portalUrl: text(body.portalUrl), anthropicUrl: text(body.anthropicUrl),
139
+ openrouterKey: text(body.openrouterKey), openrouterUrl: text(body.openrouterUrl),
140
+ openrouterModels: body.openrouterModels === undefined ? undefined : models(body.openrouterModels), openrouterDefault: body.openrouterDefault === true,
102
141
  });
103
142
  return send(result.status, result.body);
104
143
  }
@@ -106,11 +145,16 @@ export function createFleetAgent(options) {
106
145
  const result = await act({ ...base(), action: "upgrade" });
107
146
  return send(result.status, result.body);
108
147
  }
109
- const match = /^\/workspaces\/([a-z0-9-]+)(?:\/(users|suspend|resume))?$/.exec(url.pathname);
148
+ const match = /^\/workspaces\/([a-z0-9-]+)(?:\/(users|providers|suspend|resume))?$/.exec(url.pathname);
110
149
  if (match) {
111
150
  const slug = match[1];
112
151
  assertSlug(slug);
113
152
  const sub = match[2];
153
+ if (method === "POST" && sub === "providers") {
154
+ const body = await readBody(req);
155
+ const result = await act({ ...base(), action: "providers", slug, openrouterModels: models(body.models) });
156
+ return send(result.status, result.body);
157
+ }
114
158
  if (method === "POST" && sub === "users") {
115
159
  const body = await readBody(req);
116
160
  const action = body.action === "remove" ? "remove" : body.action === "add" ? "add" : undefined;