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.
- package/dist/assets/{index-C6blGSq5.js → index-DeK-z9ld.js} +1 -1
- package/dist/assets/index-XdHJ-9CF.css +1 -0
- package/dist/assets/index-lhqE1umn.js +305 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +5 -1
- package/dist-server/drivers/agents-proxy.js +52 -1
- package/dist-server/enterprise/server/index.js +387 -29
- package/dist-server/index.js +1377 -459
- package/dist-server/openmausbot.js +725 -522
- package/dist-server/pair-cli.js +725 -522
- package/dist-server/server/agent-tool-policy.js +1 -0
- package/dist-server/server/browser-live.js +3 -0
- package/dist-server/server/config.js +16 -0
- package/dist-server/server/drivers/acp/core.js +3 -1
- package/dist-server/server/drivers/agents-proxy.js +58 -2
- package/dist-server/server/drivers/boxagent.js +1 -1
- package/dist-server/server/drivers/claude.js +3 -2
- package/dist-server/server/drivers/codex.js +3 -1
- package/dist-server/server/drivers/pi.js +4 -0
- package/dist-server/server/enterprise.js +38 -0
- package/dist-server/server/environment.js +3 -0
- package/dist-server/server/fleet-agent.js +57 -13
- package/dist-server/server/fleet-cli.js +248 -31
- package/dist-server/server/fleet.js +88 -14
- package/dist-server/server/hosted-contract.js +16 -0
- package/dist-server/server/index.js +671 -100
- package/dist-server/server/message-file.js +4 -0
- package/dist-server/server/provider-key-check.js +18 -1
- package/dist-server/server/request-auth.js +14 -5
- package/dist-server/server/sessions.js +115 -8
- package/dist-server/server/shared-computer-control.js +24 -0
- package/dist-server/server/shared-computers.js +131 -0
- package/dist-server/server/steer-queue.js +2 -0
- package/dist-server/server/store.js +20 -7
- package/dist-server/server/team-computers.js +108 -0
- package/dist-server/server/tool-summary.js +53 -1
- package/dist-server/server/workspace-backup.js +1 -0
- package/dist-server/shared/approval-mode.js +4 -1
- package/dist-server/shared/team-computer.js +1 -0
- package/dist-server/vps-container-mcp.js +5 -1
- package/enterprise/README.md +21 -3
- package/enterprise/server/index.js +387 -29
- package/package.json +1 -1
- package/dist/assets/index-BtT047dk.css +0 -1
- package/dist/assets/index-fKXFpB_J.js +0 -305
|
@@ -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
|
-
?
|
|
764
|
-
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
93
|
-
|
|
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;
|