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
@@ -22,81 +22,104 @@ export function createOpenAIChatRuntime(options) {
22
22
  createdAt: new Date().toISOString(),
23
23
  });
24
24
  const complete = async (messages, model, stream, signal, onDelta) => {
25
- const timeout = AbortSignal.timeout(options.timeoutMs);
26
- const response = await fetch(`${options.apiUrl}/chat/completions`, {
27
- method: "POST",
28
- headers: { authorization: `Bearer ${options.apiKey}`, "content-type": "application/json" },
29
- body: JSON.stringify(options.requestBody(model, messages, stream)),
30
- signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
31
- });
32
- if (!response.ok) {
33
- const body = await response.text().catch(() => "");
34
- throw new Error(`${options.httpErrorLabel} HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`);
35
- }
36
- if (!stream) {
37
- const json = await response.json();
38
- const message = json.choices?.[0]?.message;
39
- return {
40
- text: typeof message?.content === "string" ? message.content : "",
41
- reasoning: options.reasoning && typeof message?.reasoning_content === "string"
42
- ? message.reasoning_content
43
- : "",
44
- usage: usageFrom(json.usage),
45
- };
46
- }
47
- if (!response.body) {
48
- throw new Error(options.noBodyError ?? `${options.httpErrorLabel} returned no response body`);
49
- }
50
- let text = "";
51
- let reasoning = "";
52
- let usage = null;
53
- const reader = response.body.getReader();
54
- const decoder = new TextDecoder();
55
- let buffer = "";
25
+ // Idle timer that is renewed on every received chunk during streaming
26
+ const timeoutController = new AbortController();
27
+ let idleTimer = null;
28
+ const resetIdleTimer = () => {
29
+ if (idleTimer)
30
+ clearTimeout(idleTimer);
31
+ idleTimer = setTimeout(() => {
32
+ timeoutController.abort(new DOMException("Streaming idle timeout elapsed", "AbortError"));
33
+ }, options.timeoutMs);
34
+ };
35
+ resetIdleTimer();
56
36
  try {
57
- readLoop: for (;;) {
58
- const { done, value } = await reader.read();
59
- if (done)
60
- break;
61
- buffer += decoder.decode(value, { stream: true });
62
- let newline;
63
- while ((newline = buffer.indexOf("\n")) !== -1) {
64
- const line = buffer.slice(0, newline).trim();
65
- buffer = buffer.slice(newline + 1);
66
- if (!line.startsWith("data:"))
67
- continue;
68
- const data = line.slice(5).trim();
69
- if (data === "[DONE]")
70
- break readLoop;
71
- let chunk;
72
- try {
73
- chunk = JSON.parse(data);
74
- }
75
- catch {
76
- continue;
77
- }
78
- const delta = chunk.choices?.[0]?.delta;
79
- const reasoningDelta = options.reasoning && typeof delta?.reasoning_content === "string"
80
- ? delta.reasoning_content
81
- : "";
82
- const contentDelta = typeof delta?.content === "string" ? delta.content : "";
83
- if (reasoningDelta) {
84
- reasoning += reasoningDelta;
85
- onDelta?.(reasoningDelta, "reasoning_text");
37
+ const activeSignal = signal
38
+ ? AbortSignal.any([signal, timeoutController.signal])
39
+ : timeoutController.signal;
40
+ const response = await fetch(`${options.apiUrl}/chat/completions`, {
41
+ method: "POST",
42
+ headers: { authorization: `Bearer ${options.apiKey}`, "content-type": "application/json" },
43
+ body: JSON.stringify(options.requestBody(model, messages, stream)),
44
+ signal: activeSignal,
45
+ });
46
+ if (!response.ok) {
47
+ const body = await response.text().catch(() => "");
48
+ throw new Error(`${options.httpErrorLabel} HTTP ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`);
49
+ }
50
+ if (!stream) {
51
+ const json = await response.json();
52
+ const message = json.choices?.[0]?.message;
53
+ return {
54
+ text: typeof message?.content === "string" ? message.content : "",
55
+ reasoning: options.reasoning && typeof message?.reasoning_content === "string"
56
+ ? message.reasoning_content
57
+ : "",
58
+ usage: usageFrom(json.usage),
59
+ };
60
+ }
61
+ if (!response.body) {
62
+ throw new Error(options.noBodyError ?? `${options.httpErrorLabel} returned no response body`);
63
+ }
64
+ let text = "";
65
+ let reasoning = "";
66
+ let usage = null;
67
+ const reader = response.body.getReader();
68
+ const decoder = new TextDecoder();
69
+ let buffer = "";
70
+ try {
71
+ readLoop: for (;;) {
72
+ const { done, value } = await reader.read();
73
+ if (done) {
74
+ if (buffer.trim() === "data: [DONE]")
75
+ break;
76
+ throw new DOMException("Stream ended before completion", "AbortError");
86
77
  }
87
- if (contentDelta) {
88
- text += contentDelta;
89
- onDelta?.(contentDelta, "assistant_text");
78
+ resetIdleTimer();
79
+ buffer += decoder.decode(value, { stream: true });
80
+ let newline;
81
+ while ((newline = buffer.indexOf("\n")) !== -1) {
82
+ const line = buffer.slice(0, newline).trim();
83
+ buffer = buffer.slice(newline + 1);
84
+ if (!line.startsWith("data:"))
85
+ continue;
86
+ const data = line.slice(5).trim();
87
+ if (data === "[DONE]")
88
+ break readLoop;
89
+ let chunk;
90
+ try {
91
+ chunk = JSON.parse(data);
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ const delta = chunk.choices?.[0]?.delta;
97
+ const reasoningDelta = options.reasoning && typeof delta?.reasoning_content === "string"
98
+ ? delta.reasoning_content
99
+ : "";
100
+ const contentDelta = typeof delta?.content === "string" ? delta.content : "";
101
+ if (reasoningDelta) {
102
+ reasoning += reasoningDelta;
103
+ onDelta?.(reasoningDelta, "reasoning_text");
104
+ }
105
+ if (contentDelta) {
106
+ text += contentDelta;
107
+ onDelta?.(contentDelta, "assistant_text");
108
+ }
109
+ if (chunk.usage)
110
+ usage = usageFrom(chunk.usage);
90
111
  }
91
- if (chunk.usage)
92
- usage = usageFrom(chunk.usage);
93
112
  }
94
113
  }
114
+ finally {
115
+ await reader.cancel().catch(() => { });
116
+ }
117
+ return { text, reasoning, usage };
95
118
  }
96
119
  finally {
97
- await reader.cancel().catch(() => { });
120
+ if (idleTimer)
121
+ clearTimeout(idleTimer);
98
122
  }
99
- return { text, reasoning, usage };
100
123
  };
101
124
  const messagesFor = (turn) => [
102
125
  ...(turn.system ? [{ role: "system", content: turn.system }] : []),
@@ -1,5 +1,13 @@
1
1
  import { createOpenAIChatRuntime } from "./openai-chat.js";
2
2
  const DRIVER_KIND = "openai-compat";
3
+ const DEFAULT_IDLE_TIMEOUT_MS = 180_000;
4
+ const idleTimeoutMs = () => {
5
+ const raw = process.env.OPENMAUS_OPENAI_COMPAT_IDLE_TIMEOUT_MS;
6
+ if (!raw)
7
+ return DEFAULT_IDLE_TIMEOUT_MS;
8
+ const value = Number(raw);
9
+ return Number.isSafeInteger(value) && value >= 1_000 && value <= 2_147_483_647 ? value : DEFAULT_IDLE_TIMEOUT_MS;
10
+ };
3
11
  const DEFAULT_MODELS = {
4
12
  default: "meta-llama/llama-3.3-70b-instruct",
5
13
  options: [
@@ -127,7 +135,7 @@ export const OpenAICompatDriver = {
127
135
  httpErrorLabel: "upstream",
128
136
  missingKeyError: `no API key — set ${config.apiKeyEnv} or add it to the instance config`,
129
137
  unavailableReason: `no API key — set ${config.apiKeyEnv} or add it to the instance config`,
130
- timeoutMs: 120_000,
138
+ timeoutMs: idleTimeoutMs(),
131
139
  reasoning: true,
132
140
  billing: "metered",
133
141
  includeUsageInCompleted: true,
@@ -26,13 +26,15 @@ import { join } from "node:path";
26
26
  import { PROVIDER_CREDENTIAL_ENV, stripWorkspaceCredentialEnv } from "../config.js";
27
27
  import { computerProxyEnv } from "../container-computer.js";
28
28
  import { augmentedPath } from "../env-path.js";
29
- import { describeSpawnFailure, killCliTree, spawnCli } from "../procs.js";
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";
34
35
  const DRIVER_KIND = "piAgent";
35
36
  const PI_ARGS = ["--mode", "rpc", "--no-session"];
37
+ const PI_MODEL_UPDATE_ARGS = ["update", "--models", "--no-approve"];
36
38
  const NODE_ENV_FLAG = { ELECTRON_RUN_AS_NODE: "1" };
37
39
  function readPiPromptImages(turn) {
38
40
  return (turn.images ?? []).map((image) => ({
@@ -317,6 +319,14 @@ export async function fetchPiModels(cli, env) {
317
319
  }
318
320
  });
319
321
  }
322
+ /** Refresh pi's provider-owned catalog cache. This is deliberately called
323
+ * only by the explicit model-picker refresh action, never during app startup.
324
+ * Failure is non-fatal: the caller still probes the last usable cache. */
325
+ export async function updatePiModelCatalog(cli, env) {
326
+ return new Promise((resolve) => {
327
+ execCli(cli, PI_MODEL_UPDATE_ARGS, { env, timeout: 60_000, maxBuffer: 1024 * 1024 }, (error) => resolve(!error));
328
+ });
329
+ }
320
330
  function decodeConfig(raw) {
321
331
  if (raw === null || raw === undefined)
322
332
  return { cli: "pi", fullAuto: false };
@@ -364,7 +374,7 @@ export const PiDriver = {
364
374
  const { instanceId, config } = input;
365
375
  const catalogEnv = piEnvironment({ ...process.env, ...input.environment });
366
376
  let models = EMPTY;
367
- const refreshModels = async () => {
377
+ const readModels = async () => {
368
378
  let base = models;
369
379
  try {
370
380
  const resolved = await fetchPiModels(config.cli, catalogEnv);
@@ -384,7 +394,13 @@ export const PiDriver = {
384
394
  models = base;
385
395
  }
386
396
  };
387
- await refreshModels();
397
+ const refreshModels = async () => {
398
+ await updatePiModelCatalog(config.cli, catalogEnv);
399
+ await readModels();
400
+ };
401
+ // Startup stays local and fast. Only the explicit Refresh button crosses
402
+ // pi's model-catalog network boundary.
403
+ await readModels();
388
404
  const listeners = new Set();
389
405
  // one active turn per thread
390
406
  const active = new Map();
@@ -595,6 +611,8 @@ export const PiDriver = {
595
611
  itemType: "tool",
596
612
  itemId: evt.toolCallId,
597
613
  title: String(evt.toolName ?? "tool").slice(0, 80),
614
+ summary: commandSummary(evt.args),
615
+ input: toolDetailPreview(evt.args),
598
616
  });
599
617
  return;
600
618
  }
@@ -605,6 +623,7 @@ export const PiDriver = {
605
623
  itemType: "tool",
606
624
  itemId: evt.toolCallId,
607
625
  ok: !evt.isError,
626
+ output: toolDetailPreview(evt.result),
608
627
  });
609
628
  return;
610
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()`);
@@ -207,8 +207,8 @@ function isFile(p) {
207
207
  }
208
208
  }
209
209
  /** PATHEXT-aware `which`. A path-ish cli is probed where it points. */
210
- function whichWin(cli) {
211
- const exts = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
210
+ function whichWin(cli, env) {
211
+ const exts = ((env ?? process.env).PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
212
212
  // an extensionless name is not runnable on Windows, so PATHEXT wins over
213
213
  // the bare file — npm installs both `claude` (a sh script) and `claude.cmd`
214
214
  const probe = (base) => {
@@ -217,7 +217,8 @@ function whichWin(cli) {
217
217
  };
218
218
  if (/[\\/]/.test(cli) || /^[a-zA-Z]:/.test(cli))
219
219
  return probe(cli);
220
- for (const dir of augmentedPath().split(delimiter)) {
220
+ const path = env ? (env.PATH ?? env.Path ?? "") : augmentedPath();
221
+ for (const dir of path.split(delimiter)) {
221
222
  if (!dir)
222
223
  continue;
223
224
  const hit = probe(join(dir, cli));
@@ -229,20 +230,20 @@ function whichWin(cli) {
229
230
  /** node.exe to run a script with: the one npm's shim would pick, else PATH,
230
231
  * else this executable only when it really is Node. In a packaged app,
231
232
  * process.execPath is Electron and must never be mistaken for node.exe. */
232
- function nodeExe(near) {
233
+ function nodeExe(near, env) {
233
234
  const local = join(near, "node.exe");
234
235
  if (isFile(local))
235
236
  return local;
236
237
  // Ask for the executable explicitly so a custom PATHEXT ordering cannot
237
238
  // make a stray node.cmd hide the real node.exe beside it.
238
- const onPath = whichWin("node.exe");
239
+ const onPath = whichWin("node.exe", env);
239
240
  if (onPath && extname(onPath).toLowerCase() === ".exe")
240
241
  return onPath;
241
242
  return process.versions.electron ? null : process.execPath;
242
243
  }
243
244
  /** npm/pnpm .cmd shims all spell their target as "%dp0%\..." (or
244
245
  * "%~dp0\..."). Whatever of those exists on disk is what the shim runs. */
245
- function parseCmdShim(shim) {
246
+ function parseCmdShim(shim, env) {
246
247
  let text;
247
248
  try {
248
249
  text = readFileSync(shim, "utf8");
@@ -256,7 +257,7 @@ function parseCmdShim(shim) {
256
257
  .filter((p) => isFile(p) && basename(p).toLowerCase() !== "node.exe");
257
258
  const script = targets.find((p) => /\.[cm]?js$/i.test(p));
258
259
  if (script) {
259
- const node = nodeExe(dir);
260
+ const node = nodeExe(dir, env);
260
261
  if (node)
261
262
  return { command: node, args: [script] };
262
263
  }
@@ -265,7 +266,7 @@ function parseCmdShim(shim) {
265
266
  }
266
267
  /** `#!/usr/bin/env node` → `node <script>`. Only node: nothing else has a
267
268
  * meaningful Windows equivalent worth guessing at. */
268
- function parseNodeShebang(file) {
269
+ function parseNodeShebang(file, env) {
269
270
  let head = "";
270
271
  let fd = null;
271
272
  try {
@@ -289,7 +290,7 @@ function parseNodeShebang(file) {
289
290
  }
290
291
  if (!/^#!.*\bnode(\.exe)?\b/.test(head))
291
292
  return null;
292
- const node = nodeExe(dirname(file));
293
+ const node = nodeExe(dirname(file), env);
293
294
  return node ? { command: node, args: [file] } : null;
294
295
  }
295
296
  /**
@@ -297,31 +298,31 @@ function parseNodeShebang(file) {
297
298
  * everywhere but win32 — POSIX already resolves PATH and #! itself.
298
299
  */
299
300
  /** Resolve a single command word (no tokenizer) — the platform spawn rules. */
300
- function resolveWord(cli, args) {
301
+ function resolveWord(cli, args, env) {
301
302
  if (process.platform !== "win32")
302
303
  return { command: cli, args };
303
- const file = whichWin(cli);
304
+ const file = whichWin(cli, env);
304
305
  // not found: hand back the name so spawn reports its own ENOENT
305
306
  if (!file)
306
307
  return { command: cli, args };
307
308
  const ext = extname(file).toLowerCase();
308
309
  if (ext === ".cmd" || ext === ".bat") {
309
- const direct = parseCmdShim(file);
310
+ const direct = parseCmdShim(file, env);
310
311
  return direct ? { command: direct.command, args: [...direct.args, ...args] } : { command: file, args };
311
312
  }
312
313
  if (ext === ".exe" || ext === ".com")
313
314
  return { command: file, args };
314
- const viaNode = parseNodeShebang(file);
315
+ const viaNode = parseNodeShebang(file, env);
315
316
  return viaNode ? { command: viaNode.command, args: [...viaNode.args, ...args] } : { command: file, args };
316
317
  }
317
- export function resolveCliSpawn(cli, args) {
318
+ export function resolveCliSpawn(cli, args, env) {
318
319
  // An EXISTING FILE wins over the tokenizer: paths with spaces come from
319
320
  // our own candidate list unquoted ("/Applications/My Tools/claude"), and
320
321
  // splitting those would shred them. Only a bare word (no spaces) or an
321
322
  // explicitly-quoted/wrapper string reaches the split below.
322
323
  const trimmed = cli.trim();
323
324
  if (trimmed.includes(" ") && existsSync(trimmed))
324
- return resolveWord(trimmed, args);
325
+ return resolveWord(trimmed, args, env);
325
326
  // A `cli` value may carry fixed leading arguments — wrapper scripts like
326
327
  // `/usr/local/bin/ag claude agp` are one string in the Engines panel. ONE
327
328
  // tokenizer pass, then resolve the head directly (never re-tokenized: a
@@ -335,7 +336,7 @@ export function resolveCliSpawn(cli, args) {
335
336
  // empty input → hand the raw string to spawn so IT reports the ENOENT
336
337
  if (!head)
337
338
  return { command: cli, args };
338
- return resolveWord(head, [...fixed, ...args]);
339
+ return resolveWord(head, [...fixed, ...args], env);
339
340
  }
340
- return resolveWord(cli, args);
341
+ return resolveWord(cli, args, env);
341
342
  }
@@ -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;