openmausbot 0.1.61 → 0.1.63
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/README.md +71 -8
- package/dist/assets/index-BuntoOSX.js +264 -0
- package/dist/assets/{index-CAZ41wL2.js → index-C-SoIGdn.js} +1 -1
- package/dist/index.html +1 -1
- package/dist-server/container-mcp.js +16 -1
- package/dist-server/drivers/agents-proxy.js +8 -4
- package/dist-server/index.js +468 -141
- package/dist-server/openmausbot.js +33585 -14506
- package/dist-server/pair-cli.js +33585 -14506
- package/dist-server/server/auto-approve.js +6 -20
- package/dist-server/server/browser-bundle-release.js +56 -0
- package/dist-server/server/browser-engine-release.js +13 -5
- package/dist-server/server/browser-engine.js +114 -15
- package/dist-server/server/cli-api-setup.js +144 -0
- package/dist-server/server/cli-phone-setup.js +130 -0
- package/dist-server/server/cli-prompts.js +185 -0
- package/dist-server/server/cli-setup.js +303 -0
- package/dist-server/server/cli.js +375 -70
- package/dist-server/server/config.js +25 -1
- package/dist-server/server/default-model-selection.js +21 -0
- package/dist-server/server/drivers/agents-proxy.js +13 -4
- package/dist-server/server/drivers/claude.js +26 -1
- package/dist-server/server/drivers/openai-compat.js +4 -2
- package/dist-server/server/index.js +123 -89
- package/dist-server/server/recall-disclosure.js +45 -0
- package/dist-server/server/request-auth.js +6 -0
- package/dist-server/server/screen-frame-gate.js +36 -1
- package/dist-server/server/screen-frame-source.js +57 -0
- package/dist-server/server/sessions.js +52 -4
- package/dist-server/vps-container-mcp.js +16 -1
- package/package.json +1 -1
- package/dist/assets/index-Br81ZQCY.js +0 -264
|
@@ -11,27 +11,13 @@
|
|
|
11
11
|
// backstop for the obvious catastrophes. Real containment is the
|
|
12
12
|
// sandbox and the bot's own computer, not a regex.
|
|
13
13
|
import { approvalModeFor } from "../shared/approval-mode.js";
|
|
14
|
-
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* And Full is a decision the person made about THEIR OWN sessions with a
|
|
20
|
-
* bot — "run without asking me". A turn started by another bot is not one
|
|
21
|
-
* of those: the person never saw the request, and the sender may itself be
|
|
22
|
-
* unattended or working off a page it just read. A Full target reached that
|
|
23
|
-
* way runs as Approve for me instead — ordinary requests still flow, the
|
|
24
|
-
* destructive and sensitive guards card, an unattended sender's block holds,
|
|
25
|
-
* and every answer goes through the fold and into the decision log, where a
|
|
26
|
-
* driver-side Full accept never appears. A webhook or scheduled turn on a
|
|
27
|
-
* Full bot is unchanged: the person opted into that, and the docs say so. */
|
|
14
|
+
/** Full access is the person's explicit grant to this receiving bot, including
|
|
15
|
+
* delegated work. It never inherits the sender's mode or elevates another bot.
|
|
16
|
+
* Custom is a provider-config choice rather than an app Full-access grant, so
|
|
17
|
+
* peer-started Custom turns still use Auto and its unattended downgrade.
|
|
18
|
+
* Provider support and grant confirmation are checked by the caller. */
|
|
28
19
|
export function approvalModeForOrigin(mode, origin) {
|
|
29
|
-
|
|
30
|
-
// this only answers who STARTED the turn. A permissive mode is the
|
|
31
|
-
// person's grant to the bot they talk to, not to every teammate that can
|
|
32
|
-
// reach it, so a peer-started turn runs one notch down and still gets the
|
|
33
|
-
// unattended downgrade after this.
|
|
34
|
-
if ((mode === "full" || mode === "custom") && origin.peerInitiated)
|
|
20
|
+
if (mode === "custom" && origin.peerInitiated)
|
|
35
21
|
return "auto";
|
|
36
22
|
return mode;
|
|
37
23
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Reviewed vendor assets, downloaded and hashed on 2026-09-07. Update all
|
|
2
|
+
// pins together and run each platform's packaged, offline browser smoke test.
|
|
3
|
+
// This is Chromium's headless shell, not full Chrome (which includes Widevine).
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { agentBrowserReleaseUrl, agentBrowserReleaseVersion, resolveAgentBrowserReleaseAsset, } from "./browser-engine-release.js";
|
|
6
|
+
export const CHROME_VERSION = "152.0.7977.82";
|
|
7
|
+
export const SUPPORTED_BROWSER_TARGETS = ["darwin-arm64", "darwin-x64", "linux-x64", "win32-x64"];
|
|
8
|
+
const CHROME_ASSETS = {
|
|
9
|
+
"darwin-arm64": { platform: "mac-arm64", bytes: 97760996, sha256: "1615f063c894aa824fd55c89f8f05e9904f63cce0c95d0cbce7394d77884fba5", executableSha256: "ac18537fb2bc2b72b0440893b6ea5d4ef3bacc6220b1e9a1693c73f8b1045fbe" },
|
|
10
|
+
"darwin-x64": { platform: "mac-x64", bytes: 102895824, sha256: "c903707292c6aaed7c6e572c0bb5e6645454e45d9e0e6db27e876b18e9165f31", executableSha256: "2faefd55d4e47764956e31e338d362a1e794037fa772a426ffd510f122f33f19" },
|
|
11
|
+
"linux-x64": { platform: "linux64", bytes: 119454769, sha256: "0ca12ea26b502a83e32db334a17883c315348845efc071d908de7a6d94a97eff", executableSha256: "1301a5024fef1d58c4cfbde5d44794cb18c5b2cbbe4f3c64f81cc4eaa683c45f" },
|
|
12
|
+
"win32-x64": { platform: "win64", bytes: 119768220, sha256: "86fb1fa7fbbda65f6f1572062c358803e61a6e6d9489e6619b37a61d609fa845", executableSha256: "ec0744f041cb6c35c439c85a5ac42d77b507838cd59cac005e25114025336c98" },
|
|
13
|
+
};
|
|
14
|
+
export function browserBundleSpec(target) {
|
|
15
|
+
if (!Object.hasOwn(CHROME_ASSETS, target))
|
|
16
|
+
throw new Error(`Unsupported desktop browser target: ${target}`);
|
|
17
|
+
const pinned = CHROME_ASSETS[target];
|
|
18
|
+
const [platform, arch] = target.split("-");
|
|
19
|
+
const engine = resolveAgentBrowserReleaseAsset(platform, arch);
|
|
20
|
+
const suffix = platform === "win32" ? ".exe" : "";
|
|
21
|
+
const directory = `chrome-headless-shell-${pinned.platform}`;
|
|
22
|
+
return {
|
|
23
|
+
schemaVersion: 1,
|
|
24
|
+
target,
|
|
25
|
+
engine: {
|
|
26
|
+
version: agentBrowserReleaseVersion(engine),
|
|
27
|
+
asset: engine.asset,
|
|
28
|
+
url: agentBrowserReleaseUrl(engine),
|
|
29
|
+
bytes: engine.bytes,
|
|
30
|
+
sha256: engine.sha256,
|
|
31
|
+
executable: `agent-browser${suffix}`,
|
|
32
|
+
},
|
|
33
|
+
chrome: {
|
|
34
|
+
version: CHROME_VERSION,
|
|
35
|
+
asset: `${directory}.zip`,
|
|
36
|
+
url: `https://storage.googleapis.com/chrome-for-testing-public/${CHROME_VERSION}/${pinned.platform}/${directory}.zip`,
|
|
37
|
+
bytes: pinned.bytes,
|
|
38
|
+
sha256: pinned.sha256,
|
|
39
|
+
executableSha256: pinned.executableSha256,
|
|
40
|
+
executable: `chrome/${directory}/chrome-headless-shell${suffix}`,
|
|
41
|
+
license: `chrome/${directory}/LICENSE.headless_shell`,
|
|
42
|
+
about: `chrome/${directory}/ABOUT`,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** bundleDirectory is the target's directory, e.g. Resources/browser-engine. */
|
|
47
|
+
export function browserBundlePaths(bundleDirectory, target) {
|
|
48
|
+
const spec = browserBundleSpec(target);
|
|
49
|
+
return {
|
|
50
|
+
directory: bundleDirectory,
|
|
51
|
+
manifest: join(bundleDirectory, "manifest.json"),
|
|
52
|
+
engine: join(bundleDirectory, spec.engine.executable),
|
|
53
|
+
chrome: join(bundleDirectory, spec.chrome.executable),
|
|
54
|
+
licenses: join(bundleDirectory, "licenses"),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The pinned agent-browser release the harness downloads for the bots'
|
|
2
2
|
// browser (docs/plans/browser-engine.md). Digests were computed from the
|
|
3
|
-
// GitHub
|
|
4
|
-
//
|
|
5
|
-
// The Dockerfile
|
|
3
|
+
// official GitHub assets on 2026-09-06, except the explicitly versioned Windows
|
|
4
|
+
// vendor build below. Update pins through a reviewed native end-to-end run.
|
|
5
|
+
// The Dockerfile retains the official Linux version.
|
|
6
6
|
export const AGENT_BROWSER_VERSION = "0.36.0";
|
|
7
7
|
const RELEASES = new Map([
|
|
8
8
|
[
|
|
@@ -31,11 +31,19 @@ const RELEASES = new Map([
|
|
|
31
31
|
],
|
|
32
32
|
[
|
|
33
33
|
"win32-x64",
|
|
34
|
-
{
|
|
34
|
+
{
|
|
35
|
+
target: "win32-x64", version: "0.36.0-omb.1",
|
|
36
|
+
asset: "agent-browser-win32-x64-0.36.0-omb.1.exe",
|
|
37
|
+
url: "https://github.com/milind-soni/OpenMausBot/releases/download/browser-engine-v0.36.0-omb.1/agent-browser-win32-x64-0.36.0-omb.1.exe",
|
|
38
|
+
sha256: "33bee834f6a6072ec8688b0914726e0262874d758f69f27e8baf7eaac6b5ed15", bytes: 13806080,
|
|
39
|
+
},
|
|
35
40
|
],
|
|
36
41
|
]);
|
|
37
42
|
export function agentBrowserReleaseUrl(asset) {
|
|
38
|
-
return `https://github.com/vercel-labs/agent-browser/releases/download/v${AGENT_BROWSER_VERSION}/${asset.asset}`;
|
|
43
|
+
return asset.url ?? `https://github.com/vercel-labs/agent-browser/releases/download/v${AGENT_BROWSER_VERSION}/${asset.asset}`;
|
|
44
|
+
}
|
|
45
|
+
export function agentBrowserReleaseVersion(asset) {
|
|
46
|
+
return asset?.version ?? AGENT_BROWSER_VERSION;
|
|
39
47
|
}
|
|
40
48
|
/** The asset for this machine, or null where Vercel publishes none. */
|
|
41
49
|
export function resolveAgentBrowserReleaseAsset(platform = process.platform, arch = process.arch, musl = false) {
|
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
// reason a person can act on, never a silently browserless bot.
|
|
11
11
|
import { spawn } from "node:child_process";
|
|
12
12
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
13
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
14
15
|
import { delimiter, join, resolve } from "node:path";
|
|
15
16
|
import { writeFileAtomic } from "./atomic.js";
|
|
16
17
|
import { DATA_DIR } from "./config.js";
|
|
17
|
-
import {
|
|
18
|
+
import { browserBundlePaths } from "./browser-bundle-release.js";
|
|
19
|
+
import { AGENT_BROWSER_VERSION, agentBrowserReleaseUrl, agentBrowserReleaseVersion, resolveAgentBrowserReleaseAsset, } from "./browser-engine-release.js";
|
|
18
20
|
const ENGINE_DIR = "tools/agent-browser";
|
|
19
21
|
const KEY_FILE = "browser-engine-key";
|
|
20
22
|
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
|
|
@@ -25,8 +27,9 @@ function executableName(platform = process.platform) {
|
|
|
25
27
|
export function isMusl(platform = process.platform, exists = existsSync) {
|
|
26
28
|
return platform === "linux" && (exists("/lib/ld-musl-x86_64.so.1") || exists("/lib/ld-musl-aarch64.so.1"));
|
|
27
29
|
}
|
|
28
|
-
export function pinnedBinaryPath(dataDir = DATA_DIR, platform = process.platform) {
|
|
29
|
-
|
|
30
|
+
export function pinnedBinaryPath(dataDir = DATA_DIR, platform = process.platform, arch = process.arch) {
|
|
31
|
+
const version = agentBrowserReleaseVersion(resolveAgentBrowserReleaseAsset(platform, arch));
|
|
32
|
+
return join(dataDir, ENGINE_DIR, version, executableName(platform));
|
|
30
33
|
}
|
|
31
34
|
function onPath(env, platform, exists) {
|
|
32
35
|
const pathValue = platform === "win32"
|
|
@@ -42,7 +45,21 @@ function onPath(env, platform, exists) {
|
|
|
42
45
|
}
|
|
43
46
|
return null;
|
|
44
47
|
}
|
|
45
|
-
|
|
48
|
+
function packagedBrowser(options) {
|
|
49
|
+
const resources = (options.env ?? process.env).OMB_RESOURCES_PATH;
|
|
50
|
+
if (!resources)
|
|
51
|
+
return null;
|
|
52
|
+
try {
|
|
53
|
+
return browserBundlePaths(join(resolve(resources), "browser-engine"), `${options.platform ?? process.platform}-${options.arch ?? process.arch}`);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null; // No desktop bundle for this platform/architecture.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function completePackage(bundle, exists) {
|
|
60
|
+
return [bundle.manifest, bundle.engine, bundle.chrome, bundle.licenses].every(exists);
|
|
61
|
+
}
|
|
62
|
+
/** OMB_AGENT_BROWSER_PATH, then the complete desktop bundle, pinned download, then
|
|
46
63
|
* PATH (a package or image that installed it globally). */
|
|
47
64
|
export function resolveAgentBrowserBinary(options = {}) {
|
|
48
65
|
const env = options.env ?? process.env;
|
|
@@ -51,7 +68,10 @@ export function resolveAgentBrowserBinary(options = {}) {
|
|
|
51
68
|
const override = env.OMB_AGENT_BROWSER_PATH?.trim();
|
|
52
69
|
if (override)
|
|
53
70
|
return resolve(override) && exists(resolve(override)) ? resolve(override) : null;
|
|
54
|
-
const
|
|
71
|
+
const bundle = packagedBrowser(options);
|
|
72
|
+
if (bundle && exists(bundle.directory))
|
|
73
|
+
return completePackage(bundle, exists) ? bundle.engine : null;
|
|
74
|
+
const pinned = pinnedBinaryPath(options.dataDir, platform, options.arch);
|
|
55
75
|
if (exists(pinned))
|
|
56
76
|
return pinned;
|
|
57
77
|
return onPath(env, platform, exists);
|
|
@@ -63,11 +83,11 @@ export async function installAgentBrowserBinary(options = {}) {
|
|
|
63
83
|
const asset = options.asset ?? resolveAgentBrowserReleaseAsset(platform, options.arch ?? process.arch, options.musl ?? isMusl(platform));
|
|
64
84
|
if (!asset)
|
|
65
85
|
throw new Error(`agent-browser publishes no build for ${platform}-${options.arch ?? process.arch}.`);
|
|
66
|
-
const destination = pinnedBinaryPath(options.dataDir, platform);
|
|
86
|
+
const destination = pinnedBinaryPath(options.dataDir, platform, options.arch);
|
|
67
87
|
const directory = join(destination, "..");
|
|
68
88
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
69
89
|
const url = agentBrowserReleaseUrl(asset);
|
|
70
|
-
options.log?.(`downloading agent-browser ${
|
|
90
|
+
options.log?.(`downloading agent-browser ${agentBrowserReleaseVersion(asset)} (${Math.round(asset.bytes / 1024 / 1024)} MB, digest pinned)`);
|
|
71
91
|
const controller = new AbortController();
|
|
72
92
|
const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
|
|
73
93
|
timer.unref?.();
|
|
@@ -97,6 +117,11 @@ export async function installAgentBrowserBinary(options = {}) {
|
|
|
97
117
|
* or Brave is found; `--with-deps` adds the Linux libraries (needs a package
|
|
98
118
|
* manager and privileges, so it is for images and root shells). */
|
|
99
119
|
export function ensureChrome(binaryPath, options = {}) {
|
|
120
|
+
const bundle = packagedBrowser(options);
|
|
121
|
+
if (!options.withDeps && bundle && resolve(binaryPath) === bundle.engine && completePackage(bundle, existsSync)) {
|
|
122
|
+
options.log?.("agent-browser: the bundled browser is ready; no download needed");
|
|
123
|
+
return Promise.resolve();
|
|
124
|
+
}
|
|
100
125
|
const args = ["install", ...(options.withDeps ? ["--with-deps"] : [])];
|
|
101
126
|
return new Promise((done, fail) => {
|
|
102
127
|
const child = spawn(binaryPath, args, { env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
@@ -135,13 +160,22 @@ export function browserEngineEncryptionKey(dataDir = DATA_DIR) {
|
|
|
135
160
|
/** What the harness can offer bots right now, with the reason when nothing. */
|
|
136
161
|
export function browserEngineStatus(options = {}) {
|
|
137
162
|
const binaryPath = resolveAgentBrowserBinary(options);
|
|
138
|
-
|
|
139
|
-
|
|
163
|
+
const bundle = packagedBrowser(options);
|
|
164
|
+
if (binaryPath) {
|
|
165
|
+
const platform = options.platform ?? process.platform;
|
|
166
|
+
const managed = binaryPath === bundle?.engine || binaryPath === pinnedBinaryPath(options.dataDir, platform, options.arch);
|
|
167
|
+
const version = managed ? agentBrowserReleaseVersion(resolveAgentBrowserReleaseAsset(platform, options.arch)) : AGENT_BROWSER_VERSION;
|
|
168
|
+
return { kind: "ready", binaryPath, version };
|
|
169
|
+
}
|
|
170
|
+
if (bundle && (options.exists ?? existsSync)(bundle.directory)) {
|
|
171
|
+
return { kind: "unavailable", reason: "The desktop browser bundle is incomplete. Reinstall or update OpenMausBot to repair it.", installable: false };
|
|
172
|
+
}
|
|
140
173
|
const platform = options.platform ?? process.platform;
|
|
141
|
-
const
|
|
174
|
+
const arch = options.arch ?? process.arch;
|
|
175
|
+
const asset = resolveAgentBrowserReleaseAsset(platform, arch, isMusl(platform));
|
|
142
176
|
return asset
|
|
143
177
|
? { kind: "unavailable", reason: "agent-browser is not installed on this machine yet", installable: true }
|
|
144
|
-
: { kind: "unavailable", reason: `agent-browser publishes no build for ${platform}-${
|
|
178
|
+
: { kind: "unavailable", reason: `agent-browser publishes no build for ${platform}-${arch}`, installable: false };
|
|
145
179
|
}
|
|
146
180
|
/** The MCP server a turn mounts so the bot gets browser tools. One isolated,
|
|
147
181
|
* auto-restored session per browser profile (or per bot), page-provided
|
|
@@ -149,6 +183,10 @@ export function browserEngineStatus(options = {}) {
|
|
|
149
183
|
export function agentBrowserIntegration(input) {
|
|
150
184
|
const env = {
|
|
151
185
|
AGENT_BROWSER_SESSION: input.session,
|
|
186
|
+
// The MCP server invokes child CLI commands without forwarding its own
|
|
187
|
+
// global flags. The environment keeps page-provided tools disabled in
|
|
188
|
+
// those commands too, and avoids changing browser launch settings later.
|
|
189
|
+
AGENT_BROWSER_NO_WEBMCP: "1",
|
|
152
190
|
// This is a restore *name*, not a boolean. "1" would give every bot
|
|
153
191
|
// the same saved cookies despite using different daemon sessions.
|
|
154
192
|
AGENT_BROWSER_RESTORE: input.session,
|
|
@@ -157,11 +195,72 @@ export function agentBrowserIntegration(input) {
|
|
|
157
195
|
};
|
|
158
196
|
if (input.headless !== false)
|
|
159
197
|
env.AGENT_BROWSER_HEADLESS = "1";
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
198
|
+
const sourceEnv = input.env ?? process.env;
|
|
199
|
+
// MCP clients may filter the parent environment. Carry the configured
|
|
200
|
+
// Chrome path explicitly without forwarding unrelated secrets or flags.
|
|
201
|
+
for (const name of ["PATH", "AGENT_BROWSER_EXECUTABLE_PATH"]) {
|
|
202
|
+
if (sourceEnv[name])
|
|
203
|
+
env[name] = sourceEnv[name];
|
|
204
|
+
}
|
|
205
|
+
const bundle = packagedBrowser({ env: sourceEnv });
|
|
206
|
+
if (!env.AGENT_BROWSER_EXECUTABLE_PATH && bundle && resolve(input.binaryPath) === bundle.engine && completePackage(bundle, existsSync)) {
|
|
207
|
+
env.AGENT_BROWSER_EXECUTABLE_PATH = bundle.chrome;
|
|
208
|
+
}
|
|
163
209
|
return { command: input.binaryPath, args: ["mcp", "--tools", "core", "--no-webmcp"], env };
|
|
164
210
|
}
|
|
211
|
+
/** How long a settled-frame capture may take before the turn gives up on it.
|
|
212
|
+
* The poller runs beside a live turn, so a hung browser must not hold the
|
|
213
|
+
* transcript open; a missing picture is better than a stuck fold. */
|
|
214
|
+
const FRAME_TIMEOUT_MS = 10_000;
|
|
215
|
+
/** One PNG of a bot's browser, for the transcript's settled frame.
|
|
216
|
+
*
|
|
217
|
+
* The Electron browser surface used to supply this and was removed with the
|
|
218
|
+
* engine swap, leaving the computer surfaces as the only frame source — so a
|
|
219
|
+
* bot whose only surface is the browser showed the reader nothing at all,
|
|
220
|
+
* despite the panel promising screenshots in the chat.
|
|
221
|
+
*
|
|
222
|
+
* Runs the same binary with the same session env as the MCP mount, so it
|
|
223
|
+
* attaches to the daemon the bot is already driving rather than starting a
|
|
224
|
+
* second browser beside it. */
|
|
225
|
+
export function agentBrowserFrame(input) {
|
|
226
|
+
const file = join(tmpdir(), `openmausbot-browser-${randomUUID()}.png`);
|
|
227
|
+
return new Promise((settle, fail) => {
|
|
228
|
+
const child = spawn(input.binaryPath, ["screenshot", file], {
|
|
229
|
+
env: { ...process.env, ...input.env },
|
|
230
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
231
|
+
windowsHide: true,
|
|
232
|
+
});
|
|
233
|
+
let stderr = "";
|
|
234
|
+
child.stderr?.on("data", (chunk) => {
|
|
235
|
+
if (stderr.length < 2_000)
|
|
236
|
+
stderr += String(chunk);
|
|
237
|
+
});
|
|
238
|
+
const timer = setTimeout(() => {
|
|
239
|
+
child.kill("SIGKILL");
|
|
240
|
+
fail(new Error("the browser did not return a picture in time"));
|
|
241
|
+
}, input.timeoutMs ?? FRAME_TIMEOUT_MS);
|
|
242
|
+
const done = (error) => {
|
|
243
|
+
clearTimeout(timer);
|
|
244
|
+
try {
|
|
245
|
+
if (error) {
|
|
246
|
+
fail(error);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
settle({ png: readFileSync(file).toString("base64"), format: "png" });
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
fail(new Error("the browser reported a picture it did not write"));
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
rmSync(file, { force: true });
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
child.on("error", (error) => done(error instanceof Error ? error : new Error(String(error))));
|
|
259
|
+
child.on("close", (code) => {
|
|
260
|
+
done(code === 0 ? null : new Error(`the browser could not be pictured${stderr.trim() ? `: ${stderr.trim().slice(0, 200)}` : ""}`));
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
}
|
|
165
264
|
/** Session ids are file-system and shell safe: a bot id or a profile partition. */
|
|
166
265
|
export function browserSessionId(botId, partitionId) {
|
|
167
266
|
if (partitionId === "guest")
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Small, stateless probes for CLI onboarding. The caller owns consent for the
|
|
2
|
+
// metered completion and persistence; neither credentials nor replies are logged.
|
|
3
|
+
export const API_ENDPOINTS = [
|
|
4
|
+
{ id: "openai", label: "OpenAI API", url: "https://api.openai.com/v1", keyUrl: "https://platform.openai.com/api-keys" },
|
|
5
|
+
{ id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/api/v1", keyUrl: "https://openrouter.ai/settings/keys" },
|
|
6
|
+
{ id: "groq", label: "Groq", url: "https://api.groq.com/openai/v1", keyUrl: "https://console.groq.com/keys" },
|
|
7
|
+
];
|
|
8
|
+
export function normalizeApiUrl(value) {
|
|
9
|
+
const input = value.trim();
|
|
10
|
+
const invalid = () => new Error("Enter an HTTPS API base URL without credentials, a query, or a fragment. HTTP is allowed only for localhost.");
|
|
11
|
+
let url;
|
|
12
|
+
try {
|
|
13
|
+
url = new URL(input);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw invalid();
|
|
17
|
+
}
|
|
18
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
19
|
+
if (
|
|
20
|
+
// Reject invisible URL characters and ambiguous authority/path separators.
|
|
21
|
+
// eslint-disable-next-line no-control-regex
|
|
22
|
+
/[\u0000-\u0020\u007f\\?#]/u.test(input) || url.username || url.password ||
|
|
23
|
+
input.slice(input.indexOf("://") + 3).split("/")[0]?.includes("@") ||
|
|
24
|
+
(url.protocol !== "https:" && !(url.protocol === "http:" && local)))
|
|
25
|
+
throw invalid();
|
|
26
|
+
return url.href.replace(/\/+$/u, "");
|
|
27
|
+
}
|
|
28
|
+
class SetupApiError extends Error {
|
|
29
|
+
}
|
|
30
|
+
function httpFailure(status, completion) {
|
|
31
|
+
const prefix = `API returned HTTP ${status}.`;
|
|
32
|
+
if (status === 401 || status === 403)
|
|
33
|
+
return new SetupApiError(`${prefix} Check the API key and its access permissions.`);
|
|
34
|
+
if (status === 402)
|
|
35
|
+
return new SetupApiError(`${prefix} Check the provider's credits and billing settings.`);
|
|
36
|
+
if (status === 429)
|
|
37
|
+
return new SetupApiError(`${prefix} A rate or quota limit was reached. Check credits and limits, then try again later.`);
|
|
38
|
+
if (status >= 300 && status < 400)
|
|
39
|
+
return new SetupApiError(`${prefix} Redirects are not followed. Use the provider's direct API base URL.`);
|
|
40
|
+
if (completion && [400, 404, 422].includes(status))
|
|
41
|
+
return new SetupApiError(`${prefix} Check the model ID and access, or choose a model that supports Chat Completions at this API URL.`);
|
|
42
|
+
if (status === 404)
|
|
43
|
+
return new SetupApiError(`${prefix} Check the API base URL: it must provide a /models endpoint.`);
|
|
44
|
+
if (status >= 500)
|
|
45
|
+
return new SetupApiError(`${prefix} The provider is unavailable. Try again later.`);
|
|
46
|
+
return new SetupApiError(`${prefix} Check the API URL and provider settings, then try again.`);
|
|
47
|
+
}
|
|
48
|
+
async function setupJson(url, key, body) {
|
|
49
|
+
const base = normalizeApiUrl(url);
|
|
50
|
+
// The wizard trims newly pasted keys. Existing credentials must be tested
|
|
51
|
+
// exactly as the runtime will send them, not silently repaired only here.
|
|
52
|
+
const token = key;
|
|
53
|
+
// Header credentials must not contain control characters or whitespace.
|
|
54
|
+
// eslint-disable-next-line no-control-regex
|
|
55
|
+
if (!token || /[\u0000-\u0020\u007f]/u.test(token))
|
|
56
|
+
throw new SetupApiError("Enter a non-empty API key without spaces or line breaks.");
|
|
57
|
+
const completion = body !== undefined;
|
|
58
|
+
const operation = completion ? "The test reply" : "The model list";
|
|
59
|
+
const timeoutMs = completion ? 30_000 : 8_000;
|
|
60
|
+
const abort = new AbortController();
|
|
61
|
+
let timer;
|
|
62
|
+
try {
|
|
63
|
+
// Include reading the response body in the deadline, not only headers.
|
|
64
|
+
return await Promise.race([
|
|
65
|
+
(async () => {
|
|
66
|
+
const response = await fetch(`${base}${completion ? "/chat/completions" : "/models"}`, {
|
|
67
|
+
method: completion ? "POST" : "GET",
|
|
68
|
+
headers: { authorization: `Bearer ${token}`, ...(completion ? { "content-type": "application/json" } : {}) },
|
|
69
|
+
...(completion ? { body: JSON.stringify(body) } : {}),
|
|
70
|
+
signal: abort.signal,
|
|
71
|
+
redirect: "error",
|
|
72
|
+
});
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
void response.body?.cancel().catch(() => { });
|
|
75
|
+
throw httpFailure(response.status, completion);
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
return await response.json();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new SetupApiError(`${operation} returned invalid JSON. Check that the API URL supports the OpenAI-compatible API.`);
|
|
82
|
+
}
|
|
83
|
+
})(),
|
|
84
|
+
new Promise((_resolve, reject) => {
|
|
85
|
+
timer = setTimeout(() => {
|
|
86
|
+
abort.abort();
|
|
87
|
+
reject(new SetupApiError(`${operation} timed out after ${timeoutMs / 1000} seconds. Check the connection and try again.`));
|
|
88
|
+
}, timeoutMs);
|
|
89
|
+
}),
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error instanceof SetupApiError)
|
|
94
|
+
throw error;
|
|
95
|
+
// fetch errors can contain a URL, echoed credentials, or provider content.
|
|
96
|
+
// Deliberately expose neither their message nor their cause.
|
|
97
|
+
throw new SetupApiError("Could not reach the API. Check the network and direct API URL; redirects are not followed.");
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
104
|
+
export async function fetchSetupModels(url, key) {
|
|
105
|
+
const json = record(await setupJson(url, key));
|
|
106
|
+
const models = new Map();
|
|
107
|
+
for (const entry of Array.isArray(json?.data) ? json.data : []) {
|
|
108
|
+
const item = record(entry);
|
|
109
|
+
const id = typeof item?.id === "string" ? item.id.trim() : "";
|
|
110
|
+
// Model IDs and display labels reach the terminal; block control sequences.
|
|
111
|
+
// eslint-disable-next-line no-control-regex
|
|
112
|
+
if (!id || /[\u0000-\u001f\u007f]/u.test(id) || models.has(id))
|
|
113
|
+
continue;
|
|
114
|
+
// eslint-disable-next-line no-control-regex
|
|
115
|
+
const name = typeof item?.name === "string" ? item.name.replace(/[\u0000-\u001f\u007f]/gu, "").trim() : "";
|
|
116
|
+
models.set(id, { id, label: name || id });
|
|
117
|
+
}
|
|
118
|
+
if (!models.size)
|
|
119
|
+
throw new SetupApiError("The API returned no usable models. Check the API URL and model access, or enter a model ID from your provider manually.");
|
|
120
|
+
return [...models.values()];
|
|
121
|
+
}
|
|
122
|
+
/** Call only after consent, passing the effective instance's provider routing. */
|
|
123
|
+
export async function verifySetupCompletion(url, key, model, provider) {
|
|
124
|
+
const id = model.trim();
|
|
125
|
+
// Keep manually entered IDs safe to show in the terminal and saved setup.
|
|
126
|
+
// eslint-disable-next-line no-control-regex
|
|
127
|
+
if (!id || /[\u0000-\u001f\u007f]/u.test(id))
|
|
128
|
+
throw new SetupApiError("Choose a non-empty model ID without control characters.");
|
|
129
|
+
const host = new URL(normalizeApiUrl(url)).hostname.toLowerCase();
|
|
130
|
+
// Mirror OpenAICompatDriver's OpenRouter-only routing, without inheriting
|
|
131
|
+
// environment overrides that the caller may have deliberately disabled.
|
|
132
|
+
const pinProvider = provider && (host === "openrouter.ai" || host.endsWith(".openrouter.ai"));
|
|
133
|
+
const json = record(await setupJson(url, key, {
|
|
134
|
+
model: id,
|
|
135
|
+
messages: [{ role: "user", content: "Reply with exactly OK." }],
|
|
136
|
+
stream: false,
|
|
137
|
+
...(pinProvider ? { provider: { order: [provider], allow_fallbacks: false } } : {}),
|
|
138
|
+
}));
|
|
139
|
+
const choice = record(Array.isArray(json?.choices) ? json.choices[0] : undefined);
|
|
140
|
+
const message = record(choice?.message);
|
|
141
|
+
if (message?.role !== "assistant" || typeof message.content !== "string" || !message.content.trim()) {
|
|
142
|
+
throw new SetupApiError("The API did not return an assistant text reply. Choose a model that supports Chat Completions, then try again.");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { SetupCancelled } from "./cli-prompts.js";
|
|
2
|
+
/** A configured HTTPS origin, not proof that a phone can reach it. Never accept
|
|
3
|
+
* a pasted invitation, password, query token, localhost or unspecified bind. */
|
|
4
|
+
export function normalizePhoneOrigin(raw) {
|
|
5
|
+
const value = raw.trim();
|
|
6
|
+
// Reject control characters before URL parsing can silently remove them.
|
|
7
|
+
// eslint-disable-next-line no-control-regex
|
|
8
|
+
if (!/^https:\/\/[^/]/i.test(value) || /[\s\u0000-\u001f\u007f\\?#@]/.test(value))
|
|
9
|
+
return null;
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(value);
|
|
12
|
+
const host = url.hostname.toLowerCase().replace(/\.$/, "");
|
|
13
|
+
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/")
|
|
14
|
+
return null;
|
|
15
|
+
if (!host || host === "localhost" || host.endsWith(".localhost") || host === "0.0.0.0" || host.startsWith("127."))
|
|
16
|
+
return null;
|
|
17
|
+
if (host === "[::]" || host === "[::1]" || /^\[::ffff:(?:0:0|7f[0-9a-f]{2}:[0-9a-f]+)\]$/.test(host))
|
|
18
|
+
return null;
|
|
19
|
+
return url.origin;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function runPhoneSetup(options, io, deps) {
|
|
26
|
+
const skip = () => ({ options });
|
|
27
|
+
const selected = (phone, route = {}) => ({
|
|
28
|
+
options: { ...options, ...route, client: true, pair: true }, phone,
|
|
29
|
+
});
|
|
30
|
+
const signIn = async () => {
|
|
31
|
+
try {
|
|
32
|
+
if (await deps.accountReady?.(options)) {
|
|
33
|
+
io.log("Your saved OpenMausBot account can be reused. The connection will be checked when the server starts.");
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
io.log("Sign in to an OpenMausBot account using an emailed code. This is separate from your AI provider account.");
|
|
37
|
+
if (await deps.login(options, io) === 0)
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error instanceof SetupCancelled)
|
|
42
|
+
throw error;
|
|
43
|
+
// Account errors can contain credentials or URLs. Leave details to the
|
|
44
|
+
// account UI rather than echoing an arbitrary error into terminal logs.
|
|
45
|
+
}
|
|
46
|
+
io.log("Phone access could not be prepared. Your AI provider setup is still saved; you can try phone setup later.");
|
|
47
|
+
return false;
|
|
48
|
+
};
|
|
49
|
+
for (;;) {
|
|
50
|
+
const device = await io.choose("Use OpenMausBot on your phone?", [
|
|
51
|
+
"Skip for now",
|
|
52
|
+
"iPhone / iPad — native app or Safari",
|
|
53
|
+
"Android — web browser",
|
|
54
|
+
], 0);
|
|
55
|
+
if (device === 0)
|
|
56
|
+
return skip();
|
|
57
|
+
const phone = device === 1 ? "ios" : "android";
|
|
58
|
+
if (options.tunnel)
|
|
59
|
+
return await signIn() ? selected(phone) : skip();
|
|
60
|
+
if (options.tailscale) {
|
|
61
|
+
io.log("Keep Tailscale connected on both this computer and your phone, on the same tailnet.");
|
|
62
|
+
return selected(phone);
|
|
63
|
+
}
|
|
64
|
+
const existing = options.publicUrl ? normalizePhoneOrigin(options.publicUrl) : null;
|
|
65
|
+
if (existing) {
|
|
66
|
+
io.log("Your configured HTTPS address will be checked before a phone pairing link is shown.");
|
|
67
|
+
return selected(phone, { publicUrl: existing });
|
|
68
|
+
}
|
|
69
|
+
io.log("This server listens only on this computer. A localhost or LAN-IP link will not connect your phone.");
|
|
70
|
+
let back = false;
|
|
71
|
+
while (!back) {
|
|
72
|
+
const route = await io.choose("How should your phone reach this computer?", [
|
|
73
|
+
"Managed HTTPS address — protected by pairing",
|
|
74
|
+
"Existing Tailscale — only your tailnet",
|
|
75
|
+
"Existing HTTPS address — advanced",
|
|
76
|
+
"Back to phone choice",
|
|
77
|
+
"Skip for now",
|
|
78
|
+
], 4);
|
|
79
|
+
if (route === 4)
|
|
80
|
+
return skip();
|
|
81
|
+
if (route === 3) {
|
|
82
|
+
back = true;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (route === 0) {
|
|
86
|
+
io.log("This creates a public HTTPS endpoint through Cloudflare. Chat and settings require device pairing; the sign-in page and basic server identity are public.");
|
|
87
|
+
io.log("Starting it may download the Cloudflare connector. It stays active while OpenMausBot runs; stop OpenMausBot to close the connection.");
|
|
88
|
+
if (!await io.confirm("Allow this managed public endpoint and connector download?", false))
|
|
89
|
+
continue;
|
|
90
|
+
if (!await signIn())
|
|
91
|
+
return skip();
|
|
92
|
+
return selected(phone, { tunnel: true, tailscale: false, publicUrl: undefined });
|
|
93
|
+
}
|
|
94
|
+
if (route === 1) {
|
|
95
|
+
io.log("Tailscale must already be installed and signed in on this computer and phone, with HTTPS certificates enabled for the tailnet.");
|
|
96
|
+
if (!await io.confirm("Allow OpenMausBot to serve HTTPS to your tailnet while it runs?", false))
|
|
97
|
+
continue;
|
|
98
|
+
return selected(phone, { tailscale: true, tunnel: false, publicUrl: undefined });
|
|
99
|
+
}
|
|
100
|
+
io.log("Use an HTTPS reverse proxy you already configured for this server. Enter only its origin, without a password, pairing code, path, query or fragment.");
|
|
101
|
+
const answer = await io.ask("Existing HTTPS address (Enter goes back): ");
|
|
102
|
+
if (!answer.trim())
|
|
103
|
+
continue;
|
|
104
|
+
const origin = normalizePhoneOrigin(answer);
|
|
105
|
+
if (!origin) {
|
|
106
|
+
io.log("Enter a non-localhost HTTPS origin, such as https://maus.example.com. The address was not saved.");
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
io.log("This does not create a proxy or open a LAN listener. Its connection will be checked before pairing.");
|
|
110
|
+
return selected(phone, { publicUrl: origin, tunnel: false, tailscale: false });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Call after route verification, beside the separately minted QR. The URL
|
|
115
|
+
* here is an origin, never the invitation containing its one-time credential. */
|
|
116
|
+
export function phonePairingInstructions(phone, input) {
|
|
117
|
+
const origin = input.origin ? normalizePhoneOrigin(input.origin) : null;
|
|
118
|
+
if (!input.ready || !origin) {
|
|
119
|
+
return ["Phone access is not ready yet. No phone QR should be shown until the HTTPS connection is verified.",
|
|
120
|
+
"Your local OpenMausBot can still be used on this computer."];
|
|
121
|
+
}
|
|
122
|
+
return [
|
|
123
|
+
phone === "ios"
|
|
124
|
+
? "On iPhone or iPad, scan the QR with Camera to open Safari. If you already have the OpenMausBot iOS app, use its pairing scanner or paste the full pairing link there."
|
|
125
|
+
: "On Android, scan the QR with Camera and open it in your web browser. This link is for the web app, not the Android native pairing scanner.",
|
|
126
|
+
`Or open ${origin}/pair on your phone and enter the code.`,
|
|
127
|
+
"Choose Connect on the phone. Scanning a QR does not mean the phone is paired.",
|
|
128
|
+
"The code works once and expires after five minutes. This phone receives client access: chat and approvals, not settings or pairing administration.",
|
|
129
|
+
];
|
|
130
|
+
}
|