openmausbot 0.1.63 → 0.1.66
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-BftlaHAF.css +1 -0
- package/dist/assets/index-DuR3H-Hf.js +278 -0
- package/dist/assets/{index-C-SoIGdn.js → index-kW5L1QeO.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/browser-proxy.js +121 -0
- package/dist-server/container-mcp.js +4 -1
- package/dist-server/index.js +17996 -15876
- package/dist-server/openmausbot.js +752 -245
- package/dist-server/pair-cli.js +752 -245
- package/dist-server/prepare-cloudflared.js +10 -2
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/attachments.js +140 -16
- package/dist-server/server/browser-engine-release.js +10 -8
- package/dist-server/server/browser-engine.js +226 -26
- package/dist-server/server/browser-live.js +567 -0
- package/dist-server/server/browser-proxy.js +129 -0
- package/dist-server/server/browser-runtime.js +370 -0
- package/dist-server/server/claude-accounts.js +103 -0
- package/dist-server/server/config.js +31 -20
- package/dist-server/server/custom-domain.js +196 -0
- package/dist-server/server/drivers/claude.js +77 -19
- package/dist-server/server/drivers/codex-device-auth.js +234 -0
- package/dist-server/server/drivers/codex.js +10 -0
- package/dist-server/server/harness/bus.js +9 -4
- package/dist-server/server/harness/registry.js +15 -0
- package/dist-server/server/index.js +446 -91
- package/dist-server/server/provider-auth-sessions.js +118 -0
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/sse-fanout.js +53 -0
- package/dist-server/src/lib/composer-attachments.js +23 -6
- package/dist-server/vps-container-mcp.js +4 -1
- package/package.json +1 -1
- package/dist/assets/index-BuntoOSX.js +0 -264
- package/dist/assets/index-oPMv6a_k.css +0 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Per-turn MCP entry point. Only a scoped browser capability crosses into the
|
|
2
|
+
// agent process; engine commands, session names, and credentials stay in OMB.
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
const MAX_INPUT_BYTES = 1_048_576;
|
|
5
|
+
const MAX_OUTPUT_BYTES = 16_777_216;
|
|
6
|
+
const MAX_PENDING = 16;
|
|
7
|
+
function failure(id, method, message, code = -32603) {
|
|
8
|
+
return method === "tools/call"
|
|
9
|
+
? { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: message }], isError: true } }
|
|
10
|
+
: { jsonrpc: "2.0", id, error: { code, message } };
|
|
11
|
+
}
|
|
12
|
+
async function boundedJson(response) {
|
|
13
|
+
if (!response.body)
|
|
14
|
+
throw new Error("Browser server returned an empty response.");
|
|
15
|
+
const reader = response.body.getReader();
|
|
16
|
+
const chunks = [];
|
|
17
|
+
let size = 0;
|
|
18
|
+
try {
|
|
19
|
+
for (;;) {
|
|
20
|
+
const { value, done } = await reader.read();
|
|
21
|
+
if (done)
|
|
22
|
+
break;
|
|
23
|
+
size += value.byteLength;
|
|
24
|
+
if (size > MAX_OUTPUT_BYTES)
|
|
25
|
+
throw new Error("Browser response exceeded the size limit.");
|
|
26
|
+
chunks.push(Buffer.from(value));
|
|
27
|
+
}
|
|
28
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await reader.cancel().catch(() => { });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function browserProxyRequest(frame, connection) {
|
|
35
|
+
if (!frame || typeof frame !== "object" || Array.isArray(frame))
|
|
36
|
+
return failure(null, null, "Invalid request.", -32600);
|
|
37
|
+
const message = frame;
|
|
38
|
+
const id = message.id ?? null;
|
|
39
|
+
if (message.jsonrpc !== "2.0" || typeof message.method !== "string" || (id !== null && typeof id !== "number" && typeof id !== "string"))
|
|
40
|
+
return failure(null, null, "Invalid request.", -32600);
|
|
41
|
+
if (!Object.hasOwn(message, "id"))
|
|
42
|
+
return undefined;
|
|
43
|
+
if (message.method === "initialize")
|
|
44
|
+
return {
|
|
45
|
+
jsonrpc: "2.0", id,
|
|
46
|
+
result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "openmausbot-browser", version: "1" } },
|
|
47
|
+
};
|
|
48
|
+
if (message.method === "ping")
|
|
49
|
+
return { jsonrpc: "2.0", id, result: {} };
|
|
50
|
+
if (message.method !== "tools/list" && message.method !== "tools/call")
|
|
51
|
+
return failure(id, message.method, "Method not found.", -32601);
|
|
52
|
+
try {
|
|
53
|
+
const url = new URL(connection.url);
|
|
54
|
+
if (!connection.token || url.protocol !== "http:" || !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
|
|
55
|
+
throw new Error("Browser connection is not configured. Start a new bot turn from OpenMausBot.");
|
|
56
|
+
}
|
|
57
|
+
const body = JSON.stringify({ method: message.method, params: message.params ?? {} });
|
|
58
|
+
if (Buffer.byteLength(body) > MAX_INPUT_BYTES)
|
|
59
|
+
throw new Error("Browser request exceeded the size limit.");
|
|
60
|
+
const response = await fetch(new URL("/api/internal/browser/mcp", url), {
|
|
61
|
+
method: "POST", redirect: "error",
|
|
62
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${connection.token}` },
|
|
63
|
+
body, signal: AbortSignal.timeout(130_000),
|
|
64
|
+
});
|
|
65
|
+
const payload = await boundedJson(response);
|
|
66
|
+
if (!response.ok || !payload || typeof payload !== "object" || !Object.hasOwn(payload, "result")) {
|
|
67
|
+
throw new Error(typeof payload?.error === "string" ? payload.error.slice(0, 2_000) : "Browser server is unavailable. Start a new bot turn or try again.");
|
|
68
|
+
}
|
|
69
|
+
return { jsonrpc: "2.0", id, result: payload.result };
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return failure(id, message.method, error instanceof Error && !/fetch failed|abort|timeout/i.test(error.message)
|
|
73
|
+
? error.message : "Browser connection was interrupted. Do not repeat the action until its state is checked.");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function run() {
|
|
77
|
+
const connection = { url: process.env.OMB_HARNESS_URL ?? "", token: process.env.OMB_BROWSER_TOKEN ?? "" };
|
|
78
|
+
let input = Buffer.alloc(0);
|
|
79
|
+
let pending = 0;
|
|
80
|
+
const output = (message) => {
|
|
81
|
+
if (message === undefined)
|
|
82
|
+
return;
|
|
83
|
+
const line = `${JSON.stringify(message)}\n`;
|
|
84
|
+
if (Buffer.byteLength(line) > MAX_OUTPUT_BYTES || process.stdout.writableLength > MAX_OUTPUT_BYTES * 2) {
|
|
85
|
+
process.stdin.destroy();
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
process.stdout.write(line);
|
|
90
|
+
};
|
|
91
|
+
process.stdout.on("error", () => { process.stdin.destroy(); process.exitCode = 1; });
|
|
92
|
+
process.stdin.on("data", (chunk) => {
|
|
93
|
+
input = Buffer.concat([input, chunk]);
|
|
94
|
+
let newline;
|
|
95
|
+
while ((newline = input.indexOf(10)) !== -1) {
|
|
96
|
+
if (newline > MAX_INPUT_BYTES) {
|
|
97
|
+
process.stdin.destroy();
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const line = input.subarray(0, newline).toString("utf8");
|
|
102
|
+
input = input.subarray(newline + 1);
|
|
103
|
+
if (!line.trim())
|
|
104
|
+
continue;
|
|
105
|
+
let frame;
|
|
106
|
+
try {
|
|
107
|
+
frame = JSON.parse(line);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
output(failure(null, null, "Parse error.", -32700));
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (pending >= MAX_PENDING) {
|
|
114
|
+
const request = frame;
|
|
115
|
+
if (request && Object.hasOwn(request, "id"))
|
|
116
|
+
output(failure(request.id ?? null, request.method, "Too many pending browser requests."));
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
pending++;
|
|
120
|
+
void browserProxyRequest(frame, connection).then(output).finally(() => { pending--; });
|
|
121
|
+
}
|
|
122
|
+
if (input.length > MAX_INPUT_BYTES) {
|
|
123
|
+
process.stdin.destroy();
|
|
124
|
+
process.exitCode = 1;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
129
|
+
run();
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { killCliTree, spawnCli } from "./procs.js";
|
|
2
|
+
export const BROWSER_CONTROL_REFUSAL = "Browser tools are paused while a person controls this browser. Wait for them to hand control back; do not try another browser or execution tool.";
|
|
3
|
+
const MAX_REQUEST_BYTES = 1_048_576;
|
|
4
|
+
const MAX_RESPONSE_BYTES = 16_777_216;
|
|
5
|
+
const HOST_ENV = ["HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PATH", "Path", "TMPDIR", "TMP", "TEMP", "SystemRoot", "WINDIR", "SYSTEMDRIVE", "COMSPEC", "PATHEXT", "LANG", "LC_ALL", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR"];
|
|
6
|
+
/** MCP, viewer commands, and cleanup must resolve the same HOME/socket paths.
|
|
7
|
+
* Inherit OS plumbing, never the harness's model-provider credentials. */
|
|
8
|
+
export function browserRuntimeEnv(overrides) {
|
|
9
|
+
const env = Object.fromEntries(HOST_ENV.flatMap((key) => process.env[key] === undefined ? [] : [[key, process.env[key]]]));
|
|
10
|
+
return { ...env, ...overrides };
|
|
11
|
+
}
|
|
12
|
+
class TransportError extends Error {
|
|
13
|
+
}
|
|
14
|
+
/** A server-owned JSONL client. Neither child stderr nor its environment is
|
|
15
|
+
* returned to the agent. Reuse the existing cross-platform spawn/kill rules. */
|
|
16
|
+
class BrowserClient {
|
|
17
|
+
child;
|
|
18
|
+
ready;
|
|
19
|
+
pending = new Map();
|
|
20
|
+
buffer = Buffer.alloc(0);
|
|
21
|
+
nextId = 1;
|
|
22
|
+
stopped = false;
|
|
23
|
+
idleTimer;
|
|
24
|
+
stoppedPromise;
|
|
25
|
+
requestTimeoutMs;
|
|
26
|
+
idleMs;
|
|
27
|
+
maxPending;
|
|
28
|
+
onClose;
|
|
29
|
+
constructor(spec, requestTimeoutMs, idleMs, maxPending, onClose) {
|
|
30
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
31
|
+
this.idleMs = idleMs;
|
|
32
|
+
this.maxPending = maxPending;
|
|
33
|
+
this.onClose = onClose;
|
|
34
|
+
this.child = spawnCli(spec.command, spec.args, {
|
|
35
|
+
env: browserRuntimeEnv(spec.env), stdio: ["pipe", "pipe", "pipe"], shell: false,
|
|
36
|
+
});
|
|
37
|
+
this.child.stderr.resume();
|
|
38
|
+
this.child.stdout.on("data", (chunk) => this.read(chunk));
|
|
39
|
+
this.child.stdin.on("error", () => { void this.stop(new TransportError("Browser connection closed.")); });
|
|
40
|
+
this.child.on("error", () => { void this.stop(new TransportError("Could not start the browser engine.")); });
|
|
41
|
+
this.child.on("close", () => { void this.stop(new TransportError("Browser connection closed.")); });
|
|
42
|
+
this.ready = this.rpc("initialize", {
|
|
43
|
+
protocolVersion: "2024-11-05", capabilities: {},
|
|
44
|
+
clientInfo: { name: "openmausbot-browser", version: "1" },
|
|
45
|
+
}).then((result) => {
|
|
46
|
+
if (!result || typeof result !== "object" || !("protocolVersion" in result)) {
|
|
47
|
+
throw new TransportError("Browser engine returned an invalid handshake.");
|
|
48
|
+
}
|
|
49
|
+
this.write({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
50
|
+
}).catch((error) => {
|
|
51
|
+
void this.stop(error instanceof Error ? error : new TransportError("Browser handshake failed."));
|
|
52
|
+
throw error;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
read(chunk) {
|
|
56
|
+
if (this.stopped)
|
|
57
|
+
return;
|
|
58
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
59
|
+
let newline;
|
|
60
|
+
while ((newline = this.buffer.indexOf(10)) !== -1) {
|
|
61
|
+
if (newline > MAX_RESPONSE_BYTES) {
|
|
62
|
+
void this.stop(new TransportError("Browser response exceeded the size limit."));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const line = this.buffer.subarray(0, newline).toString("utf8");
|
|
66
|
+
this.buffer = this.buffer.subarray(newline + 1);
|
|
67
|
+
if (!line.trim())
|
|
68
|
+
continue;
|
|
69
|
+
let message;
|
|
70
|
+
try {
|
|
71
|
+
message = JSON.parse(line);
|
|
72
|
+
if (!message || typeof message !== "object" || Array.isArray(message))
|
|
73
|
+
throw new Error();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
void this.stop(new TransportError("Browser engine returned invalid JSON."));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const pending = typeof message.id === "number" ? this.pending.get(message.id) : undefined;
|
|
80
|
+
if (!pending)
|
|
81
|
+
continue; // MCP notifications do not contain tool results.
|
|
82
|
+
this.pending.delete(message.id);
|
|
83
|
+
clearTimeout(pending.timer);
|
|
84
|
+
if (message.error)
|
|
85
|
+
pending.reject(new Error(message.error.message || "Browser request failed."));
|
|
86
|
+
else if (Object.hasOwn(message, "result"))
|
|
87
|
+
pending.resolve(message.result);
|
|
88
|
+
else
|
|
89
|
+
pending.reject(new TransportError("Browser response has no result."));
|
|
90
|
+
this.armIdle();
|
|
91
|
+
}
|
|
92
|
+
if (this.buffer.length > MAX_RESPONSE_BYTES) {
|
|
93
|
+
void this.stop(new TransportError("Browser response exceeded the size limit."));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
write(message) {
|
|
97
|
+
if (this.stopped)
|
|
98
|
+
throw new TransportError("Browser connection closed.");
|
|
99
|
+
this.child.stdin.write(`${JSON.stringify(message)}\n`, (error) => {
|
|
100
|
+
if (error)
|
|
101
|
+
void this.stop(new TransportError("Browser connection closed."));
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
rpc(method, params) {
|
|
105
|
+
if (this.stopped)
|
|
106
|
+
return Promise.reject(new TransportError("Browser connection closed."));
|
|
107
|
+
if (this.pending.size >= this.maxPending)
|
|
108
|
+
return Promise.reject(new Error("Too many pending browser requests. Try again when the current action finishes."));
|
|
109
|
+
const id = this.nextId++;
|
|
110
|
+
const message = { jsonrpc: "2.0", id, method, params };
|
|
111
|
+
if (Buffer.byteLength(JSON.stringify(message)) > MAX_REQUEST_BYTES)
|
|
112
|
+
return Promise.reject(new Error("Browser request exceeded the size limit."));
|
|
113
|
+
if (this.idleTimer)
|
|
114
|
+
clearTimeout(this.idleTimer);
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const timer = setTimeout(() => { void this.stop(new TransportError("Browser request timed out; restart the browser before taking control.")); }, this.requestTimeoutMs);
|
|
117
|
+
timer.unref();
|
|
118
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
119
|
+
try {
|
|
120
|
+
this.write(message);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
void this.stop(new TransportError("Browser connection closed."));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
armIdle() {
|
|
128
|
+
if (this.idleTimer)
|
|
129
|
+
clearTimeout(this.idleTimer);
|
|
130
|
+
if (!this.stopped && this.pending.size === 0) {
|
|
131
|
+
// Only the stateless MCP transport expires; saved browser state and the
|
|
132
|
+
// browser daemon itself belong to the profile, not this client.
|
|
133
|
+
this.idleTimer = setTimeout(() => { void this.stop(); }, this.idleMs);
|
|
134
|
+
this.idleTimer.unref();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
stop(error = new TransportError("Browser connection closed.")) {
|
|
138
|
+
if (this.stoppedPromise)
|
|
139
|
+
return this.stoppedPromise;
|
|
140
|
+
this.stopped = true;
|
|
141
|
+
if (this.idleTimer)
|
|
142
|
+
clearTimeout(this.idleTimer);
|
|
143
|
+
this.buffer = Buffer.alloc(0);
|
|
144
|
+
for (const pending of this.pending.values()) {
|
|
145
|
+
clearTimeout(pending.timer);
|
|
146
|
+
pending.reject(error);
|
|
147
|
+
}
|
|
148
|
+
this.pending.clear();
|
|
149
|
+
this.onClose();
|
|
150
|
+
this.stoppedPromise = killCliTree(this.child, 1_000).then((stopped) => {
|
|
151
|
+
if (stopped)
|
|
152
|
+
return;
|
|
153
|
+
try {
|
|
154
|
+
if (process.platform !== "win32" && this.child.pid)
|
|
155
|
+
process.kill(-this.child.pid, "SIGKILL");
|
|
156
|
+
else
|
|
157
|
+
this.child.kill("SIGKILL");
|
|
158
|
+
}
|
|
159
|
+
catch { /* owned process already exited */ }
|
|
160
|
+
});
|
|
161
|
+
return this.stoppedPromise;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export class BrowserRuntime {
|
|
165
|
+
gates = new Map();
|
|
166
|
+
clients = new Map();
|
|
167
|
+
options;
|
|
168
|
+
constructor(options = {}) {
|
|
169
|
+
this.options = { requestTimeoutMs: 120_000, takeoverTimeoutMs: 15_000, idleMs: 60_000, maxPending: 16, ...options };
|
|
170
|
+
}
|
|
171
|
+
gate(session) {
|
|
172
|
+
let gate = this.gates.get(session);
|
|
173
|
+
if (!gate) {
|
|
174
|
+
gate = { owner: null, ready: false, releasing: false, agents: 0, humans: 0, uncertain: false, closing: false, changed: new Set() };
|
|
175
|
+
this.gates.set(session, gate);
|
|
176
|
+
}
|
|
177
|
+
return gate;
|
|
178
|
+
}
|
|
179
|
+
changed(gate) {
|
|
180
|
+
if (gate.releasing && gate.humans === 0) {
|
|
181
|
+
gate.owner = null;
|
|
182
|
+
gate.releasing = false;
|
|
183
|
+
gate.ready = false;
|
|
184
|
+
}
|
|
185
|
+
for (const notify of gate.changed)
|
|
186
|
+
notify();
|
|
187
|
+
}
|
|
188
|
+
async withAgentAction(session, fn) {
|
|
189
|
+
const gate = this.gate(session);
|
|
190
|
+
if (gate.owner !== null)
|
|
191
|
+
throw new Error(BROWSER_CONTROL_REFUSAL);
|
|
192
|
+
if (gate.uncertain)
|
|
193
|
+
throw new Error("A browser action was interrupted. Restart this browser before continuing.");
|
|
194
|
+
if (gate.closing)
|
|
195
|
+
throw new Error("The browser is closing. Try again shortly.");
|
|
196
|
+
gate.agents++;
|
|
197
|
+
try {
|
|
198
|
+
const result = await fn();
|
|
199
|
+
// Discard observations completed after takeover was requested.
|
|
200
|
+
if (gate.owner !== null)
|
|
201
|
+
throw new Error(BROWSER_CONTROL_REFUSAL);
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
gate.agents--;
|
|
206
|
+
this.changed(gate);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async agentRpc(session, spec, method, params, beforeDispatch) {
|
|
210
|
+
if (method !== "tools/list" && method !== "tools/call")
|
|
211
|
+
throw new Error("Unsupported browser method.");
|
|
212
|
+
const invoke = async () => {
|
|
213
|
+
const key = JSON.stringify([spec.command, spec.args, Object.entries(spec.env).sort(([a], [b]) => a.localeCompare(b))]);
|
|
214
|
+
let entry = this.clients.get(session);
|
|
215
|
+
if (entry && entry.key !== key)
|
|
216
|
+
throw new Error("Browser launch settings changed. Close the browser before reconnecting.");
|
|
217
|
+
if (!entry) {
|
|
218
|
+
const client = new BrowserClient(spec, this.options.requestTimeoutMs, this.options.idleMs, this.options.maxPending, () => {
|
|
219
|
+
if (this.clients.get(session)?.client === client)
|
|
220
|
+
this.clients.delete(session);
|
|
221
|
+
});
|
|
222
|
+
entry = { key, client };
|
|
223
|
+
this.clients.set(session, entry);
|
|
224
|
+
}
|
|
225
|
+
await entry.client.ready;
|
|
226
|
+
beforeDispatch?.();
|
|
227
|
+
if (method === "tools/call" && this.gate(session).owner !== null)
|
|
228
|
+
throw new Error(BROWSER_CONTROL_REFUSAL);
|
|
229
|
+
try {
|
|
230
|
+
const result = await entry.client.rpc(method, params);
|
|
231
|
+
beforeDispatch?.(); // A turn revoked while the tool ran receives no result.
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
// An MCP timeout cannot prove the independent daemon stopped an
|
|
236
|
+
// accepted action. Recovery must close the browser, not just its pipe.
|
|
237
|
+
if (method === "tools/call" && error instanceof TransportError)
|
|
238
|
+
this.gate(session).uncertain = true;
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
return method === "tools/call" ? this.withAgentAction(session, invoke) : invoke();
|
|
243
|
+
}
|
|
244
|
+
async take(session, owner) {
|
|
245
|
+
if (!owner)
|
|
246
|
+
throw new Error("Browser control requires an owner.");
|
|
247
|
+
const gate = this.gate(session);
|
|
248
|
+
if (gate.closing || gate.releasing)
|
|
249
|
+
throw new Error("Browser control is changing. Try again shortly.");
|
|
250
|
+
if (gate.owner !== null && gate.owner !== owner)
|
|
251
|
+
throw new Error("Another person controls this browser.");
|
|
252
|
+
gate.owner = owner; // synchronous: no new agent work slips in while draining.
|
|
253
|
+
gate.ready = false;
|
|
254
|
+
await new Promise((resolve, reject) => {
|
|
255
|
+
const finish = (error) => {
|
|
256
|
+
clearTimeout(timer);
|
|
257
|
+
gate.changed.delete(check);
|
|
258
|
+
if (error)
|
|
259
|
+
reject(error);
|
|
260
|
+
else {
|
|
261
|
+
gate.ready = true;
|
|
262
|
+
resolve();
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
const check = () => {
|
|
266
|
+
if (gate.owner !== owner || gate.releasing || gate.closing)
|
|
267
|
+
finish(new Error("Browser control request was cancelled."));
|
|
268
|
+
else if (gate.uncertain)
|
|
269
|
+
finish(new Error("A browser action may still be running. Restart this browser before taking control."));
|
|
270
|
+
else if (gate.agents === 0)
|
|
271
|
+
finish();
|
|
272
|
+
};
|
|
273
|
+
const timer = setTimeout(() => finish(new Error("Browser action is still finishing. Control remains paused; retry taking control or hand it back.")), this.options.takeoverTimeoutMs);
|
|
274
|
+
timer.unref();
|
|
275
|
+
gate.changed.add(check);
|
|
276
|
+
check();
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
canControl(session, owner) {
|
|
280
|
+
const gate = this.gates.get(session);
|
|
281
|
+
return Boolean(owner && gate?.owner === owner && gate.ready && !gate.releasing && !gate.closing && !gate.uncertain && gate.agents === 0);
|
|
282
|
+
}
|
|
283
|
+
heldBy(session) { return this.gates.get(session)?.owner ?? null; }
|
|
284
|
+
/** A disconnected viewer may leave a physical key/button pressed. Never
|
|
285
|
+
* let an agent inherit that input state; explicit restart clears it. */
|
|
286
|
+
abandonHumanInput(session, owner) {
|
|
287
|
+
const gate = this.gates.get(session);
|
|
288
|
+
if (!owner || !gate || gate.owner !== owner)
|
|
289
|
+
return;
|
|
290
|
+
gate.uncertain = true;
|
|
291
|
+
gate.ready = false;
|
|
292
|
+
this.changed(gate);
|
|
293
|
+
}
|
|
294
|
+
release(session, owner) {
|
|
295
|
+
const gate = this.gates.get(session);
|
|
296
|
+
if (!owner || !gate || gate.owner !== owner)
|
|
297
|
+
return;
|
|
298
|
+
gate.ready = false;
|
|
299
|
+
gate.releasing = true;
|
|
300
|
+
this.changed(gate); // don't admit agents until pending human input drains.
|
|
301
|
+
}
|
|
302
|
+
async withHumanAction(session, owner, fn) {
|
|
303
|
+
if (!this.canControl(session, owner))
|
|
304
|
+
throw new Error("Take control of this browser before interacting.");
|
|
305
|
+
const gate = this.gate(session);
|
|
306
|
+
gate.humans++;
|
|
307
|
+
try {
|
|
308
|
+
return await fn();
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
// Validation happens before entry. A failed accepted command might still
|
|
312
|
+
// be executing in the daemon; hand-back must not race its completion.
|
|
313
|
+
gate.uncertain = true;
|
|
314
|
+
gate.ready = false;
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
gate.humans--;
|
|
319
|
+
this.changed(gate);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/** Exclusive recovery. Unlike take(), this never waits through active work
|
|
323
|
+
* or admits human input; a successful native close is its safety barrier. */
|
|
324
|
+
async restart(session, owner, closeBrowser) {
|
|
325
|
+
if (!owner)
|
|
326
|
+
throw new Error("Browser recovery requires an owner.");
|
|
327
|
+
const gate = this.gate(session);
|
|
328
|
+
if (gate.closing || gate.releasing || gate.agents || gate.humans)
|
|
329
|
+
throw new Error("The browser is busy. Wait for current work to finish before restarting.");
|
|
330
|
+
if (gate.owner !== null && gate.owner !== owner)
|
|
331
|
+
throw new Error("Another person controls this browser.");
|
|
332
|
+
gate.owner = owner;
|
|
333
|
+
gate.ready = false;
|
|
334
|
+
gate.closing = true;
|
|
335
|
+
this.changed(gate);
|
|
336
|
+
try {
|
|
337
|
+
await closeBrowser();
|
|
338
|
+
await this.clients.get(session)?.client.stop();
|
|
339
|
+
gate.uncertain = false;
|
|
340
|
+
gate.owner = null;
|
|
341
|
+
gate.releasing = false;
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
gate.owner = owner;
|
|
345
|
+
gate.uncertain = true;
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
gate.closing = false;
|
|
350
|
+
this.changed(gate);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** Call after the underlying browser is closed when recovering an uncertain
|
|
354
|
+
* action. This closes the MCP transport, not saved logins or profile files. */
|
|
355
|
+
async close(session) {
|
|
356
|
+
const gate = this.gate(session);
|
|
357
|
+
gate.closing = true;
|
|
358
|
+
gate.ready = false;
|
|
359
|
+
this.changed(gate);
|
|
360
|
+
await this.clients.get(session)?.client.stop();
|
|
361
|
+
gate.closing = false;
|
|
362
|
+
gate.uncertain = false;
|
|
363
|
+
this.changed(gate);
|
|
364
|
+
if (!gate.owner && !gate.agents && !gate.humans)
|
|
365
|
+
this.gates.delete(session);
|
|
366
|
+
}
|
|
367
|
+
async closeAll() {
|
|
368
|
+
await Promise.all([...new Set([...this.clients.keys(), ...this.gates.keys()])].map((session) => this.close(session)));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Named accounts reuse provider instances. Claude owns login and credentials;
|
|
2
|
+
// removing an OpenMausBot entry never removes its directory or signs it out.
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { DATA_DIR, persistableInstanceConfigs } from "./config.js";
|
|
8
|
+
import { CLAUDE_ACCOUNT_ENV_KEYS, resolveClaudeConfigDir } from "./drivers/claude.js";
|
|
9
|
+
import { resolveCli } from "./procs.js";
|
|
10
|
+
const displayName = z.string().trim().min(1).max(80).refine((value) => !/\p{Cc}/u.test(value), "Name cannot contain control characters");
|
|
11
|
+
const configDir = z.string().trim().max(4096).refine((value) => !/\p{Cc}/u.test(value), "Directory cannot contain control characters");
|
|
12
|
+
export const createClaudeAccountSchema = z.object({ displayName, configDir: configDir.optional() }).strict();
|
|
13
|
+
export const instanceSettingsSchema = z.object({
|
|
14
|
+
cli: z.string().max(4096).refine((value) => !/\p{Cc}/u.test(value), "CLI cannot contain control characters").optional(),
|
|
15
|
+
displayName: displayName.optional(),
|
|
16
|
+
configDir: configDir.optional(),
|
|
17
|
+
}).strict().refine((value) => Object.keys(value).length > 0, "No settings supplied");
|
|
18
|
+
function rawConfig(entry) {
|
|
19
|
+
return entry.config && typeof entry.config === "object" && !Array.isArray(entry.config)
|
|
20
|
+
? entry.config : {};
|
|
21
|
+
}
|
|
22
|
+
export function accountDirectory(entry) {
|
|
23
|
+
const raw = rawConfig(entry).configDir;
|
|
24
|
+
try {
|
|
25
|
+
if (raw !== undefined && typeof raw !== "string")
|
|
26
|
+
throw new Error("Invalid saved directory");
|
|
27
|
+
return resolveClaudeConfigDir(raw, { ...process.env, ...entry.environment });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw Object.assign(new Error("Use an absolute Claude configuration directory or a path beginning with ~/."), { status: 400 });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function configuredAccountDirectory(entry) {
|
|
34
|
+
const explicit = rawConfig(entry).configDir;
|
|
35
|
+
if (explicit !== undefined && typeof explicit !== "string")
|
|
36
|
+
return accountDirectory(entry);
|
|
37
|
+
return (typeof explicit === "string" && explicit.trim()) || entry.environment?.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR
|
|
38
|
+
? accountDirectory(entry) : "";
|
|
39
|
+
}
|
|
40
|
+
function directoryIdentity(path) {
|
|
41
|
+
let canonical = path;
|
|
42
|
+
try {
|
|
43
|
+
canonical = realpathSync.native(path);
|
|
44
|
+
}
|
|
45
|
+
catch { /* Login may create it later. */ }
|
|
46
|
+
return process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
47
|
+
}
|
|
48
|
+
export function assertSeparateClaudeAccount(map, id, entry) {
|
|
49
|
+
const directory = directoryIdentity(accountDirectory(entry));
|
|
50
|
+
if (Object.entries(map).some(([otherId, other]) => {
|
|
51
|
+
if (otherId === id || other.driver !== "claudeAgent")
|
|
52
|
+
return false;
|
|
53
|
+
// A malformed saved account must not block adding or repairing another one.
|
|
54
|
+
try {
|
|
55
|
+
return directoryIdentity(accountDirectory(other)) === directory;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
})) {
|
|
61
|
+
throw Object.assign(new Error("That Claude directory is already configured. Select the existing account or use a separate directory."), { status: 409 });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function newClaudeAccount(cfg, body) {
|
|
65
|
+
const instances = persistableInstanceConfigs(cfg);
|
|
66
|
+
const instanceId = `claude-${randomUUID()}`;
|
|
67
|
+
// Reuse the installed binary, not the original account's environment/auth.
|
|
68
|
+
const existing = Object.values(instances).find((entry) => entry.driver === "claudeAgent");
|
|
69
|
+
const cli = existing ? rawConfig(existing).cli : undefined;
|
|
70
|
+
const entry = {
|
|
71
|
+
driver: "claudeAgent",
|
|
72
|
+
displayName: body.displayName,
|
|
73
|
+
config: {
|
|
74
|
+
...(typeof cli === "string" && cli ? { cli } : {}),
|
|
75
|
+
configDir: body.configDir || join(DATA_DIR, "providers", instanceId),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
entry.config = { ...entry.config, configDir: accountDirectory(entry) };
|
|
79
|
+
assertSeparateClaudeAccount(instances, instanceId, entry);
|
|
80
|
+
instances[instanceId] = entry;
|
|
81
|
+
return { instanceId, instances };
|
|
82
|
+
}
|
|
83
|
+
/** Copyable commands are quoted as data, including wrapper arguments. The
|
|
84
|
+
* subshell/block restores the caller's environment after Claude finishes. */
|
|
85
|
+
export function claudeSignInCommand(cli, directory, platform = process.platform) {
|
|
86
|
+
const resolved = resolveCli(cli, ["auth", "login"]);
|
|
87
|
+
if (platform === "win32") {
|
|
88
|
+
const quote = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
89
|
+
const keys = [...CLAUDE_ACCOUNT_ENV_KEYS, "CLAUDE_CONFIG_DIR"];
|
|
90
|
+
return `& { $ombKeys = ${keys.map(quote).join(",")}; $ombSaved = @{}; foreach ($k in $ombKeys) { $ombSaved[$k] = [Environment]::GetEnvironmentVariable($k, 'Process'); [Environment]::SetEnvironmentVariable($k, $null, 'Process') }; try { ${directory ? `$env:CLAUDE_CONFIG_DIR = ${quote(directory)}; ` : ""}& ${[resolved.command, ...resolved.args].map(quote).join(" ")} } finally { foreach ($k in $ombKeys) { [Environment]::SetEnvironmentVariable($k, $ombSaved[$k], 'Process') } } }`;
|
|
91
|
+
}
|
|
92
|
+
const quote = (value) => `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
93
|
+
return `(unset ${[...CLAUDE_ACCOUNT_ENV_KEYS, "CLAUDE_CONFIG_DIR"].join(" ")}; ${directory ? `export CLAUDE_CONFIG_DIR=${quote(directory)}; ` : ""}${[resolved.command, ...resolved.args].map(quote).join(" ")})`;
|
|
94
|
+
}
|
|
95
|
+
export function claudeAccountInfo(instanceId, entry, cli) {
|
|
96
|
+
const directory = configuredAccountDirectory(entry);
|
|
97
|
+
return {
|
|
98
|
+
configDir: directory,
|
|
99
|
+
signInCommand: claudeSignInCommand(cli, directory),
|
|
100
|
+
signInShell: process.platform === "win32" ? "powershell" : "sh",
|
|
101
|
+
isDefault: instanceId === "claude",
|
|
102
|
+
};
|
|
103
|
+
}
|