openmausbot 0.1.69 → 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.
@@ -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
- for (const { stop } of active.values())
1427
- stop();
1428
- for (const threadId of [...sessions.keys()])
1429
- closeSession(threadId, "dispose");
1430
- listeners.clear();
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
  },
@@ -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
- constructor(drivers) {
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 instance = this.get(instanceId);
118
- if (!instance?.installRuntime)
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 instance.installRuntime();
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