openmausbot 0.1.68 → 0.1.70
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-B2F__IRS.css +1 -0
- package/dist/assets/index-CLj3t5Ba.js +280 -0
- package/dist/assets/{index-HqooKFTS.js → index-D1RGhLUK.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +40 -2
- package/dist-server/drivers/agents-proxy.js +188 -5
- package/dist-server/index.js +2431 -984
- package/dist-server/local-computer-proxy.js +2 -0
- package/dist-server/openmausbot.js +923 -334
- package/dist-server/pair-cli.js +923 -334
- package/dist-server/prepare-cloudflared.js +20 -3
- package/dist-server/server/avatar-image.js +86 -34
- package/dist-server/server/bot-package.js +60 -14
- package/dist-server/server/config.js +23 -2
- package/dist-server/server/delegations.js +132 -74
- package/dist-server/server/drivers/acp/qwen.js +142 -5
- package/dist-server/server/drivers/agents-proxy.js +224 -4
- package/dist-server/server/drivers/claude-login-auth.js +102 -9
- package/dist-server/server/drivers/claude.js +11 -5
- package/dist-server/server/drivers/codex-device-auth.js +102 -3
- package/dist-server/server/drivers/codex-identity.js +115 -0
- package/dist-server/server/drivers/codex.js +6 -0
- package/dist-server/server/engine-install.js +137 -0
- package/dist-server/server/env-path.js +12 -0
- package/dist-server/server/harness/registry.js +33 -6
- package/dist-server/server/index.js +365 -26
- package/dist-server/server/package-export.js +7 -0
- package/dist-server/server/peer-provenance.js +6 -2
- package/dist-server/server/provider-auth-sessions.js +24 -0
- package/dist-server/server/routine-requests.js +270 -25
- package/dist-server/server/routines.js +297 -69
- package/dist-server/server/steer-queue.js +48 -1
- package/dist-server/server/store.js +22 -4
- package/dist-server/server/system-prompt.js +1 -0
- package/dist-server/server/team-backup.js +18 -4
- package/dist-server/shared/image-generation.js +28 -0
- package/dist-server/shared/team-backup.js +1 -0
- package/dist-server/vps-container-mcp.js +40 -2
- package/package.json +1 -1
- package/dist/assets/index-CbaejjQA.js +0 -278
- package/dist/assets/index-aJ5EWZOQ.css +0 -1
|
@@ -1376,6 +1376,7 @@ export const ClaudeDriver = {
|
|
|
1376
1376
|
getAuthentication: (flowId) => login.get(flowId),
|
|
1377
1377
|
completeAuthentication: (flowId, code) => login.complete(flowId, code),
|
|
1378
1378
|
cancelAuthentication: () => login.cancel(),
|
|
1379
|
+
signOut: () => login.signOut(),
|
|
1379
1380
|
adapter: {
|
|
1380
1381
|
provider: DRIVER_KIND,
|
|
1381
1382
|
capabilities: {
|
|
@@ -1423,11 +1424,16 @@ export const ClaudeDriver = {
|
|
|
1423
1424
|
generateText: (prompt) => generateReview(prompt),
|
|
1424
1425
|
reviewPermission: generateReview,
|
|
1425
1426
|
dispose: async () => {
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1427
|
+
try {
|
|
1428
|
+
await login.dispose();
|
|
1429
|
+
}
|
|
1430
|
+
finally {
|
|
1431
|
+
for (const { stop } of active.values())
|
|
1432
|
+
stop();
|
|
1433
|
+
for (const threadId of [...sessions.keys()])
|
|
1434
|
+
closeSession(threadId, "dispose");
|
|
1435
|
+
listeners.clear();
|
|
1436
|
+
}
|
|
1431
1437
|
},
|
|
1432
1438
|
};
|
|
1433
1439
|
},
|
|
@@ -45,6 +45,7 @@ function loginFailure(output) {
|
|
|
45
45
|
* output stays in bounded private memory; only a device URL/code is exposed. */
|
|
46
46
|
export class CodexDeviceAuthController {
|
|
47
47
|
flow = null;
|
|
48
|
+
command = null;
|
|
48
49
|
disposed = false;
|
|
49
50
|
options;
|
|
50
51
|
constructor(options) { this.options = options; }
|
|
@@ -121,12 +122,111 @@ export class CodexDeviceAuthController {
|
|
|
121
122
|
await this.flow?.stopping;
|
|
122
123
|
if (this.flow?.terminationFailed)
|
|
123
124
|
throw new Error(this.flow.status.message);
|
|
125
|
+
if (this.command && !await this.stopChild(this.command)) {
|
|
126
|
+
throw new Error("Codex could not be stopped on this server. Ask the server administrator to stop the account command before trying again.");
|
|
127
|
+
}
|
|
124
128
|
}
|
|
125
129
|
async dispose() {
|
|
126
130
|
this.disposed = true;
|
|
127
131
|
await this.cancel();
|
|
128
132
|
this.flow = null;
|
|
129
133
|
}
|
|
134
|
+
/** Remove the ChatGPT sign-in Codex stores for this server account so a
|
|
135
|
+
* different account can connect. A sign-in in progress is never pulled
|
|
136
|
+
* away underneath the browser completing it. */
|
|
137
|
+
async signOut() {
|
|
138
|
+
if (this.disposed)
|
|
139
|
+
throw new Error("This provider was removed. Refresh Settings before signing out.");
|
|
140
|
+
if (this.flow?.status.phase === "waiting")
|
|
141
|
+
throw new Error("Finish or cancel the ChatGPT sign-in in progress before signing out.");
|
|
142
|
+
await this.flow?.stopping;
|
|
143
|
+
if (this.disposed)
|
|
144
|
+
throw new Error("This provider was removed. Refresh Settings before signing out.");
|
|
145
|
+
const env = this.options.environment();
|
|
146
|
+
const home = env.HOME || env.USERPROFILE || homedir();
|
|
147
|
+
if (!isAbsolute(home) || (env.CODEX_HOME && !isAbsolute(env.CODEX_HOME))) {
|
|
148
|
+
throw new Error("Use an absolute HOME and CODEX_HOME path for this server's Codex provider before signing out.");
|
|
149
|
+
}
|
|
150
|
+
const homeKey = canonicalPath(resolve(env.CODEX_HOME || join(home, ".codex")));
|
|
151
|
+
if (authenticatingHomes.has(homeKey)) {
|
|
152
|
+
throw new Error("A ChatGPT sign-in is running for this server account. Finish or cancel it before signing out.");
|
|
153
|
+
}
|
|
154
|
+
authenticatingHomes.add(homeKey);
|
|
155
|
+
try {
|
|
156
|
+
const before = await this.exec(["login", "status"], env, home);
|
|
157
|
+
if (before.code === 1 && /^not logged in\b/im.test(before.output))
|
|
158
|
+
return;
|
|
159
|
+
if (before.code !== 0 || !/^logged in.*chatgpt\b/im.test(before.output)) {
|
|
160
|
+
throw new Error("Codex did not confirm a ChatGPT sign-in. Other authentication methods will not be removed; ask the server administrator to check the account.");
|
|
161
|
+
}
|
|
162
|
+
const logout = await this.exec(["logout"], env, home);
|
|
163
|
+
if (logout.code !== 0)
|
|
164
|
+
throw new Error("Codex could not remove the sign-in on this server. Check the server's Codex installation and try again.");
|
|
165
|
+
// The command's own report is not enough: confirm with the same status
|
|
166
|
+
// check that decides whether bots may run on this account.
|
|
167
|
+
const status = await this.exec(["login", "status"], env, home);
|
|
168
|
+
if (status.code !== 1 || !/^not logged in\b/im.test(status.output)) {
|
|
169
|
+
throw new Error("Codex still reports a sign-in on this server. Update Codex and check its login status before trying again.");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
// If the OS cannot stop a command, it may still change credentials.
|
|
174
|
+
// Keep the home reserved until that exact child actually exits.
|
|
175
|
+
const child = this.command;
|
|
176
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
177
|
+
child.once("close", () => authenticatingHomes.delete(homeKey));
|
|
178
|
+
}
|
|
179
|
+
else
|
|
180
|
+
authenticatingHomes.delete(homeKey);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** One bounded, non-interactive Codex command. Its output stays here;
|
|
184
|
+
* callers see an exit code and a status-line match, never the text. */
|
|
185
|
+
exec(args, env, cwd) {
|
|
186
|
+
return new Promise((resolveExec, rejectExec) => {
|
|
187
|
+
if (this.disposed) {
|
|
188
|
+
rejectExec(new Error("This provider was removed. Refresh Settings before signing out."));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
let child;
|
|
192
|
+
try {
|
|
193
|
+
child = spawnCli(this.options.cli, args, { env: { ...env, NO_COLOR: "1" }, cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
rejectExec(new Error("Codex could not start on this server. Install or update the configured Codex CLI, then try again."));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
this.command = child;
|
|
200
|
+
child.stdin.end();
|
|
201
|
+
let output = "";
|
|
202
|
+
const receive = (chunk) => {
|
|
203
|
+
if (output.length < MAX_OUTPUT)
|
|
204
|
+
output += chunk.toString("utf8").slice(0, MAX_OUTPUT - output.length);
|
|
205
|
+
};
|
|
206
|
+
child.stdout.on("data", receive);
|
|
207
|
+
child.stderr.on("data", receive);
|
|
208
|
+
// A hung CLI must not hold the credential-home lock forever.
|
|
209
|
+
const timer = setTimeout(() => {
|
|
210
|
+
void this.stopChild(child).catch(() => false).then((stopped) => {
|
|
211
|
+
if (!stopped)
|
|
212
|
+
rejectExec(new Error("Codex could not be stopped on this server. Ask the server administrator to stop the account command before trying again."));
|
|
213
|
+
});
|
|
214
|
+
}, this.options.startupTimeoutMs ?? 30_000);
|
|
215
|
+
timer.unref();
|
|
216
|
+
child.once("error", (error) => {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
rejectExec(new Error(error.code === "ENOENT"
|
|
219
|
+
? "Codex is not installed on this server. Run npm install -g @openai/codex@latest on the server, then try again."
|
|
220
|
+
: "Codex could not start on this server. Check the configured CLI path and its executable permissions."));
|
|
221
|
+
});
|
|
222
|
+
child.once("close", (code) => {
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
if (this.command === child)
|
|
225
|
+
this.command = null;
|
|
226
|
+
resolveExec({ code, output: stripVTControlCharacters(output) });
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
130
230
|
run(flow, args, env, cwd, done, parsePrompt = false) {
|
|
131
231
|
if (flow.status.phase !== "waiting")
|
|
132
232
|
return;
|
|
@@ -188,7 +288,7 @@ export class CodexDeviceAuthController {
|
|
|
188
288
|
else
|
|
189
289
|
flow.reject(new Error(message ?? "ChatGPT sign-in did not finish."));
|
|
190
290
|
}
|
|
191
|
-
flow.stopping = this.stopChild(flow).catch(() => false).then((stopped) => {
|
|
291
|
+
flow.stopping = this.stopChild(flow.child).catch(() => false).then((stopped) => {
|
|
192
292
|
const release = () => authenticatingHomes.delete(flow.homeKey);
|
|
193
293
|
if (stopped) {
|
|
194
294
|
release();
|
|
@@ -206,8 +306,7 @@ export class CodexDeviceAuthController {
|
|
|
206
306
|
if (phase === "succeeded")
|
|
207
307
|
void this.options.onAuthenticated?.().catch(() => { });
|
|
208
308
|
}
|
|
209
|
-
async stopChild(
|
|
210
|
-
const child = flow.child;
|
|
309
|
+
async stopChild(child) {
|
|
211
310
|
if (!child || await killCliTree(child, this.options.terminateTimeoutMs ?? 1500))
|
|
212
311
|
return true;
|
|
213
312
|
// A broken CLI may ignore SIGTERM. Do not leave a stale device flow able
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Let Codex resolve its effective account, including keyring credentials.
|
|
2
|
+
// Reading auth.json directly can label an API-key or keyring login with an
|
|
3
|
+
// old file's email. Only the protocol's display email leaves this helper.
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { killCliTree, spawnCli } from "../procs.js";
|
|
7
|
+
const MAX_OUTPUT = 16_384;
|
|
8
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
9
|
+
/** Where this environment's Codex keeps its credentials, or null when the
|
|
10
|
+
* configured home is not absolute (the login controller refuses those too). */
|
|
11
|
+
export function codexHome(env) {
|
|
12
|
+
if (env.CODEX_HOME)
|
|
13
|
+
return isAbsolute(env.CODEX_HOME) ? resolve(env.CODEX_HOME) : null;
|
|
14
|
+
const home = env.HOME || env.USERPROFILE || homedir();
|
|
15
|
+
return isAbsolute(home) ? join(home, ".codex") : null;
|
|
16
|
+
}
|
|
17
|
+
/** Read-only account metadata. Unsupported CLIs and uncertain responses stay
|
|
18
|
+
* unnamed; never fall back to a potentially inactive credential file. */
|
|
19
|
+
export async function codexAccountEmail(cli, env, timeoutMs = 3_000) {
|
|
20
|
+
const cwd = env.HOME || env.USERPROFILE || homedir();
|
|
21
|
+
if (!isAbsolute(cwd) || !codexHome(env))
|
|
22
|
+
return null;
|
|
23
|
+
return new Promise((resolveEmail) => {
|
|
24
|
+
let child;
|
|
25
|
+
try {
|
|
26
|
+
child = spawnCli(cli, ["app-server"], { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
resolveEmail(null);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let finishing = false;
|
|
33
|
+
let buffer = "";
|
|
34
|
+
let outputBytes = 0;
|
|
35
|
+
let initialized = false;
|
|
36
|
+
const finish = async (email) => {
|
|
37
|
+
if (finishing)
|
|
38
|
+
return;
|
|
39
|
+
finishing = true;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
let stopped = await killCliTree(child, 1_000);
|
|
42
|
+
if (!stopped && child.pid) {
|
|
43
|
+
try {
|
|
44
|
+
if (process.platform === "win32")
|
|
45
|
+
child.kill("SIGKILL");
|
|
46
|
+
else
|
|
47
|
+
process.kill(-child.pid, "SIGKILL");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
try {
|
|
51
|
+
child.kill("SIGKILL");
|
|
52
|
+
}
|
|
53
|
+
catch { /* already gone */ }
|
|
54
|
+
}
|
|
55
|
+
stopped = await killCliTree(child, 1_000);
|
|
56
|
+
}
|
|
57
|
+
resolveEmail(stopped ? email : null);
|
|
58
|
+
};
|
|
59
|
+
const timer = setTimeout(() => { void finish(null); }, timeoutMs);
|
|
60
|
+
timer.unref();
|
|
61
|
+
const send = (message) => {
|
|
62
|
+
try {
|
|
63
|
+
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
void finish(null);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
child.stdin.on("error", () => { void finish(null); });
|
|
70
|
+
child.stderr.resume(); // Do not retain or log raw CLI errors or credentials.
|
|
71
|
+
child.on("error", () => { void finish(null); });
|
|
72
|
+
child.on("close", () => { void finish(null); });
|
|
73
|
+
child.stdout.setEncoding("utf8");
|
|
74
|
+
child.stdout.on("data", (chunk) => {
|
|
75
|
+
if (finishing)
|
|
76
|
+
return;
|
|
77
|
+
outputBytes += Buffer.byteLength(chunk);
|
|
78
|
+
if (outputBytes > MAX_OUTPUT) {
|
|
79
|
+
void finish(null);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
buffer += chunk;
|
|
83
|
+
let newline;
|
|
84
|
+
while (!finishing && (newline = buffer.indexOf("\n")) !== -1) {
|
|
85
|
+
const line = buffer.slice(0, newline);
|
|
86
|
+
buffer = buffer.slice(newline + 1);
|
|
87
|
+
if (!line.trim())
|
|
88
|
+
continue;
|
|
89
|
+
let message;
|
|
90
|
+
try {
|
|
91
|
+
message = JSON.parse(line);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
void finish(null);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!initialized && message?.id === 1) {
|
|
98
|
+
if (message.error || !message.result) {
|
|
99
|
+
void finish(null);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
initialized = true;
|
|
103
|
+
send({ method: "initialized", params: {} });
|
|
104
|
+
send({ id: 2, method: "account/read", params: { refreshToken: false } });
|
|
105
|
+
}
|
|
106
|
+
else if (initialized && message?.id === 2) {
|
|
107
|
+
const account = message.error ? null : message.result?.account;
|
|
108
|
+
const email = account?.type === "chatgpt" ? account.email : null;
|
|
109
|
+
void finish(typeof email === "string" && email.length <= 254 && EMAIL.test(email) && !/[\p{Cc}\p{Cf}]/u.test(email) ? email : null);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
send({ id: 1, method: "initialize", params: { clientInfo: { name: "openmausbot", version: "1" } } });
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -24,6 +24,7 @@ import { classifyError, computeBackoff, RETRY_MAX_ATTEMPTS } from "./retry.js";
|
|
|
24
24
|
import { appendNative } from "./native.js";
|
|
25
25
|
import { codexDeveloperInstructions, syncCodexInstructions } from "./codex-instructions.js";
|
|
26
26
|
import { CodexDeviceAuthController } from "./codex-device-auth.js";
|
|
27
|
+
import { codexAccountEmail } from "./codex-identity.js";
|
|
27
28
|
export { decodeCodexSelection, readCodexModelCatalog, STATIC_CODEX_MODELS } from "./codex-catalog.js";
|
|
28
29
|
const DRIVER_KIND = "codex";
|
|
29
30
|
const ASTRA_MODEL_ID = "gpt-6-astra";
|
|
@@ -1093,11 +1094,15 @@ export const CodexDriver = {
|
|
|
1093
1094
|
const authenticated = await new Promise((resolve) => {
|
|
1094
1095
|
execCli(config.cli, ["login", "status"], { timeout: 8000, env }, (err, stdout, stderr) => resolve(!err && /^logged in\b/im.test(`${stdout}\n${stderr ?? ""}`)));
|
|
1095
1096
|
});
|
|
1097
|
+
// Display identity only, so Settings can say whose ChatGPT account the
|
|
1098
|
+
// bots run on; the status command above stays the authority on sign-in.
|
|
1099
|
+
const email = authenticated ? await codexAccountEmail(config.cli, env) : null;
|
|
1096
1100
|
// childEnv drops OPENAI_API_KEY on purpose — turns run on the ChatGPT login
|
|
1097
1101
|
return {
|
|
1098
1102
|
state: "available",
|
|
1099
1103
|
version,
|
|
1100
1104
|
authenticated,
|
|
1105
|
+
...(email ? { account: { email } } : {}),
|
|
1101
1106
|
update: codexAstraUpdate(version, models, config.cli),
|
|
1102
1107
|
billing: "subscription",
|
|
1103
1108
|
};
|
|
@@ -1114,6 +1119,7 @@ export const CodexDriver = {
|
|
|
1114
1119
|
startAuthentication: () => authentication.start(),
|
|
1115
1120
|
getAuthentication: (flowId) => authentication.get(flowId),
|
|
1116
1121
|
cancelAuthentication: () => authentication.cancel(),
|
|
1122
|
+
signOut: () => authentication.signOut(),
|
|
1117
1123
|
snapshot,
|
|
1118
1124
|
adapter: {
|
|
1119
1125
|
provider: DRIVER_KIND,
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Install an engine's command-line app from Settings, on the machine that
|
|
2
|
+
// runs this server, as this process's own user, into a directory the app
|
|
3
|
+
// owns. No sudo, no shell, no terminal: the package name comes from the
|
|
4
|
+
// driver's own install descriptor and never from a request, npm runs with
|
|
5
|
+
// a fixed argument list, and the binary is found on the engines' PATH
|
|
6
|
+
// afterwards because that directory is registered ahead of everything else.
|
|
7
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { stripVTControlCharacters } from "node:util";
|
|
10
|
+
import { DATA_DIR, stripWorkspaceCredentialEnv } from "./config.js";
|
|
11
|
+
import { augmentedPath, findCliCandidates, registerPathDir, resetPathCache } from "./env-path.js";
|
|
12
|
+
import { killCliTree, spawnCli } from "./procs.js";
|
|
13
|
+
const MAX_OUTPUT = 64 * 1024;
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60_000;
|
|
15
|
+
/** npm's global prefix for engines the app installs itself. */
|
|
16
|
+
export function enginesPrefix(baseDir = DATA_DIR) {
|
|
17
|
+
return join(baseDir, "tools", "npm");
|
|
18
|
+
}
|
|
19
|
+
/** Where that prefix puts executables: `bin/` on POSIX, the prefix itself on Windows. */
|
|
20
|
+
export function enginesBinDir(baseDir = DATA_DIR, platform = process.platform) {
|
|
21
|
+
return platform === "win32" ? enginesPrefix(baseDir) : join(enginesPrefix(baseDir), "bin");
|
|
22
|
+
}
|
|
23
|
+
/** Called once at boot: engines installed here win over any other copy on PATH. */
|
|
24
|
+
export function registerEnginesBinDir(baseDir = DATA_DIR) {
|
|
25
|
+
registerPathDir(enginesBinDir(baseDir));
|
|
26
|
+
}
|
|
27
|
+
/** The npm package a driver's own install one-liner names, when it is one. */
|
|
28
|
+
export function npmPackageOf(install) {
|
|
29
|
+
const line = install?.command?.linux ?? install?.command?.darwin ?? install?.command?.win32;
|
|
30
|
+
const match = line ? /^npm install -g ((?:@[\w.-]+\/)?[\w.-]+)$/.exec(line.trim()) : null;
|
|
31
|
+
return match ? match[1] : null;
|
|
32
|
+
}
|
|
33
|
+
export function npmAvailable() {
|
|
34
|
+
return findCliCandidates("npm").length > 0;
|
|
35
|
+
}
|
|
36
|
+
/** What Settings may install for this engine on this machine, or null. A
|
|
37
|
+
* managed engine keeps its own verified download; anything else needs a
|
|
38
|
+
* plain npm package and npm on PATH. */
|
|
39
|
+
export function serverInstallFor(install, npmPresent = npmAvailable()) {
|
|
40
|
+
if (!install || install.managed)
|
|
41
|
+
return null;
|
|
42
|
+
const pkg = npmPackageOf(install);
|
|
43
|
+
return pkg && npmPresent ? { package: pkg } : null;
|
|
44
|
+
}
|
|
45
|
+
const running = new Map();
|
|
46
|
+
/** Install or update one npm engine. Concurrent clicks share one npm run. */
|
|
47
|
+
export function installNpmEngine(pkg, options = {}) {
|
|
48
|
+
const key = `${options.baseDir ?? DATA_DIR} ${pkg}`;
|
|
49
|
+
const existing = running.get(key);
|
|
50
|
+
if (existing)
|
|
51
|
+
return existing;
|
|
52
|
+
const run = installOnce(pkg, options).finally(() => running.delete(key));
|
|
53
|
+
running.set(key, run);
|
|
54
|
+
return run;
|
|
55
|
+
}
|
|
56
|
+
async function installOnce(pkg, options) {
|
|
57
|
+
const prefix = enginesPrefix(options.baseDir);
|
|
58
|
+
mkdirSync(prefix, { recursive: true });
|
|
59
|
+
const env = {
|
|
60
|
+
...(options.env ?? process.env),
|
|
61
|
+
PATH: options.path ?? augmentedPath(),
|
|
62
|
+
NO_COLOR: "1",
|
|
63
|
+
npm_config_update_notifier: "false",
|
|
64
|
+
npm_config_fund: "false",
|
|
65
|
+
npm_config_audit: "false",
|
|
66
|
+
};
|
|
67
|
+
// Workspace credentials (xai/box/voice keys) are not npm's to see.
|
|
68
|
+
stripWorkspaceCredentialEnv(env);
|
|
69
|
+
// npm 11 skips a dependency's install script unless the package is named
|
|
70
|
+
// here; the engines that need one (Claude Code) are exactly these.
|
|
71
|
+
const args = ["install", "-g", "--prefix", prefix, "--loglevel=error", `--allow-scripts=${pkg}`, `${pkg}@latest`];
|
|
72
|
+
const result = await runNpm(args, env, prefix, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
73
|
+
if (result.code !== 0) {
|
|
74
|
+
throw new Error(`npm could not install ${pkg} on this server.${tail(result.output)}`);
|
|
75
|
+
}
|
|
76
|
+
// Whatever the boot registration did, the directory we just filled must
|
|
77
|
+
// be on the engines' PATH from here on.
|
|
78
|
+
registerPathDir(enginesBinDir(options.baseDir));
|
|
79
|
+
resetPathCache();
|
|
80
|
+
if (options.cli) {
|
|
81
|
+
const binDir = enginesBinDir(options.baseDir);
|
|
82
|
+
if (!findCliCandidates(options.cli).some((path) => path.startsWith(binDir))) {
|
|
83
|
+
throw new Error(`${pkg} installed, but it did not provide a \`${options.cli}\` command. Check the package name in this engine's install descriptor.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function tail(output) {
|
|
88
|
+
const clean = stripVTControlCharacters(output).trim();
|
|
89
|
+
if (!clean)
|
|
90
|
+
return "";
|
|
91
|
+
const lines = clean.split(/\r?\n/).filter((line) => line.trim()).slice(-6);
|
|
92
|
+
return `\n${lines.join("\n").slice(-600)}`;
|
|
93
|
+
}
|
|
94
|
+
function runNpm(args, env, cwd, timeoutMs) {
|
|
95
|
+
return new Promise((resolveRun, rejectRun) => {
|
|
96
|
+
let child;
|
|
97
|
+
try {
|
|
98
|
+
child = spawnCli("npm", args, { env, cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
rejectRun(new Error("npm could not start on this server. Install Node.js with npm for the user running OpenMausBot, then try again."));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
child.stdin.end();
|
|
105
|
+
let output = "";
|
|
106
|
+
const receive = (chunk) => {
|
|
107
|
+
if (output.length < MAX_OUTPUT)
|
|
108
|
+
output += chunk.toString("utf8").slice(0, MAX_OUTPUT - output.length);
|
|
109
|
+
};
|
|
110
|
+
child.stdout.on("data", receive);
|
|
111
|
+
child.stderr.on("data", receive);
|
|
112
|
+
let timedOut = false;
|
|
113
|
+
const timer = setTimeout(() => {
|
|
114
|
+
timedOut = true;
|
|
115
|
+
void killCliTree(child);
|
|
116
|
+
}, timeoutMs);
|
|
117
|
+
timer.unref();
|
|
118
|
+
child.once("error", (error) => {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
rejectRun(new Error(error.code === "ENOENT"
|
|
121
|
+
? "npm is not installed on this server. Install Node.js with npm for the user running OpenMausBot, then try again."
|
|
122
|
+
: "npm could not start on this server. Check that Node.js is installed for the user running OpenMausBot."));
|
|
123
|
+
});
|
|
124
|
+
child.once("close", (code) => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
if (timedOut) {
|
|
127
|
+
rejectRun(new Error("The install took too long and was stopped. Check the server's network connection and try again."));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
resolveRun({ code, output });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/** True once something has been installed here, for status pages. */
|
|
135
|
+
export function enginesInstalled(baseDir = DATA_DIR) {
|
|
136
|
+
return existsSync(enginesBinDir(baseDir));
|
|
137
|
+
}
|
|
@@ -80,6 +80,16 @@ let loginShellPath = null;
|
|
|
80
80
|
* and without resetting it a rescan would rebuild the cache without those
|
|
81
81
|
* entries and never re-probe — "check again" would permanently lose
|
|
82
82
|
* anything only the login shell's rc file knows about. */
|
|
83
|
+
// Directories the app manages itself (its own npm prefix for engines it
|
|
84
|
+
// installs from Settings). They go ahead of everything else so an engine
|
|
85
|
+
// installed there wins over an older copy elsewhere on PATH.
|
|
86
|
+
const registeredDirs = [];
|
|
87
|
+
export function registerPathDir(dir) {
|
|
88
|
+
if (registeredDirs.includes(dir))
|
|
89
|
+
return;
|
|
90
|
+
registeredDirs.unshift(dir);
|
|
91
|
+
resetPathCache();
|
|
92
|
+
}
|
|
83
93
|
export function resetPathCache() {
|
|
84
94
|
cached = null;
|
|
85
95
|
probed = false;
|
|
@@ -88,6 +98,7 @@ export function resetPathCache() {
|
|
|
88
98
|
export function augmentedPath() {
|
|
89
99
|
if (cached === null) {
|
|
90
100
|
cached = mergePaths([
|
|
101
|
+
...registeredDirs.filter((d) => existsSync(d)),
|
|
91
102
|
...(process.env.OMB_EXTRA_PATH ? process.env.OMB_EXTRA_PATH.split(delimiter) : []),
|
|
92
103
|
...(process.env.PATH ? process.env.PATH.split(delimiter) : []),
|
|
93
104
|
// Keep the last successful login-shell result while a rescan starts a
|
|
@@ -131,6 +142,7 @@ export function resetPathCacheForTests() {
|
|
|
131
142
|
cached = null;
|
|
132
143
|
probed = false;
|
|
133
144
|
loginShellPath = null;
|
|
145
|
+
registeredDirs.length = 0;
|
|
134
146
|
}
|
|
135
147
|
/** Every `name` binary on the augmented PATH as absolute paths, in PATH
|
|
136
148
|
* order (first = what a bare name would run). Used by the Engines panel's
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
// compatible — do not remove it); dispose tears an instance down without
|
|
6
6
|
// touching its siblings.
|
|
7
7
|
import { findCliCandidates } from "../env-path.js";
|
|
8
|
+
import { installNpmEngine, npmAvailable, serverInstallFor } from "../engine-install.js";
|
|
9
|
+
/** The driver's install descriptor plus what this machine can do about it. */
|
|
10
|
+
function withServerInstall(install, npmPresent) {
|
|
11
|
+
const server = install ? serverInstallFor(install, npmPresent) : null;
|
|
12
|
+
return server ? { ...install, server } : install;
|
|
13
|
+
}
|
|
8
14
|
/** The `cli` field off a driver's default config, when it has one — the
|
|
9
15
|
* placeholder an override input shows when nothing is set. */
|
|
10
16
|
function cliDefaultOf(driver) {
|
|
@@ -30,7 +36,14 @@ export class ProviderRegistry {
|
|
|
30
36
|
* from their own config; this map only reports what was configured */
|
|
31
37
|
cliByInstance = new Map();
|
|
32
38
|
driversByKind;
|
|
33
|
-
|
|
39
|
+
/** Where Settings-driven npm installs go; the data directory by default. */
|
|
40
|
+
enginesBaseDir;
|
|
41
|
+
/** Whether npm is on this machine; injectable because the PATH scan also
|
|
42
|
+
* looks in standard install locations, which tests cannot empty. */
|
|
43
|
+
npmPresent;
|
|
44
|
+
constructor(drivers, options = {}) {
|
|
45
|
+
this.enginesBaseDir = options.enginesBaseDir;
|
|
46
|
+
this.npmPresent = options.npmAvailable ?? npmAvailable;
|
|
34
47
|
this.driversByKind = new Map(drivers.map((d) => [d.driverKind, d]));
|
|
35
48
|
}
|
|
36
49
|
async load(configs) {
|
|
@@ -113,11 +126,22 @@ export class ProviderRegistry {
|
|
|
113
126
|
await instance.refreshModels?.();
|
|
114
127
|
return true;
|
|
115
128
|
}
|
|
129
|
+
/** A driver's own installer (a managed download) first; otherwise the
|
|
130
|
+
* app's npm prefix, when the driver's install one-liner is an npm package
|
|
131
|
+
* and npm is on PATH. False means Settings has nothing to offer here. */
|
|
116
132
|
async installRuntime(instanceId) {
|
|
117
|
-
const
|
|
118
|
-
if (!
|
|
133
|
+
const entry = this.byId.get(instanceId);
|
|
134
|
+
if (!entry)
|
|
135
|
+
return false;
|
|
136
|
+
if (entry.live?.installRuntime) {
|
|
137
|
+
await entry.live.installRuntime();
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
const driver = this.driversByKind.get(entry.shadow?.driverKind ?? entry.live.driverKind);
|
|
141
|
+
const server = serverInstallFor(driver?.install, this.npmPresent());
|
|
142
|
+
if (!server)
|
|
119
143
|
return false;
|
|
120
|
-
await
|
|
144
|
+
await installNpmEngine(server.package, { baseDir: this.enginesBaseDir, cli: cliDefaultOf(driver) });
|
|
121
145
|
return true;
|
|
122
146
|
}
|
|
123
147
|
async startAuthentication(instanceId) {
|
|
@@ -158,6 +182,7 @@ export class ProviderRegistry {
|
|
|
158
182
|
candidatesByName.set(name, found);
|
|
159
183
|
return found;
|
|
160
184
|
};
|
|
185
|
+
const npmPresent = this.npmPresent();
|
|
161
186
|
return Promise.all(this.entries().map(async (entry) => {
|
|
162
187
|
const driver = this.driversByKind.get(entry.shadow?.driverKind ?? entry.live.driverKind);
|
|
163
188
|
if (entry.shadow) {
|
|
@@ -170,7 +195,7 @@ export class ProviderRegistry {
|
|
|
170
195
|
capabilities: { computerMcp: false, agentsMcp: false, localComputerMcp: false },
|
|
171
196
|
// an unknown driver has no driver record, hence no install path
|
|
172
197
|
access: driver?.metadata.access ?? "subscription",
|
|
173
|
-
install: driver?.install,
|
|
198
|
+
install: withServerInstall(driver?.install, npmPresent),
|
|
174
199
|
cli: entry.shadow.cli,
|
|
175
200
|
cliDefault: cliDefaultOf(driver),
|
|
176
201
|
// a shadow is exactly the "your CLI is broken, pick another"
|
|
@@ -205,7 +230,7 @@ export class ProviderRegistry {
|
|
|
205
230
|
approvalReview: inst.reviewPermission !== undefined,
|
|
206
231
|
},
|
|
207
232
|
access: driver?.metadata.access ?? "subscription",
|
|
208
|
-
install: driver?.install,
|
|
233
|
+
install: withServerInstall(driver?.install, npmPresent),
|
|
209
234
|
authentication: inst.startAuthentication
|
|
210
235
|
? {
|
|
211
236
|
method: inst.getAuthentication && inst.completeAuthentication
|
|
@@ -213,6 +238,8 @@ export class ProviderRegistry {
|
|
|
213
238
|
: inst.getAuthentication
|
|
214
239
|
? "device-code" // a code to enter at the provider's page (Codex)
|
|
215
240
|
: "browser", // a link and a callback URL (managed engines)
|
|
241
|
+
// the browser may remove the stored sign-in to switch accounts
|
|
242
|
+
signOut: inst.signOut !== undefined,
|
|
216
243
|
}
|
|
217
244
|
: undefined,
|
|
218
245
|
cli: this.cliByInstance.get(inst.instanceId),
|