openmausbot 0.1.78 → 0.1.79
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-BqDJqf2C.js → index-C2Je5jdF.js} +1 -1
- package/dist/assets/index-D_TFZLlo.js +305 -0
- package/dist/index.html +1 -1
- package/dist-server/container-mcp.js +11 -5
- package/dist-server/enterprise/server/index.js +0 -1
- package/dist-server/index.js +15451 -6812
- package/dist-server/local-computer.js +0 -1
- package/dist-server/mcp-server.js +8 -1
- package/dist-server/openmausbot.js +9190 -758
- package/dist-server/pair-cli.js +9190 -758
- package/dist-server/proxy-paths.js +0 -1
- package/dist-server/server/box.js +98 -41
- package/dist-server/server/browser-engine.js +1 -1
- package/dist-server/server/browser-runtime.js +11 -3
- package/dist-server/server/browser-tool-shape.js +72 -0
- package/dist-server/server/claude-accounts.js +1 -0
- package/dist-server/server/cli-setup.js +1 -1
- package/dist-server/server/composio.js +18 -0
- package/dist-server/server/config.js +16 -4
- package/dist-server/server/container-computer.js +0 -12
- package/dist-server/server/drivers/acp/core.js +4 -19
- package/dist-server/server/drivers/boxagent.js +48 -4
- package/dist-server/server/drivers/chat-mcp-tools.js +368 -0
- package/dist-server/server/drivers/chat-tool-approval.js +42 -0
- package/dist-server/server/drivers/claude.js +87 -32
- package/dist-server/server/drivers/codex.js +69 -31
- package/dist-server/server/drivers/grok.js +4 -0
- package/dist-server/server/drivers/minimax.js +11 -3
- package/dist-server/server/drivers/openai-chat-protocol.js +92 -0
- package/dist-server/server/drivers/openai-chat.js +332 -88
- package/dist-server/server/drivers/openai-compat.js +4 -0
- package/dist-server/server/drivers/pi.js +1 -9
- package/dist-server/server/index.js +64 -38
- package/dist-server/server/proxy-paths.js +0 -1
- package/dist-server/server/room-handoffs.js +47 -9
- package/dist-server/server/tts/chatterbox.js +83 -0
- package/dist-server/server/tts/index.js +45 -12
- package/dist-server/server/workspace.js +12 -2
- package/dist-server/shared/ask-question.js +28 -0
- package/dist-server/shared/computer-contention.js +17 -0
- package/dist-server/vps-container-mcp.js +11 -5
- package/enterprise/server/index.js +0 -1
- package/package.json +1 -1
- package/dist/assets/index-DjwAroIZ.js +0 -305
- package/dist-server/computer-proxy.js +0 -1196
- package/dist-server/server/computer-proxy.js +0 -1070
- package/dist-server/server/remote-computer.js +0 -170
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// Per-turn MCP transport for the shared Chat Completions runtime. Approval is
|
|
2
|
+
// owned by the caller; only registered, schema-validated calls reach this file.
|
|
3
|
+
import { Ajv } from "ajv";
|
|
4
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
5
|
+
import formats from "ajv-formats";
|
|
6
|
+
import { killCliTree, spawnCli } from "../procs.js";
|
|
7
|
+
/** The transport cannot safely continue this turn. A dispatched operation may
|
|
8
|
+
* already have taken effect, so callers must not retry it through a new round. */
|
|
9
|
+
export class ChatToolSessionError extends Error {
|
|
10
|
+
}
|
|
11
|
+
const STARTUP_MS = 8_000;
|
|
12
|
+
const CALL_MS = 10 * 60_000;
|
|
13
|
+
const FRAME_BYTES = 2 * 1024 * 1024;
|
|
14
|
+
const OUTPUT_BYTES = 50 * 1024;
|
|
15
|
+
const TOOL_COUNT = 128;
|
|
16
|
+
const MAX_PAGES = 100;
|
|
17
|
+
const SCHEMA_BYTES = 64 * 1024;
|
|
18
|
+
const CATALOG_BYTES = 1024 * 1024;
|
|
19
|
+
const MAX_FRAMES = 10_000;
|
|
20
|
+
function object(value) {
|
|
21
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
function aborted() { return new Error("MCP operation cancelled"); }
|
|
24
|
+
class ChatMcpClient {
|
|
25
|
+
child;
|
|
26
|
+
buffer = "";
|
|
27
|
+
nextId = 1;
|
|
28
|
+
frames = 0;
|
|
29
|
+
closed = false;
|
|
30
|
+
closing;
|
|
31
|
+
pending = new Map();
|
|
32
|
+
constructor(server) {
|
|
33
|
+
try {
|
|
34
|
+
this.child = spawnCli(server.command, server.args, {
|
|
35
|
+
stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...server.env },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error("MCP server could not start; check its command and installation");
|
|
40
|
+
}
|
|
41
|
+
this.child.stdout.setEncoding("utf8");
|
|
42
|
+
this.child.stdout.on("data", (chunk) => this.onData(chunk));
|
|
43
|
+
// Stderr may contain credentials; drain without recording or returning it.
|
|
44
|
+
this.child.stderr.on("data", () => { });
|
|
45
|
+
this.child.on("error", () => this.fail(new Error("MCP server could not start; check its command and installation")));
|
|
46
|
+
this.child.on("exit", () => this.fail(new Error("MCP server exited before the session closed")));
|
|
47
|
+
this.child.stdin.on("error", () => this.fail(new Error("MCP server input closed")));
|
|
48
|
+
}
|
|
49
|
+
fail(error) {
|
|
50
|
+
if (this.closed)
|
|
51
|
+
return;
|
|
52
|
+
this.closed = true;
|
|
53
|
+
for (const entry of this.pending.values())
|
|
54
|
+
entry.reject(error);
|
|
55
|
+
this.pending.clear();
|
|
56
|
+
// Capture rejection here; mount/execute finally await close and surface it.
|
|
57
|
+
void this.close().catch(() => { });
|
|
58
|
+
}
|
|
59
|
+
close() {
|
|
60
|
+
if (this.closing)
|
|
61
|
+
return this.closing;
|
|
62
|
+
this.closed = true;
|
|
63
|
+
for (const entry of this.pending.values())
|
|
64
|
+
entry.reject(new Error("MCP session closed"));
|
|
65
|
+
this.pending.clear();
|
|
66
|
+
this.buffer = "";
|
|
67
|
+
this.closing = killCliTree(this.child, 500).then((stopped) => {
|
|
68
|
+
if (!stopped)
|
|
69
|
+
throw new Error("MCP server shutdown could not be confirmed; execution outcome may be uncertain");
|
|
70
|
+
});
|
|
71
|
+
return this.closing;
|
|
72
|
+
}
|
|
73
|
+
write(frame) {
|
|
74
|
+
if (this.closed || !this.child.stdin.writable || this.child.stdin.destroyed)
|
|
75
|
+
throw new Error("MCP session closed");
|
|
76
|
+
const encoded = JSON.stringify(frame) + "\n";
|
|
77
|
+
if (Buffer.byteLength(encoded) > FRAME_BYTES)
|
|
78
|
+
throw new Error("MCP request exceeds the frame limit");
|
|
79
|
+
this.child.stdin.write(encoded);
|
|
80
|
+
}
|
|
81
|
+
onData(chunk) {
|
|
82
|
+
if (this.closed)
|
|
83
|
+
return;
|
|
84
|
+
this.buffer += chunk;
|
|
85
|
+
let newline;
|
|
86
|
+
while ((newline = this.buffer.indexOf("\n")) !== -1) {
|
|
87
|
+
const line = this.buffer.slice(0, newline);
|
|
88
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
89
|
+
if (++this.frames > MAX_FRAMES)
|
|
90
|
+
return this.fail(new Error("MCP session exceeded the response frame count limit"));
|
|
91
|
+
if (Buffer.byteLength(line) > FRAME_BYTES)
|
|
92
|
+
return this.fail(new Error("MCP response exceeds the frame limit"));
|
|
93
|
+
if (!line.trim())
|
|
94
|
+
continue;
|
|
95
|
+
let message;
|
|
96
|
+
try {
|
|
97
|
+
message = JSON.parse(line);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return this.fail(new Error("MCP server returned invalid JSON"));
|
|
101
|
+
}
|
|
102
|
+
if (!object(message) || message.jsonrpc !== "2.0")
|
|
103
|
+
return this.fail(new Error("MCP server returned an invalid RPC envelope"));
|
|
104
|
+
if (typeof message.method === "string") {
|
|
105
|
+
if (typeof message.id === "number" || typeof message.id === "string") {
|
|
106
|
+
try {
|
|
107
|
+
this.write({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Client method not supported" } });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return this.fail(new Error("MCP server input closed"));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (typeof message.id !== "number")
|
|
116
|
+
return this.fail(new Error("MCP server returned an invalid response ID"));
|
|
117
|
+
const entry = this.pending.get(message.id);
|
|
118
|
+
if (!entry)
|
|
119
|
+
continue;
|
|
120
|
+
this.pending.delete(message.id);
|
|
121
|
+
if ("error" in message)
|
|
122
|
+
entry.reject(new Error("MCP request failed; inspect the configured integration"));
|
|
123
|
+
else if (!("result" in message))
|
|
124
|
+
entry.reject(new Error("MCP response has no result"));
|
|
125
|
+
else
|
|
126
|
+
entry.resolve(message.result);
|
|
127
|
+
}
|
|
128
|
+
if (Buffer.byteLength(this.buffer) > FRAME_BYTES)
|
|
129
|
+
this.fail(new Error("MCP response exceeds the frame limit"));
|
|
130
|
+
}
|
|
131
|
+
async call(method, params, signal, timeout) {
|
|
132
|
+
if (signal.aborted) {
|
|
133
|
+
await this.close();
|
|
134
|
+
throw aborted();
|
|
135
|
+
}
|
|
136
|
+
if (this.closed)
|
|
137
|
+
throw new Error("MCP session closed");
|
|
138
|
+
const id = this.nextId++;
|
|
139
|
+
try {
|
|
140
|
+
return await new Promise((resolve, reject) => {
|
|
141
|
+
const finish = (error, value) => {
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
signal.removeEventListener("abort", cancel);
|
|
144
|
+
this.pending.delete(id);
|
|
145
|
+
if (error)
|
|
146
|
+
reject(error);
|
|
147
|
+
else
|
|
148
|
+
resolve(value);
|
|
149
|
+
};
|
|
150
|
+
const cancel = () => {
|
|
151
|
+
try {
|
|
152
|
+
this.write({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: id, reason: "Cancelled" } });
|
|
153
|
+
}
|
|
154
|
+
catch { /* cleanup owns transport failure */ }
|
|
155
|
+
finish(aborted());
|
|
156
|
+
};
|
|
157
|
+
const timer = setTimeout(() => finish(new Error("MCP request timed out; execution outcome may be uncertain")), timeout);
|
|
158
|
+
timer.unref();
|
|
159
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
160
|
+
this.pending.set(id, { resolve: (value) => finish(undefined, value), reject: (error) => finish(error) });
|
|
161
|
+
try {
|
|
162
|
+
this.write({ jsonrpc: "2.0", id, method, params });
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
finish(new Error("MCP request could not be sent"));
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
await this.close();
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async tools(signal) {
|
|
175
|
+
const deadline = Date.now() + STARTUP_MS;
|
|
176
|
+
const remaining = () => {
|
|
177
|
+
if (Date.now() >= deadline)
|
|
178
|
+
throw new Error("MCP startup timed out");
|
|
179
|
+
return deadline - Date.now();
|
|
180
|
+
};
|
|
181
|
+
const initialized = await this.call("initialize", {
|
|
182
|
+
protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "openmausbot-chat", version: "1" },
|
|
183
|
+
}, signal, remaining());
|
|
184
|
+
if (!object(initialized))
|
|
185
|
+
throw new Error("MCP initialization returned an invalid result");
|
|
186
|
+
if (signal.aborted)
|
|
187
|
+
throw aborted();
|
|
188
|
+
this.write({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
189
|
+
const tools = [];
|
|
190
|
+
const cursors = new Set();
|
|
191
|
+
let cursor;
|
|
192
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
193
|
+
const result = await this.call("tools/list", cursor ? { cursor } : {}, signal, remaining());
|
|
194
|
+
if (!object(result) || !Array.isArray(result.tools))
|
|
195
|
+
throw new Error("MCP tools/list returned an invalid result");
|
|
196
|
+
tools.push(...result.tools);
|
|
197
|
+
if (tools.length > TOOL_COUNT)
|
|
198
|
+
throw new Error("MCP tool count exceeds the 128-tool limit");
|
|
199
|
+
if (result.nextCursor === undefined)
|
|
200
|
+
return tools;
|
|
201
|
+
if (typeof result.nextCursor !== "string" || !result.nextCursor || cursors.has(result.nextCursor))
|
|
202
|
+
throw new Error("MCP tools/list returned an invalid pagination cursor");
|
|
203
|
+
cursors.add(result.nextCursor);
|
|
204
|
+
cursor = result.nextCursor;
|
|
205
|
+
}
|
|
206
|
+
throw new Error("MCP tools/list exceeded the pagination limit");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// A schema is a contract, so conversion that drops constraints is not safe.
|
|
210
|
+
// Ajv validates the same schema sent to the provider without coercing values,
|
|
211
|
+
// applying defaults, removing fields, or fetching external references.
|
|
212
|
+
const validatorOptions = {
|
|
213
|
+
strict: true, allErrors: false, coerceTypes: false, useDefaults: false,
|
|
214
|
+
removeAdditional: false, validateFormats: true, ownProperties: true, logger: false,
|
|
215
|
+
// These are style diagnostics, not unsupported validation keywords. Valid
|
|
216
|
+
// schemas may require undeclared names, use untyped composition branches,
|
|
217
|
+
// or describe an open tuple. Ajv still enforces every constraint.
|
|
218
|
+
strictRequired: false, strictTypes: false, strictTuples: false,
|
|
219
|
+
};
|
|
220
|
+
function compileSchema(schema) {
|
|
221
|
+
const dialect = schema.$schema;
|
|
222
|
+
if (dialect !== undefined && dialect !== "http://json-schema.org/draft-07/schema#" && dialect !== "https://json-schema.org/draft/2020-12/schema") {
|
|
223
|
+
throw new Error("MCP tool schema uses an unsupported dialect; use JSON Schema draft-07 or 2020-12");
|
|
224
|
+
}
|
|
225
|
+
// One compiler per schema also prevents external IDs from resolving against
|
|
226
|
+
// unrelated tools or retaining schemas after the turn has closed.
|
|
227
|
+
const compiler = dialect === "https://json-schema.org/draft/2020-12/schema"
|
|
228
|
+
? new Ajv2020(validatorOptions) : new Ajv(validatorOptions);
|
|
229
|
+
// ajv-formats is CommonJS and exports the plugin as both module.exports
|
|
230
|
+
// and .default; the latter also matches its NodeNext declaration.
|
|
231
|
+
formats.default(compiler);
|
|
232
|
+
try {
|
|
233
|
+
return compiler.compile(schema);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
throw new Error("MCP tool schema could not be validated; check its constraints, formats, and references");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function boundedText(value) {
|
|
240
|
+
const bytes = Buffer.from(value);
|
|
241
|
+
if (bytes.length <= OUTPUT_BYTES)
|
|
242
|
+
return value;
|
|
243
|
+
let end = OUTPUT_BYTES;
|
|
244
|
+
while ((bytes[end] & 0xc0) === 0x80)
|
|
245
|
+
end -= 1;
|
|
246
|
+
return `${bytes.subarray(0, end).toString()}\n[MCP result truncated at 50KB; request less output.]`;
|
|
247
|
+
}
|
|
248
|
+
export async function mountChatTools(integrations, signal) {
|
|
249
|
+
const servers = [];
|
|
250
|
+
if (integrations?.agents)
|
|
251
|
+
servers.push(["agents", integrations.agents]);
|
|
252
|
+
if (integrations?.composio)
|
|
253
|
+
servers.push(["composio", integrations.composio]);
|
|
254
|
+
servers.push(...Object.entries(integrations?.custom ?? {}));
|
|
255
|
+
if (servers.length > 32)
|
|
256
|
+
throw new Error("MCP server count exceeds the 32-server limit");
|
|
257
|
+
const clients = [];
|
|
258
|
+
let closed = false;
|
|
259
|
+
let closing;
|
|
260
|
+
const close = () => {
|
|
261
|
+
if (closing)
|
|
262
|
+
return closing;
|
|
263
|
+
closed = true;
|
|
264
|
+
signal.removeEventListener("abort", cancel);
|
|
265
|
+
closing = Promise.allSettled(clients.map((client) => client.close())).then((results) => {
|
|
266
|
+
if (results.some((result) => result.status === "rejected"))
|
|
267
|
+
throw new ChatToolSessionError("MCP server shutdown could not be confirmed; execution outcome may be uncertain");
|
|
268
|
+
});
|
|
269
|
+
return closing;
|
|
270
|
+
};
|
|
271
|
+
const cancel = () => { void close().catch(() => { }); };
|
|
272
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
273
|
+
const definitions = [];
|
|
274
|
+
const registered = new Map();
|
|
275
|
+
try {
|
|
276
|
+
if (signal.aborted)
|
|
277
|
+
throw aborted();
|
|
278
|
+
// Start independent servers concurrently; consume results in config order
|
|
279
|
+
// so names and collision suffixes remain stable across startup timings.
|
|
280
|
+
const mounts = await Promise.allSettled(servers.map(async ([name, descriptor]) => {
|
|
281
|
+
if (signal.aborted || closed)
|
|
282
|
+
throw aborted();
|
|
283
|
+
const client = new ChatMcpClient(descriptor);
|
|
284
|
+
clients.push(client);
|
|
285
|
+
return { name, client, tools: await client.tools(signal) };
|
|
286
|
+
}));
|
|
287
|
+
for (const mount of mounts) {
|
|
288
|
+
if (mount.status === "rejected")
|
|
289
|
+
throw mount.reason;
|
|
290
|
+
const { name: server, client, tools } = mount.value;
|
|
291
|
+
const originalNames = new Set();
|
|
292
|
+
for (const tool of tools) {
|
|
293
|
+
if (!object(tool) || typeof tool.name !== "string" || !tool.name.trim() || originalNames.has(tool.name))
|
|
294
|
+
throw new Error("MCP server advertised an invalid or duplicate tool name");
|
|
295
|
+
originalNames.add(tool.name);
|
|
296
|
+
if (definitions.length >= TOOL_COUNT)
|
|
297
|
+
throw new Error("MCP tool count exceeds the 128-tool limit");
|
|
298
|
+
if (!object(tool.inputSchema) || tool.inputSchema.type !== "object")
|
|
299
|
+
throw new Error("MCP tools require an object input schema");
|
|
300
|
+
if (Buffer.byteLength(JSON.stringify(tool.inputSchema)) > SCHEMA_BYTES)
|
|
301
|
+
throw new Error("MCP tool schema exceeds the 64KB limit");
|
|
302
|
+
const schema = compileSchema(tool.inputSchema);
|
|
303
|
+
const base = `${server}_${tool.name}`.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64) || "mcp_tool";
|
|
304
|
+
let name = base;
|
|
305
|
+
for (let index = 2; registered.has(name); index += 1) {
|
|
306
|
+
const suffix = `_${index}`;
|
|
307
|
+
name = base.slice(0, 64 - suffix.length) + suffix;
|
|
308
|
+
}
|
|
309
|
+
registered.set(name, { client, name: tool.name, schema });
|
|
310
|
+
definitions.push({ type: "function", function: { name, description: typeof tool.description === "string" ? tool.description : "Configured MCP tool", parameters: tool.inputSchema } });
|
|
311
|
+
if (Buffer.byteLength(JSON.stringify(definitions)) > CATALOG_BYTES)
|
|
312
|
+
throw new Error("MCP tool catalog exceeds the 1MB limit");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (signal.aborted || closed)
|
|
316
|
+
throw aborted();
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
await close();
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
const validate = (name, args) => {
|
|
323
|
+
if (closed || signal.aborted)
|
|
324
|
+
throw new ChatToolSessionError("MCP session closed");
|
|
325
|
+
const tool = registered.get(name);
|
|
326
|
+
if (!tool)
|
|
327
|
+
throw new Error("The requested tool was not advertised for this turn");
|
|
328
|
+
if (!object(args) || !tool.schema(args))
|
|
329
|
+
throw new Error("Tool arguments do not match the advertised input schema; use its required fields and types");
|
|
330
|
+
};
|
|
331
|
+
return {
|
|
332
|
+
definitions, validate, close,
|
|
333
|
+
async execute(name, args, callSignal) {
|
|
334
|
+
validate(name, args);
|
|
335
|
+
if (callSignal.aborted) {
|
|
336
|
+
await close();
|
|
337
|
+
throw aborted();
|
|
338
|
+
}
|
|
339
|
+
const tool = registered.get(name);
|
|
340
|
+
try {
|
|
341
|
+
const result = await tool.client.call("tools/call", { name: tool.name, arguments: args }, AbortSignal.any([signal, callSignal]), CALL_MS);
|
|
342
|
+
if (signal.aborted || callSignal.aborted)
|
|
343
|
+
throw aborted();
|
|
344
|
+
if (!object(result) || !Array.isArray(result.content) || (result.isError !== undefined && typeof result.isError !== "boolean"))
|
|
345
|
+
throw new Error("MCP tool returned an invalid result; execution outcome may be uncertain");
|
|
346
|
+
const parts = [];
|
|
347
|
+
let unsupported = 0;
|
|
348
|
+
for (const item of result.content) {
|
|
349
|
+
if (!object(item) || typeof item.type !== "string" || (item.type === "text" && typeof item.text !== "string"))
|
|
350
|
+
throw new Error("MCP tool returned invalid content; execution outcome may be uncertain");
|
|
351
|
+
if (item.type === "text")
|
|
352
|
+
parts.push(item.text);
|
|
353
|
+
else
|
|
354
|
+
unsupported += 1;
|
|
355
|
+
}
|
|
356
|
+
if (result.structuredContent !== undefined)
|
|
357
|
+
parts.push(JSON.stringify(result.structuredContent));
|
|
358
|
+
if (unsupported)
|
|
359
|
+
parts.unshift(`[${unsupported} unsupported MCP content item(s) omitted. The operation may have taken effect, but its full result cannot be represented; inspect its state before retrying.]`);
|
|
360
|
+
return { text: boundedText(parts.join("\n") || "(empty result)"), ok: result.isError !== true && unsupported === 0 };
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
await close();
|
|
364
|
+
throw new ChatToolSessionError(error instanceof Error ? error.message : "MCP transport failed; execution outcome may be uncertain");
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Per-turn permission gate, adapted from the approval lifecycle in #780.
|
|
2
|
+
// Register before publishing: a harness listener may answer synchronously.
|
|
3
|
+
import { newId } from "../contracts.js";
|
|
4
|
+
export function createChatToolApproval(options) {
|
|
5
|
+
const pending = new Map();
|
|
6
|
+
let closed = false;
|
|
7
|
+
return {
|
|
8
|
+
ask(tool, summary) {
|
|
9
|
+
if (closed || options.signal.aborted)
|
|
10
|
+
return Promise.resolve(false);
|
|
11
|
+
const ask = { id: newId(), tool, summary };
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const finish = (allowed, source) => {
|
|
14
|
+
if (!pending.delete(ask.id))
|
|
15
|
+
return;
|
|
16
|
+
clearTimeout(timer);
|
|
17
|
+
options.signal.removeEventListener("abort", abort);
|
|
18
|
+
options.resolved(ask, allowed, source);
|
|
19
|
+
resolve(allowed);
|
|
20
|
+
};
|
|
21
|
+
const abort = () => finish(false, "system");
|
|
22
|
+
const timer = setTimeout(() => finish(false, "timeout"), options.timeoutMs ?? 15 * 60_000);
|
|
23
|
+
timer.unref?.();
|
|
24
|
+
pending.set(ask.id, finish);
|
|
25
|
+
options.signal.addEventListener("abort", abort, { once: true });
|
|
26
|
+
options.open(ask);
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
answer(id, behavior) {
|
|
30
|
+
const finish = pending.get(id);
|
|
31
|
+
if (!finish || options.signal.aborted || behavior === "answer")
|
|
32
|
+
return "unavailable";
|
|
33
|
+
finish(behavior === "allow", "user");
|
|
34
|
+
return behavior === "allow" ? "allowed-once" : "rejected";
|
|
35
|
+
},
|
|
36
|
+
close() {
|
|
37
|
+
closed = true;
|
|
38
|
+
for (const finish of pending.values())
|
|
39
|
+
finish(false, "system");
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -18,7 +18,6 @@ import { augmentedPath } from "../env-path.js";
|
|
|
18
18
|
import { brokerSocketPath, describeSpawnFailure, execCli, killCliTree, spawnCli } from "../procs.js";
|
|
19
19
|
import { classifyResumeFailure, mayReplay, recoveryPromptFor } from "../resume-recovery.js";
|
|
20
20
|
import { ClaudeLoginController } from "./claude-login-auth.js";
|
|
21
|
-
import { computerProxyEnv } from "../container-computer.js";
|
|
22
21
|
import { gateServer, resultBudget } from "../mcp-gate-config.js";
|
|
23
22
|
import { newEventId, newId } from "../contracts.js";
|
|
24
23
|
import { askInputSummary, commandSummary, toolDetailPreview } from "../tool-summary.js";
|
|
@@ -26,7 +25,7 @@ import { classifyError, computeBackoff, interruptibleDelay, RETRY_MAX_ATTEMPTS }
|
|
|
26
25
|
import { applyClaudeInject, decodeInjectId, mergeLocalInject, probeLocalInjects, resolveInjectId, } from "./local-inject.js";
|
|
27
26
|
import { appendNative } from "./native.js";
|
|
28
27
|
import { SPAWNED_PROXIES } from "../proxy-paths.js";
|
|
29
|
-
import { ASK_USER_QUESTION_TOOL, askQuestionSummary, parseAskQuestions, questionChoices, } from "../../shared/ask-question.js";
|
|
28
|
+
import { ASK_USER_QUESTION_TOOL, askQuestionSummary, parseAskQuestions, parseChoices, questionChoices, } from "../../shared/ask-question.js";
|
|
30
29
|
/** Whether `claude` has been signed in.
|
|
31
30
|
*
|
|
32
31
|
* Credential storage is deliberately not inspected here. Claude Code uses the
|
|
@@ -293,6 +292,30 @@ export function claudeCliUpdate(version, cli) {
|
|
|
293
292
|
command: cli === "claude" ? "claude update" : `${cli} update`,
|
|
294
293
|
};
|
|
295
294
|
}
|
|
295
|
+
/** Whether `claude --help` output lists `flag` as a supported option.
|
|
296
|
+
*
|
|
297
|
+
* A wrapper may print its own banner before the real options list, and the
|
|
298
|
+
* flag may appear only in an example or description rather than as an
|
|
299
|
+
* option. We scan for a line that begins with the flag (after optional
|
|
300
|
+
* leading whitespace), which is how the real CLI formats its `--help`.
|
|
301
|
+
*/
|
|
302
|
+
export function claudeCliHelpSupportsFlag(help, flag) {
|
|
303
|
+
if (!help)
|
|
304
|
+
return false;
|
|
305
|
+
const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
306
|
+
return new RegExp(`^\\s*${escaped}\\b`, "m").test(help);
|
|
307
|
+
}
|
|
308
|
+
/** Whether the installed CLI supports `--autocompact`.
|
|
309
|
+
*
|
|
310
|
+
* When `snapshot()` has already probed `claude --help`, use that. If the
|
|
311
|
+
* probe has not run or `--help` failed, fall back to the version floor so
|
|
312
|
+
* the pre-snapshot behavior is preserved.
|
|
313
|
+
*/
|
|
314
|
+
export function claudeAutoCompactSupported(version, help) {
|
|
315
|
+
if (help !== null)
|
|
316
|
+
return claudeCliHelpSupportsFlag(help, "--autocompact");
|
|
317
|
+
return claudeCliSupports(version, "--autocompact");
|
|
318
|
+
}
|
|
296
319
|
const DRIVER_KIND = "claudeAgent";
|
|
297
320
|
// model catalog ported from upstream packages/contracts/src/model.ts
|
|
298
321
|
export const STATIC_CLAUDE_MODELS = {
|
|
@@ -338,12 +361,15 @@ function extrasFromUnknown(value) {
|
|
|
338
361
|
* ANTHROPIC_API_KEY ("Not logged in · Please run /login"). Live injects
|
|
339
362
|
* come from mergeLocalInject. */
|
|
340
363
|
export function readClaudeModelCatalog(env = process.env) {
|
|
364
|
+
// A missing or unreadable settings.json is not fatal: an instance whose
|
|
365
|
+
// environment sets ANTHROPIC_MODEL (a Claude Code install pointed at an
|
|
366
|
+
// Anthropic-compatible host) still lists that model as Custom.
|
|
341
367
|
let settings = {};
|
|
342
368
|
try {
|
|
343
369
|
settings = JSON.parse(readFileSync(join(resolveClaudeConfigDir(undefined, env), "settings.json"), "utf8"));
|
|
344
370
|
}
|
|
345
371
|
catch {
|
|
346
|
-
|
|
372
|
+
settings = {};
|
|
347
373
|
}
|
|
348
374
|
const extras = [
|
|
349
375
|
...extrasFromUnknown(settings.availableModels),
|
|
@@ -367,7 +393,6 @@ export function readClaudeModelCatalog(env = process.env) {
|
|
|
367
393
|
// Resolved from the server root, never relative to this file: bundling inlines
|
|
368
394
|
// this module into an entry one directory up, so a `".."` here would climb too
|
|
369
395
|
// far. See server/proxy-paths.ts.
|
|
370
|
-
const PROXY_PATH = SPAWNED_PROXIES.computer;
|
|
371
396
|
const PERM_PROXY_PATH = SPAWNED_PROXIES.permission;
|
|
372
397
|
const DWEB_PROXY_PATH = SPAWNED_PROXIES.dweb;
|
|
373
398
|
// in the packaged app process.execPath is the Electron binary — this env
|
|
@@ -782,15 +807,16 @@ export const ClaudeDriver = {
|
|
|
782
807
|
}
|
|
783
808
|
};
|
|
784
809
|
await refreshModels();
|
|
785
|
-
// The installed CLI's version as snapshot() last read
|
|
786
|
-
// CLI does not know is never passed to it
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
//
|
|
792
|
-
// the older install, and the next snapshot corrects it.
|
|
810
|
+
// The installed CLI's version and `--help` output as snapshot() last read
|
|
811
|
+
// them, so a flag the CLI does not know is never passed to it. The harness
|
|
812
|
+
// snapshots every instance whenever it describes them — app load, the
|
|
813
|
+
// Engines page, and right after `claude update` — which is exactly when
|
|
814
|
+
// the answer changes, so a turn normally finds it filled. A turn before
|
|
815
|
+
// any snapshot falls back to the version floor for the flags that are not
|
|
816
|
+
// known yet.
|
|
793
817
|
let cliVersion = null;
|
|
818
|
+
let cliHelp = null;
|
|
819
|
+
let cliHelpVersion = null;
|
|
794
820
|
const listeners = new Set();
|
|
795
821
|
// one active turn per thread; a second send while busy is a caller bug
|
|
796
822
|
const active = new Map();
|
|
@@ -876,7 +902,11 @@ export const ClaudeDriver = {
|
|
|
876
902
|
const retryState = new Map();
|
|
877
903
|
const sendTurn = async (turn, logicalTurnId) => {
|
|
878
904
|
const { threadId, botId } = turn;
|
|
879
|
-
|
|
905
|
+
// An internal relaunch (transient failure, rejected resume) keeps the
|
|
906
|
+
// logical turn's stop handle in `active` while it sets up, so Stop is
|
|
907
|
+
// never a silent no-op between two CLI processes of the same turn.
|
|
908
|
+
const relaunch = logicalTurnId !== undefined;
|
|
909
|
+
if (active.has(threadId) && !relaunch)
|
|
880
910
|
throw new Error("a turn is already running on this thread");
|
|
881
911
|
// A bot-level mode is authoritative for this turn. In particular, an
|
|
882
912
|
// old provider instance may still be configured with
|
|
@@ -901,7 +931,10 @@ export const ClaudeDriver = {
|
|
|
901
931
|
const turnId = logicalTurnId ?? newId();
|
|
902
932
|
const retryAbort = new AbortController();
|
|
903
933
|
const retry = retryState.get(threadId) ?? { attempt: 0, cancelled: false };
|
|
904
|
-
|
|
934
|
+
// A fresh user turn starts un-cancelled. A relaunch must keep a Stop
|
|
935
|
+
// that landed while it was being scheduled.
|
|
936
|
+
if (!relaunch)
|
|
937
|
+
retry.cancelled = false;
|
|
905
938
|
retryState.set(threadId, retry);
|
|
906
939
|
// a retry relaunches the whole CLI; the backoff is scaled down in tests
|
|
907
940
|
// so a fake's transient failures don't stall real seconds
|
|
@@ -941,7 +974,7 @@ export const ClaudeDriver = {
|
|
|
941
974
|
args.push("--setting-sources", "project");
|
|
942
975
|
}
|
|
943
976
|
const compactWindow = autoCompactWindow(turnEnvironment);
|
|
944
|
-
if (compactWindow &&
|
|
977
|
+
if (compactWindow && claudeAutoCompactSupported(cliVersion, cliHelp)) {
|
|
945
978
|
args.push("--autocompact", compactWindow);
|
|
946
979
|
}
|
|
947
980
|
const turnModel = await resolveClaudeTurnModel(turn.model, turnEnvironment);
|
|
@@ -964,15 +997,7 @@ export const ClaudeDriver = {
|
|
|
964
997
|
mcpServers.composio = { ...turn.integrations.composio };
|
|
965
998
|
allowed.push("mcp__composio");
|
|
966
999
|
}
|
|
967
|
-
if (turn.integrations?.
|
|
968
|
-
mcpServers.computer = {
|
|
969
|
-
command: process.execPath,
|
|
970
|
-
args: [PROXY_PATH],
|
|
971
|
-
env: { ...NODE_ENV_FLAG, ...computerProxyEnv(turn.integrations.computer) },
|
|
972
|
-
};
|
|
973
|
-
allowed.push("mcp__computer");
|
|
974
|
-
}
|
|
975
|
-
else if (turn.integrations?.localComputer) {
|
|
1000
|
+
if (turn.integrations?.localComputer) {
|
|
976
1001
|
const local = turn.integrations.localComputer;
|
|
977
1002
|
mcpServers.computer = {
|
|
978
1003
|
command: local.command,
|
|
@@ -1216,9 +1241,7 @@ export const ClaudeDriver = {
|
|
|
1216
1241
|
questions: questions ?? undefined,
|
|
1217
1242
|
// A structured ask still offers flat labels, for the phone
|
|
1218
1243
|
// companions and any client that predates the question card.
|
|
1219
|
-
choices: questions
|
|
1220
|
-
? questionChoices(questions)
|
|
1221
|
-
: Array.isArray(ask.input?.choices) ? ask.input.choices.slice(0, 5) : undefined,
|
|
1244
|
+
choices: questions ? questionChoices(questions) : parseChoices(ask.input?.choices),
|
|
1222
1245
|
});
|
|
1223
1246
|
},
|
|
1224
1247
|
onResolve: (resolved) => {
|
|
@@ -1259,6 +1282,16 @@ export const ClaudeDriver = {
|
|
|
1259
1282
|
cleanupUnownedLaunch();
|
|
1260
1283
|
throw error;
|
|
1261
1284
|
}
|
|
1285
|
+
// Stop reached the relaunch handle while this attempt was still setting
|
|
1286
|
+
// up (model probe, broker). Settle the logical turn as interrupted
|
|
1287
|
+
// instead of spawning a process nobody wants.
|
|
1288
|
+
if (relaunch && retry.cancelled) {
|
|
1289
|
+
cleanupUnownedLaunch();
|
|
1290
|
+
if (active.get(threadId)?.turnId === turnId)
|
|
1291
|
+
active.delete(threadId);
|
|
1292
|
+
emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "interrupted", cost: null });
|
|
1293
|
+
return { turnId };
|
|
1294
|
+
}
|
|
1262
1295
|
let child;
|
|
1263
1296
|
try {
|
|
1264
1297
|
child = spawnCli(config.cli, args, {
|
|
@@ -1544,14 +1577,18 @@ export const ClaudeDriver = {
|
|
|
1544
1577
|
});
|
|
1545
1578
|
return;
|
|
1546
1579
|
}
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
|
|
1580
|
+
// Keep Stop reachable while the relaunch sets up: there is no
|
|
1581
|
+
// process yet, so this handle only records the cancellation and
|
|
1582
|
+
// the relaunched sendTurn honors it before spawning.
|
|
1583
|
+
retryState.set(threadId, retry);
|
|
1584
|
+
active.set(threadId, { stop: () => { retry.cancelled = true; retryAbort.abort(); }, turnId });
|
|
1550
1585
|
try {
|
|
1551
1586
|
const cursor = session.sessionId ?? sessionId ?? undefined;
|
|
1552
1587
|
await sendTurn({ ...turn, resumeCursor: cursor }, turnId);
|
|
1553
1588
|
}
|
|
1554
1589
|
catch (e) {
|
|
1590
|
+
if (active.get(threadId)?.turnId === turnId)
|
|
1591
|
+
active.delete(threadId);
|
|
1555
1592
|
retryState.delete(threadId);
|
|
1556
1593
|
emit({
|
|
1557
1594
|
...base(threadId, turnId),
|
|
@@ -1605,7 +1642,9 @@ export const ClaudeDriver = {
|
|
|
1605
1642
|
}
|
|
1606
1643
|
sessions.delete(threadId);
|
|
1607
1644
|
session.turn = null;
|
|
1608
|
-
|
|
1645
|
+
// Same relaunch handle as the transient-retry path above.
|
|
1646
|
+
retryState.set(threadId, retry);
|
|
1647
|
+
active.set(threadId, { stop: () => { retry.cancelled = true; retryAbort.abort(); }, turnId });
|
|
1609
1648
|
emit({
|
|
1610
1649
|
...base(threadId, turnId),
|
|
1611
1650
|
type: "turn.retrying",
|
|
@@ -1619,6 +1658,8 @@ export const ClaudeDriver = {
|
|
|
1619
1658
|
await sendTurn({ ...turn, resumeCursor: undefined, recoveryText: undefined, text: recovery.text }, turnId);
|
|
1620
1659
|
}
|
|
1621
1660
|
catch (e) {
|
|
1661
|
+
if (active.get(threadId)?.turnId === turnId)
|
|
1662
|
+
active.delete(threadId);
|
|
1622
1663
|
retryState.delete(threadId);
|
|
1623
1664
|
emit({
|
|
1624
1665
|
...base(threadId, turnId),
|
|
@@ -1693,12 +1734,26 @@ export const ClaudeDriver = {
|
|
|
1693
1734
|
if (!version)
|
|
1694
1735
|
return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` };
|
|
1695
1736
|
cliVersion = parseClaudeCliVersion(version);
|
|
1737
|
+
if (version !== cliHelpVersion) {
|
|
1738
|
+
cliHelp = await new Promise((resolve) => {
|
|
1739
|
+
execCli(config.cli, ["--help"], { timeout: 8000, env }, (err, stdout) => resolve(err ? null : stdout));
|
|
1740
|
+
});
|
|
1741
|
+
cliHelpVersion = version;
|
|
1742
|
+
}
|
|
1743
|
+
const features = cliHelp !== null ? { autocompact: claudeCliHelpSupportsFlag(cliHelp, "--autocompact") } : undefined;
|
|
1696
1744
|
const auth = await claudeAuthStatus(config.cli, env);
|
|
1697
1745
|
// claudeEnvironment strips ANTHROPIC_API_KEY, so turns run on the
|
|
1698
1746
|
// CLI's own login (Pro/Max): the cost it reports is what the call
|
|
1699
1747
|
// WOULD bill, not a charge
|
|
1700
1748
|
const update = claudeCliUpdate(version, config.cli);
|
|
1701
|
-
return {
|
|
1749
|
+
return {
|
|
1750
|
+
state: "available",
|
|
1751
|
+
version,
|
|
1752
|
+
...auth,
|
|
1753
|
+
...(update ? { update } : {}),
|
|
1754
|
+
...(features ? { features } : {}),
|
|
1755
|
+
billing: "subscription",
|
|
1756
|
+
};
|
|
1702
1757
|
};
|
|
1703
1758
|
/** One-shot Claude call with the prompt on stdin, never argv. Approval
|
|
1704
1759
|
* summaries can contain paths, commands, or secrets, so the generic
|