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.
- package/dist/assets/{index-BTlyRwLl.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/companion/src/routes.js +6 -2
- package/dist-server/container-mcp.js +5 -1
- package/dist-server/drivers/agents-proxy.js +184 -11
- package/dist-server/enterprise/server/index.js +387 -29
- package/dist-server/index.js +3786 -1135
- package/dist-server/mcp-gate.js +28 -18
- package/dist-server/openmausbot.js +927 -642
- package/dist-server/pair-cli.js +927 -642
- package/dist-server/server/agent-tool-policy.js +2 -0
- package/dist-server/server/atomic.js +37 -1
- package/dist-server/server/bot-overview.js +5 -1
- package/dist-server/server/browser-live.js +3 -0
- package/dist-server/server/chief-of-staff.js +17 -12
- package/dist-server/server/config.js +16 -0
- package/dist-server/server/delegations.js +177 -63
- package/dist-server/server/drivers/acp/core.js +3 -1
- package/dist-server/server/drivers/acp/hermes.js +21 -3
- package/dist-server/server/drivers/agents-proxy.js +169 -10
- package/dist-server/server/drivers/boxagent.js +1 -1
- package/dist-server/server/drivers/claude.js +34 -16
- package/dist-server/server/drivers/codex.js +3 -1
- package/dist-server/server/drivers/openai-chat.js +90 -67
- package/dist-server/server/drivers/openai-compat.js +9 -1
- package/dist-server/server/drivers/pi.js +22 -3
- package/dist-server/server/enterprise.js +38 -0
- package/dist-server/server/env-path.js +18 -17
- 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 +1777 -406
- package/dist-server/server/message-db.js +21 -0
- package/dist-server/server/message-file.js +4 -0
- package/dist-server/server/peer-roster.js +48 -9
- package/dist-server/server/procs.js +19 -3
- package/dist-server/server/provider-key-check.js +18 -1
- package/dist-server/server/request-auth.js +14 -5
- package/dist-server/server/room-handoffs.js +237 -0
- package/dist-server/server/section-context.js +64 -14
- 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 +267 -15
- package/dist-server/server/surface.js +16 -5
- package/dist-server/server/team-backup.js +17 -1
- package/dist-server/server/team-computers.js +108 -0
- package/dist-server/server/team-setup-requests.js +252 -0
- package/dist-server/server/tool-summary.js +53 -1
- package/dist-server/server/turn-dispatch-guard.js +31 -3
- package/dist-server/server/workspace-backup.js +1 -0
- package/dist-server/shared/approval-mode.js +11 -1
- package/dist-server/shared/team-backup.js +1 -0
- package/dist-server/shared/team-computer.js +1 -0
- package/dist-server/shared/team-setup.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-CeMbvsgx.js +0 -305
- 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
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|