openmausbot 0.1.79 → 0.1.81
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 +6 -5
- package/dist/assets/index-CKysBq-V.css +1 -0
- package/dist/assets/{index-C2Je5jdF.js → index-oLMljotU.js} +1 -1
- package/dist/assets/index-peLFiSH8.js +307 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +41 -10
- package/dist-server/drivers/agents-proxy.js +71 -17
- package/dist-server/index.js +4104 -1505
- package/dist-server/openmausbot.js +554 -163
- package/dist-server/pair-cli.js +554 -163
- package/dist-server/server/box-delete-journal.js +471 -0
- package/dist-server/server/box.js +345 -75
- package/dist-server/server/cli-phone-setup.js +2 -2
- package/dist-server/server/cli.js +84 -12
- package/dist-server/server/config.js +48 -18
- package/dist-server/server/container-computer.js +7 -0
- package/dist-server/server/contracts.js +5 -0
- package/dist-server/server/default-model-selection.js +1 -0
- package/dist-server/server/drivers/acp/core.js +112 -18
- package/dist-server/server/drivers/acp/opencode-go.js +6 -0
- package/dist-server/server/drivers/agents-proxy.js +76 -13
- package/dist-server/server/drivers/chat-mcp-tools.js +6 -1
- package/dist-server/server/drivers/claude.js +87 -63
- package/dist-server/server/drivers/codex.js +248 -51
- package/dist-server/server/drivers/native.js +6 -1
- package/dist-server/server/harness/bus.js +4 -0
- package/dist-server/server/harness/registry.js +3 -2
- package/dist-server/server/index.js +1007 -222
- package/dist-server/server/local-vm-lease.js +4 -0
- package/dist-server/server/managed-desktop.js +205 -0
- package/dist-server/server/mcp-http.js +337 -0
- package/dist-server/server/mcp-probe.js +67 -18
- package/dist-server/server/mcp-registry.js +153 -58
- package/dist-server/server/member-turn.js +13 -0
- package/dist-server/server/message-db.js +74 -8
- package/dist-server/server/model-context-window.js +20 -0
- package/dist-server/server/recall-disclosure.js +4 -0
- package/dist-server/server/recent-work.js +142 -0
- package/dist-server/server/redact.js +1 -0
- package/dist-server/server/request-auth.js +3 -1
- package/dist-server/server/room-handoffs.js +16 -6
- package/dist-server/server/routines.js +17 -0
- package/dist-server/server/screen-frame-gate.js +4 -94
- package/dist-server/server/sessions.js +22 -3
- package/dist-server/server/store.js +29 -2
- package/dist-server/server/surface.js +41 -19
- package/dist-server/server/team-setup-requests.js +1 -1
- package/dist-server/server/thread-log-rotation.js +104 -0
- package/dist-server/server/tts/fish.js +155 -0
- package/dist-server/server/tts/index.js +25 -3
- package/dist-server/server/vps-computer.js +26 -4
- package/dist-server/server/vps-ssh.js +88 -0
- package/dist-server/server/workspace-backup.js +11 -0
- package/dist-server/server/workspace.js +2 -1
- package/dist-server/shared/credential-request.js +10 -0
- package/dist-server/shared/tool-surface.js +62 -0
- package/dist-server/vps-container-mcp.js +41 -10
- package/package.json +1 -1
- package/dist/assets/index-D_TFZLlo.js +0 -305
- package/dist/assets/index-Pbb6Ao0s.css +0 -1
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstatSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
const uuid = z.string().uuid();
|
|
6
|
+
const model = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._/:+-]{0,199}$/).refine(value => !value.includes("::"));
|
|
7
|
+
const connectionSchema = z.object({
|
|
8
|
+
portalOrigin: z.string().max(2048), organizationId: uuid, organizationName: z.string().trim().min(1).max(100),
|
|
9
|
+
email: z.string().email().max(320), deviceId: uuid, token: z.string().regex(/^omg_[A-Za-z0-9_-]{43}$/),
|
|
10
|
+
expiresAt: z.number().finite().int().positive(),
|
|
11
|
+
providers: z.array(z.object({ id: z.enum(["anthropic", "openai", "openrouter"]), configured: z.boolean(), models: z.array(model).max(500) }).strict()).max(3),
|
|
12
|
+
}).strict();
|
|
13
|
+
const signature = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
14
|
+
const expiredMessage = "Company access has ended. Reconnect your organization or explicitly choose a personal model; personal billing will not be used automatically.";
|
|
15
|
+
/** Validate only messages received over Electron's private utility-parent port.
|
|
16
|
+
* Never expose this input to renderer HTTP or merge it into saved AppConfig.
|
|
17
|
+
*/
|
|
18
|
+
export function parseManagedDesktopConnection(raw, now = Date.now()) {
|
|
19
|
+
if (raw === null)
|
|
20
|
+
return null;
|
|
21
|
+
const parsed = connectionSchema.safeParse(raw);
|
|
22
|
+
if (!parsed.success)
|
|
23
|
+
throw new Error("Invalid Company connection from the desktop parent.");
|
|
24
|
+
const value = parsed.data, url = new URL(value.portalOrigin);
|
|
25
|
+
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash ||
|
|
26
|
+
(url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)))) {
|
|
27
|
+
throw new Error("Company connection must use an HTTPS origin or loopback fixture.");
|
|
28
|
+
}
|
|
29
|
+
if (value.expiresAt <= now)
|
|
30
|
+
throw new Error("Company connection expired. Reconnect your organization.");
|
|
31
|
+
if (new Set(value.providers.map(provider => provider.id)).size !== value.providers.length)
|
|
32
|
+
throw new Error("Duplicate Company provider configuration.");
|
|
33
|
+
return { ...value, portalOrigin: url.origin, email: value.email.toLowerCase(), providers: value.providers.map(provider => ({ ...provider, models: [...new Set(provider.models)] })).sort((a, b) => a.id.localeCompare(b.id)) };
|
|
34
|
+
}
|
|
35
|
+
export function companyInstanceId(connection, provider) {
|
|
36
|
+
// Device identity prevents an old company's native resume cursor from being
|
|
37
|
+
// inherited by a different enrollment using the same organization/email.
|
|
38
|
+
return `company.${signature([connection.portalOrigin, connection.organizationId, connection.deviceId]).slice(0, 24)}.${provider}`;
|
|
39
|
+
}
|
|
40
|
+
export function companyInstanceConfigs(connection, runtimeDirectory) {
|
|
41
|
+
const entries = {};
|
|
42
|
+
for (const provider of connection.providers) {
|
|
43
|
+
if (!provider.configured || !provider.models.length)
|
|
44
|
+
continue;
|
|
45
|
+
const id = companyInstanceId(connection, provider.id), base = `${connection.portalOrigin}/api/desktop/gateway/${provider.id}`;
|
|
46
|
+
const displayName = `Company · ${connection.organizationName} · ${provider.id === "anthropic" ? "Claude" : provider.id === "openai" ? "Codex" : "OpenRouter"}`;
|
|
47
|
+
if (provider.id === "anthropic")
|
|
48
|
+
entries[id] = {
|
|
49
|
+
driver: "claudeAgent", displayName,
|
|
50
|
+
config: { configDir: join(runtimeDirectory, id, "claude"), managed: true },
|
|
51
|
+
environment: { ANTHROPIC_API_KEY: connection.token, ANTHROPIC_AUTH_TOKEN: connection.token, ANTHROPIC_BASE_URL: base, ANTHROPIC_MODEL: provider.models[0] },
|
|
52
|
+
};
|
|
53
|
+
if (provider.id === "openai")
|
|
54
|
+
entries[id] = {
|
|
55
|
+
driver: "codex", displayName, config: { managed: { url: `${base}/v1`, models: provider.models } },
|
|
56
|
+
environment: { OPENMAUSBOT_COMPANY_API_KEY: connection.token, CODEX_HOME: join(runtimeDirectory, id, "codex") },
|
|
57
|
+
};
|
|
58
|
+
if (provider.id === "openrouter")
|
|
59
|
+
entries[id] = {
|
|
60
|
+
driver: "openai-compat", displayName, config: { url: `${base}/v1`, apiKeyEnv: "OPENMAUSBOT_COMPANY_API_KEY", model: provider.models[0], provider: "" },
|
|
61
|
+
environment: { OPENMAUSBOT_COMPANY_API_KEY: connection.token },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return entries;
|
|
65
|
+
}
|
|
66
|
+
/** One memory-only Company overlay, separate from all personal settings.
|
|
67
|
+
* Revocation fences wrappers synchronously, then stops only their instances.
|
|
68
|
+
*/
|
|
69
|
+
export class ManagedDesktopProviders {
|
|
70
|
+
connection = null;
|
|
71
|
+
currentSignature = signature(null);
|
|
72
|
+
revision = 0;
|
|
73
|
+
ids = new Set();
|
|
74
|
+
tail = Promise.resolve();
|
|
75
|
+
timer;
|
|
76
|
+
now;
|
|
77
|
+
options;
|
|
78
|
+
constructor(options) { this.options = options; this.now = options.now ?? Date.now; }
|
|
79
|
+
info(instanceId) {
|
|
80
|
+
if (!this.ids.has(instanceId) || !this.connection || this.connection.expiresAt <= this.now())
|
|
81
|
+
return;
|
|
82
|
+
return { organizationId: this.connection.organizationId, organizationName: this.connection.organizationName };
|
|
83
|
+
}
|
|
84
|
+
/** Ids this overlay has ever created in this process, so a personal instance
|
|
85
|
+
* that happens to be named company.* is never treated as managed. */
|
|
86
|
+
known = new Set();
|
|
87
|
+
owns(instanceId) { return this.known.has(instanceId); }
|
|
88
|
+
apply(raw, force = false) {
|
|
89
|
+
const connection = parseManagedDesktopConnection(raw, this.now()), nextSignature = signature(connection);
|
|
90
|
+
if (!force && nextSignature === this.currentSignature)
|
|
91
|
+
return this.tail;
|
|
92
|
+
this.currentSignature = nextSignature;
|
|
93
|
+
this.connection = connection;
|
|
94
|
+
const revision = ++this.revision;
|
|
95
|
+
clearTimeout(this.timer);
|
|
96
|
+
const replace = async () => {
|
|
97
|
+
const previous = [...this.ids];
|
|
98
|
+
await this.options.beforeReplace?.(previous);
|
|
99
|
+
for (const id of previous)
|
|
100
|
+
await this.options.registry.dispose(id);
|
|
101
|
+
this.ids.clear();
|
|
102
|
+
if (revision !== this.revision || !connection || connection.expiresAt <= this.now()) {
|
|
103
|
+
this.options.afterReplace?.([]);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Keep native sessions across server/app restarts, but outside portable
|
|
107
|
+
// workspace backups. Device-scoped ids prevent a new enrollment from
|
|
108
|
+
// inheriting another account's native conversation or credentials.
|
|
109
|
+
const directory = this.ensureDirectory(["providers", "company"]);
|
|
110
|
+
const configs = companyInstanceConfigs(connection, directory);
|
|
111
|
+
for (const [id, entry] of Object.entries(configs)) {
|
|
112
|
+
if (this.options.registry.entries().some(existing => existing.instanceId === id))
|
|
113
|
+
throw new Error("Company instance conflicts with an existing account.");
|
|
114
|
+
if (entry.driver === "claudeAgent" || entry.driver === "codex") {
|
|
115
|
+
this.ensureDirectory(["providers", "company", id, entry.driver === "claudeAgent" ? "claude" : "codex"]);
|
|
116
|
+
}
|
|
117
|
+
this.ids.add(id);
|
|
118
|
+
this.known.add(id);
|
|
119
|
+
}
|
|
120
|
+
const active = () => revision === this.revision && connection.expiresAt > this.now();
|
|
121
|
+
await this.options.registry.load(configs, instance => {
|
|
122
|
+
const provider = connection.providers.find(provider => companyInstanceId(connection, provider.id) === instance.instanceId);
|
|
123
|
+
const models = { default: provider.models[0], options: provider.models.map(id => ({ id, label: `${id} (Company)` })) };
|
|
124
|
+
return {
|
|
125
|
+
...instance, models,
|
|
126
|
+
refreshModels: async () => { },
|
|
127
|
+
startAuthentication: undefined, getAuthentication: undefined, completeAuthentication: undefined, cancelAuthentication: undefined, signOut: undefined,
|
|
128
|
+
snapshot: async () => {
|
|
129
|
+
if (!active())
|
|
130
|
+
return { state: "unavailable", reason: expiredMessage };
|
|
131
|
+
const snapshot = await instance.snapshot();
|
|
132
|
+
if (!active())
|
|
133
|
+
return { state: "unavailable", reason: expiredMessage };
|
|
134
|
+
return { ...snapshot, authenticated: snapshot.state === "available", billing: "metered", account: { email: connection.email, organization: connection.organizationName, method: "api-key" } };
|
|
135
|
+
},
|
|
136
|
+
adapter: {
|
|
137
|
+
...instance.adapter,
|
|
138
|
+
sendTurn: async (input) => {
|
|
139
|
+
if (!active())
|
|
140
|
+
throw new Error(expiredMessage);
|
|
141
|
+
if (!input.model || !provider.models.includes(input.model))
|
|
142
|
+
throw new Error("This model is not enabled by your Company administrator.");
|
|
143
|
+
return instance.adapter.sendTurn(input);
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
if (revision === this.revision) {
|
|
149
|
+
this.options.afterReplace?.([...this.ids]);
|
|
150
|
+
this.scheduleExpiry(connection, revision);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
this.tail = this.tail.catch(() => { }).then(replace).catch(async (error) => {
|
|
154
|
+
// A partial replacement must neither retain usable Company wrappers
|
|
155
|
+
// nor memoize failure forever. Do not invalidate a newer queued grant.
|
|
156
|
+
if (revision === this.revision) {
|
|
157
|
+
this.revision++;
|
|
158
|
+
this.connection = null;
|
|
159
|
+
this.currentSignature = null;
|
|
160
|
+
clearTimeout(this.timer);
|
|
161
|
+
}
|
|
162
|
+
await Promise.allSettled([...this.ids].map(id => this.options.registry.dispose(id)));
|
|
163
|
+
this.ids.clear();
|
|
164
|
+
this.options.afterReplace?.([]);
|
|
165
|
+
throw error;
|
|
166
|
+
});
|
|
167
|
+
return this.tail;
|
|
168
|
+
}
|
|
169
|
+
/** Called after the ordinary fleet is rebuilt; never add Company to cfg. */
|
|
170
|
+
restore() { return this.apply(this.connection && this.connection.expiresAt > this.now() ? this.connection : null, true); }
|
|
171
|
+
ensureDirectory(parts) {
|
|
172
|
+
// Only the configured workspace root may already be an operator-chosen
|
|
173
|
+
// alias. None of our owned descendants may redirect into personal homes.
|
|
174
|
+
mkdirSync(this.options.dataDirectory, { recursive: true, mode: 0o700 });
|
|
175
|
+
let directory = this.options.dataDirectory;
|
|
176
|
+
for (const part of parts) {
|
|
177
|
+
directory = join(directory, part);
|
|
178
|
+
try {
|
|
179
|
+
mkdirSync(directory, { mode: 0o700 });
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
if (error.code !== "EEXIST")
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
const stat = lstatSync(directory);
|
|
186
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
187
|
+
throw new Error("Company native storage must be an owned workspace directory.");
|
|
188
|
+
}
|
|
189
|
+
return directory;
|
|
190
|
+
}
|
|
191
|
+
scheduleExpiry(connection, revision) {
|
|
192
|
+
this.timer = setTimeout(() => {
|
|
193
|
+
if (revision !== this.revision)
|
|
194
|
+
return;
|
|
195
|
+
if (connection.expiresAt > this.now())
|
|
196
|
+
this.scheduleExpiry(connection, revision);
|
|
197
|
+
else
|
|
198
|
+
void this.apply(null).catch(() => { });
|
|
199
|
+
}, Math.min(2_147_483_647, Math.max(1, connection.expiresAt - this.now())));
|
|
200
|
+
this.timer.unref?.();
|
|
201
|
+
}
|
|
202
|
+
async close() {
|
|
203
|
+
await this.apply(null, true);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
export class McpHttpError extends Error {
|
|
2
|
+
kind;
|
|
3
|
+
status;
|
|
4
|
+
constructor(kind, message, status) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "McpHttpError";
|
|
7
|
+
this.kind = kind;
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Bytes of one response (or one SSE stream) a probe is willing to read. */
|
|
12
|
+
const MAX_BODY_BYTES = 1_048_576;
|
|
13
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function unwrap(message) {
|
|
18
|
+
if (message.error !== undefined) {
|
|
19
|
+
const detail = isRecord(message.error) && typeof message.error.message === "string" ? message.error.message : "request failed";
|
|
20
|
+
throw new McpHttpError("protocol", `MCP error: ${detail}`);
|
|
21
|
+
}
|
|
22
|
+
return message.result;
|
|
23
|
+
}
|
|
24
|
+
function parseMessage(text) {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(text);
|
|
27
|
+
if (Array.isArray(parsed))
|
|
28
|
+
return parsed.filter(isRecord);
|
|
29
|
+
return isRecord(parsed) ? parsed : null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function parseSseBlock(block) {
|
|
36
|
+
let event = "message";
|
|
37
|
+
const data = [];
|
|
38
|
+
for (const line of block.split("\n")) {
|
|
39
|
+
if (!line || line.startsWith(":"))
|
|
40
|
+
continue;
|
|
41
|
+
const colon = line.indexOf(":");
|
|
42
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
43
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
44
|
+
if (value.startsWith(" "))
|
|
45
|
+
value = value.slice(1);
|
|
46
|
+
if (field === "event")
|
|
47
|
+
event = value;
|
|
48
|
+
else if (field === "data")
|
|
49
|
+
data.push(value);
|
|
50
|
+
}
|
|
51
|
+
return data.length ? { event, data: data.join("\n") } : null;
|
|
52
|
+
}
|
|
53
|
+
/** Read an event stream until the handler says stop, the server ends it,
|
|
54
|
+
* or the signal fires. Bounded, so a chatty server cannot fill memory. */
|
|
55
|
+
async function readSse(response, signal, onEvent) {
|
|
56
|
+
const reader = response.body?.getReader();
|
|
57
|
+
if (!reader)
|
|
58
|
+
throw new McpHttpError("protocol", "empty event stream");
|
|
59
|
+
const decoder = new TextDecoder();
|
|
60
|
+
let buffer = "";
|
|
61
|
+
let bytes = 0;
|
|
62
|
+
const onAbort = () => void reader.cancel().catch(() => { });
|
|
63
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
64
|
+
try {
|
|
65
|
+
for (;;) {
|
|
66
|
+
const { value, done } = await reader.read();
|
|
67
|
+
if (done)
|
|
68
|
+
return;
|
|
69
|
+
bytes += value.byteLength;
|
|
70
|
+
if (bytes > MAX_BODY_BYTES)
|
|
71
|
+
throw new McpHttpError("protocol", "event stream too large");
|
|
72
|
+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n");
|
|
73
|
+
let separator;
|
|
74
|
+
while ((separator = buffer.indexOf("\n\n")) !== -1) {
|
|
75
|
+
const block = buffer.slice(0, separator);
|
|
76
|
+
buffer = buffer.slice(separator + 2);
|
|
77
|
+
const event = parseSseBlock(block);
|
|
78
|
+
if (event && onEvent(event) === "stop") {
|
|
79
|
+
await reader.cancel().catch(() => { });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
signal.removeEventListener("abort", onAbort);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function readBounded(response) {
|
|
90
|
+
const reader = response.body?.getReader();
|
|
91
|
+
if (!reader)
|
|
92
|
+
return "";
|
|
93
|
+
const decoder = new TextDecoder();
|
|
94
|
+
let text = "";
|
|
95
|
+
let bytes = 0;
|
|
96
|
+
for (;;) {
|
|
97
|
+
const { value, done } = await reader.read();
|
|
98
|
+
if (done)
|
|
99
|
+
return text + decoder.decode();
|
|
100
|
+
bytes += value.byteLength;
|
|
101
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
102
|
+
await reader.cancel().catch(() => { });
|
|
103
|
+
throw new McpHttpError("protocol", "response too large");
|
|
104
|
+
}
|
|
105
|
+
text += decoder.decode(value, { stream: true });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function drain(response) {
|
|
109
|
+
void response.body?.cancel().catch(() => { });
|
|
110
|
+
}
|
|
111
|
+
export class RemoteMcpClient {
|
|
112
|
+
target;
|
|
113
|
+
fetchImpl;
|
|
114
|
+
sessionId = null;
|
|
115
|
+
protocolVersion = null;
|
|
116
|
+
nextId = 1;
|
|
117
|
+
closed = false;
|
|
118
|
+
/** Legacy SSE: one long GET stream carries every response. */
|
|
119
|
+
pending = new Map();
|
|
120
|
+
sseEndpoint = null;
|
|
121
|
+
sseAbort = new AbortController();
|
|
122
|
+
constructor(target, options = {}) {
|
|
123
|
+
this.target = target;
|
|
124
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
125
|
+
}
|
|
126
|
+
/** The MCP handshake: initialize, then the initialized notification. */
|
|
127
|
+
async initialize(clientName, signal) {
|
|
128
|
+
const result = await this.request("initialize", {
|
|
129
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
130
|
+
capabilities: {},
|
|
131
|
+
clientInfo: { name: clientName, version: "1" },
|
|
132
|
+
}, signal);
|
|
133
|
+
if (isRecord(result) && typeof result.protocolVersion === "string")
|
|
134
|
+
this.protocolVersion = result.protocolVersion;
|
|
135
|
+
await this.notify("notifications/initialized", undefined, signal);
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
request(method, params, signal) {
|
|
139
|
+
if (this.closed)
|
|
140
|
+
return Promise.reject(new McpHttpError("protocol", "client closed"));
|
|
141
|
+
const id = this.nextId++;
|
|
142
|
+
const frame = { jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) };
|
|
143
|
+
return this.target.type === "sse" ? this.sseRequest(id, frame, signal) : this.streamableRequest(id, frame, signal);
|
|
144
|
+
}
|
|
145
|
+
async notify(method, params, signal) {
|
|
146
|
+
if (this.closed)
|
|
147
|
+
throw new McpHttpError("protocol", "client closed");
|
|
148
|
+
const frame = { jsonrpc: "2.0", method, ...(params === undefined ? {} : { params }) };
|
|
149
|
+
if (this.target.type === "sse") {
|
|
150
|
+
await this.ssePost(frame, signal);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const response = await this.post(this.target.url, frame, signal);
|
|
154
|
+
drain(response);
|
|
155
|
+
}
|
|
156
|
+
async close() {
|
|
157
|
+
if (this.closed)
|
|
158
|
+
return;
|
|
159
|
+
this.closed = true;
|
|
160
|
+
this.sseAbort.abort();
|
|
161
|
+
for (const entry of this.pending.values())
|
|
162
|
+
entry.reject(new McpHttpError("network", "client closed"));
|
|
163
|
+
this.pending.clear();
|
|
164
|
+
// Streamable HTTP: tell the server its session is over. Best effort —
|
|
165
|
+
// many servers answer 405 here, and a probe has nothing left to lose.
|
|
166
|
+
if (this.sessionId && this.target.type !== "sse") {
|
|
167
|
+
try {
|
|
168
|
+
const headers = this.headers();
|
|
169
|
+
headers.set("mcp-session-id", this.sessionId);
|
|
170
|
+
const response = await this.fetchImpl(this.target.url, { method: "DELETE", headers, signal: AbortSignal.timeout(2_000) });
|
|
171
|
+
drain(response);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// the session lapses on its own
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
headers() {
|
|
179
|
+
const headers = new Headers();
|
|
180
|
+
for (const [name, value] of Object.entries(this.target.headers))
|
|
181
|
+
headers.set(name, value);
|
|
182
|
+
headers.set("accept", "application/json, text/event-stream");
|
|
183
|
+
if (this.sessionId)
|
|
184
|
+
headers.set("mcp-session-id", this.sessionId);
|
|
185
|
+
if (this.protocolVersion)
|
|
186
|
+
headers.set("mcp-protocol-version", this.protocolVersion);
|
|
187
|
+
return headers;
|
|
188
|
+
}
|
|
189
|
+
async post(url, frame, signal) {
|
|
190
|
+
const headers = this.headers();
|
|
191
|
+
headers.set("content-type", "application/json");
|
|
192
|
+
let response;
|
|
193
|
+
try {
|
|
194
|
+
response = await this.fetchImpl(url, { method: "POST", headers, body: JSON.stringify(frame), signal });
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (signal.aborted)
|
|
198
|
+
throw error;
|
|
199
|
+
throw new McpHttpError("network", "request failed");
|
|
200
|
+
}
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
drain(response);
|
|
203
|
+
throw new McpHttpError("status", `HTTP ${response.status}`, response.status);
|
|
204
|
+
}
|
|
205
|
+
const session = response.headers.get("mcp-session-id");
|
|
206
|
+
if (session)
|
|
207
|
+
this.sessionId = session;
|
|
208
|
+
return response;
|
|
209
|
+
}
|
|
210
|
+
// ── streamable HTTP: each request is one POST; the answer is JSON or a
|
|
211
|
+
// short event stream that carries the response among server messages ──
|
|
212
|
+
async streamableRequest(id, frame, signal) {
|
|
213
|
+
const response = await this.post(this.target.url, frame, signal);
|
|
214
|
+
const type = (response.headers.get("content-type") ?? "").toLowerCase();
|
|
215
|
+
if (type.startsWith("application/json")) {
|
|
216
|
+
const parsed = parseMessage(await readBounded(response));
|
|
217
|
+
const message = (Array.isArray(parsed) ? parsed : parsed ? [parsed] : []).find((entry) => entry.id === id);
|
|
218
|
+
if (!message)
|
|
219
|
+
throw new McpHttpError("protocol", "response did not answer the request");
|
|
220
|
+
return unwrap(message);
|
|
221
|
+
}
|
|
222
|
+
if (type.startsWith("text/event-stream")) {
|
|
223
|
+
let found;
|
|
224
|
+
await readSse(response, signal, (event) => {
|
|
225
|
+
if (event.event !== "message")
|
|
226
|
+
return undefined;
|
|
227
|
+
const parsed = parseMessage(event.data);
|
|
228
|
+
const message = (Array.isArray(parsed) ? parsed : parsed ? [parsed] : []).find((entry) => entry.id === id);
|
|
229
|
+
if (!message)
|
|
230
|
+
return undefined;
|
|
231
|
+
found = message;
|
|
232
|
+
return "stop";
|
|
233
|
+
});
|
|
234
|
+
if (signal.aborted)
|
|
235
|
+
throw new McpHttpError("network", "aborted");
|
|
236
|
+
if (!found)
|
|
237
|
+
throw new McpHttpError("protocol", "event stream ended without the response");
|
|
238
|
+
return unwrap(found);
|
|
239
|
+
}
|
|
240
|
+
drain(response);
|
|
241
|
+
throw new McpHttpError("protocol", "unexpected content type");
|
|
242
|
+
}
|
|
243
|
+
// ── legacy SSE: a GET opens the stream, its first event names where to
|
|
244
|
+
// POST, and every response comes back over the stream ──
|
|
245
|
+
openSseStream(signal) {
|
|
246
|
+
if (this.sseEndpoint)
|
|
247
|
+
return this.sseEndpoint;
|
|
248
|
+
this.sseEndpoint = new Promise((resolveEndpoint, rejectEndpoint) => {
|
|
249
|
+
let settled = false;
|
|
250
|
+
const fail = (error) => {
|
|
251
|
+
if (!settled) {
|
|
252
|
+
settled = true;
|
|
253
|
+
rejectEndpoint(error);
|
|
254
|
+
}
|
|
255
|
+
for (const entry of this.pending.values())
|
|
256
|
+
entry.reject(error);
|
|
257
|
+
this.pending.clear();
|
|
258
|
+
};
|
|
259
|
+
const headers = this.headers();
|
|
260
|
+
headers.set("accept", "text/event-stream");
|
|
261
|
+
// the caller's signal covers the opening; once open, the stream lives
|
|
262
|
+
// until close(), because later requests are answered on it
|
|
263
|
+
const opening = AbortSignal.any([signal, this.sseAbort.signal]);
|
|
264
|
+
this.fetchImpl(this.target.url, { method: "GET", headers, signal: opening })
|
|
265
|
+
.then(async (response) => {
|
|
266
|
+
if (!response.ok) {
|
|
267
|
+
drain(response);
|
|
268
|
+
throw new McpHttpError("status", `HTTP ${response.status}`, response.status);
|
|
269
|
+
}
|
|
270
|
+
await readSse(response, this.sseAbort.signal, (event) => {
|
|
271
|
+
if (event.event === "endpoint") {
|
|
272
|
+
if (!settled) {
|
|
273
|
+
settled = true;
|
|
274
|
+
try {
|
|
275
|
+
resolveEndpoint(new URL(event.data, this.target.url).toString());
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
rejectEndpoint(new McpHttpError("protocol", "invalid endpoint event"));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
if (event.event !== "message")
|
|
284
|
+
return undefined;
|
|
285
|
+
const parsed = parseMessage(event.data);
|
|
286
|
+
for (const message of Array.isArray(parsed) ? parsed : parsed ? [parsed] : []) {
|
|
287
|
+
const entry = typeof message.id === "number" ? this.pending.get(message.id) : undefined;
|
|
288
|
+
if (!entry)
|
|
289
|
+
continue;
|
|
290
|
+
this.pending.delete(message.id);
|
|
291
|
+
try {
|
|
292
|
+
entry.resolve(unwrap(message));
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
entry.reject(error instanceof Error ? error : new McpHttpError("protocol", String(error)));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return undefined;
|
|
299
|
+
});
|
|
300
|
+
fail(new McpHttpError("network", "event stream ended"));
|
|
301
|
+
})
|
|
302
|
+
.catch((error) => {
|
|
303
|
+
fail(error instanceof McpHttpError ? error : new McpHttpError("network", "event stream failed"));
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
return this.sseEndpoint;
|
|
307
|
+
}
|
|
308
|
+
async ssePost(frame, signal) {
|
|
309
|
+
const endpoint = await this.openSseStream(signal);
|
|
310
|
+
const response = await this.post(endpoint, frame, signal);
|
|
311
|
+
drain(response);
|
|
312
|
+
}
|
|
313
|
+
sseRequest(id, frame, signal) {
|
|
314
|
+
return new Promise((resolve, reject) => {
|
|
315
|
+
const onAbort = () => {
|
|
316
|
+
this.pending.delete(id);
|
|
317
|
+
reject(new McpHttpError("network", "aborted"));
|
|
318
|
+
};
|
|
319
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
320
|
+
this.pending.set(id, {
|
|
321
|
+
resolve: (value) => {
|
|
322
|
+
signal.removeEventListener("abort", onAbort);
|
|
323
|
+
resolve(value);
|
|
324
|
+
},
|
|
325
|
+
reject: (error) => {
|
|
326
|
+
signal.removeEventListener("abort", onAbort);
|
|
327
|
+
reject(error);
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
this.ssePost(frame, signal).catch((error) => {
|
|
331
|
+
this.pending.delete(id);
|
|
332
|
+
signal.removeEventListener("abort", onAbort);
|
|
333
|
+
reject(error instanceof Error ? error : new McpHttpError("network", String(error)));
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { augmentedPath } from "./env-path.js";
|
|
2
2
|
import { PROVIDER_CREDENTIAL_ENV, stripWorkspaceCredentialEnv, } from "./config.js";
|
|
3
3
|
import { createLineSplitter } from "./mcp-bridge.js";
|
|
4
|
+
import { McpHttpError, RemoteMcpClient } from "./mcp-http.js";
|
|
5
|
+
import { isRemoteMcpServer } from "./mcp-registry.js";
|
|
4
6
|
import { killCliTree, spawnCli } from "./procs.js";
|
|
5
7
|
const MAX_STDOUT_BYTES = 1_048_576;
|
|
6
8
|
const MAX_TOOLS = 100;
|
|
@@ -24,18 +26,79 @@ function publicProbeError(kind) {
|
|
|
24
26
|
return "Connection test was cancelled.";
|
|
25
27
|
return "The command did not return a valid MCP tools list.";
|
|
26
28
|
}
|
|
27
|
-
function redactConfiguredValues(value,
|
|
29
|
+
function redactConfiguredValues(value, secrets) {
|
|
28
30
|
let redacted = value;
|
|
29
|
-
for (const secret of Object.values(
|
|
31
|
+
for (const secret of Object.values(secrets)) {
|
|
30
32
|
if (secret)
|
|
31
33
|
redacted = redacted.split(secret).join("[redacted]");
|
|
32
34
|
}
|
|
33
35
|
return redacted;
|
|
34
36
|
}
|
|
37
|
+
/** The bounded, redacted tool list the renderer may see. `secrets` are the
|
|
38
|
+
* configured values (env or header values) a careless server might echo. */
|
|
39
|
+
function publicTools(raw, secrets) {
|
|
40
|
+
const tools = [];
|
|
41
|
+
for (const entry of raw.slice(0, MAX_TOOLS)) {
|
|
42
|
+
if (!entry || typeof entry !== "object")
|
|
43
|
+
continue;
|
|
44
|
+
const candidate = entry;
|
|
45
|
+
if (typeof candidate.name !== "string" || !candidate.name.trim())
|
|
46
|
+
continue;
|
|
47
|
+
tools.push({
|
|
48
|
+
name: redactConfiguredValues(candidate.name, secrets).slice(0, 200),
|
|
49
|
+
...(typeof candidate.description === "string"
|
|
50
|
+
? { description: redactConfiguredValues(candidate.description, secrets).slice(0, 500) }
|
|
51
|
+
: {}),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return tools;
|
|
55
|
+
}
|
|
56
|
+
/** Prove the MCP handshake and list the tools of one configured server,
|
|
57
|
+
* whichever way it is reached. Neither path returns anything the renderer
|
|
58
|
+
* must not see: child stderr, environment values, header values. */
|
|
59
|
+
export function probeMcpServer(server, timeoutMs = DEFAULT_TIMEOUT_MS, signal) {
|
|
60
|
+
return isRemoteMcpServer(server)
|
|
61
|
+
? probeRemoteMcpServer(server, timeoutMs, signal)
|
|
62
|
+
: probeStdioMcpServer(server, timeoutMs, signal);
|
|
63
|
+
}
|
|
64
|
+
/** Connect to a remote server over its transport, bounded by the same
|
|
65
|
+
* timeout as a command. HTTP status codes are safe to show and are the one
|
|
66
|
+
* detail that tells a wrong token from a wrong address. */
|
|
67
|
+
async function probeRemoteMcpServer(server, timeoutMs, signal) {
|
|
68
|
+
if (signal?.aborted)
|
|
69
|
+
return { ok: false, error: publicProbeError("cancelled") };
|
|
70
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
71
|
+
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
72
|
+
const client = new RemoteMcpClient(server);
|
|
73
|
+
try {
|
|
74
|
+
await client.initialize("OpenMausBot", combined);
|
|
75
|
+
const result = await client.request("tools/list", {}, combined);
|
|
76
|
+
const tools = result && typeof result === "object" ? result.tools : undefined;
|
|
77
|
+
if (!Array.isArray(tools))
|
|
78
|
+
return { ok: false, error: "The server did not return a valid MCP tools list." };
|
|
79
|
+
return { ok: true, tools: publicTools(tools, server.headers) };
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (signal?.aborted)
|
|
83
|
+
return { ok: false, error: publicProbeError("cancelled") };
|
|
84
|
+
if (timeout.aborted)
|
|
85
|
+
return { ok: false, error: publicProbeError("timeout") };
|
|
86
|
+
if (error instanceof McpHttpError && error.kind === "status") {
|
|
87
|
+
return { ok: false, error: `The server answered HTTP ${error.status}. Check the address and headers.` };
|
|
88
|
+
}
|
|
89
|
+
if (error instanceof McpHttpError && error.kind === "protocol") {
|
|
90
|
+
return { ok: false, error: "The server did not return a valid MCP tools list." };
|
|
91
|
+
}
|
|
92
|
+
return { ok: false, error: "Could not reach this address. Check the URL and your network." };
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await client.close().catch(() => { });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
35
98
|
/** Start one stdio server long enough to prove the MCP handshake and list its
|
|
36
99
|
* tools. It is always reaped, never inherits OpenMaus credentials, and never
|
|
37
100
|
* returns child stderr or environment values to the renderer. */
|
|
38
|
-
|
|
101
|
+
function probeStdioMcpServer(server, timeoutMs, signal) {
|
|
39
102
|
return new Promise((resolve) => {
|
|
40
103
|
if (signal?.aborted) {
|
|
41
104
|
resolve({ ok: false, error: publicProbeError("cancelled") });
|
|
@@ -102,21 +165,7 @@ export function probeMcpServer(server, timeoutMs = DEFAULT_TIMEOUT_MS, signal) {
|
|
|
102
165
|
finish({ ok: false, error: publicProbeError("protocol") });
|
|
103
166
|
return;
|
|
104
167
|
}
|
|
105
|
-
|
|
106
|
-
for (const raw of result.tools.slice(0, MAX_TOOLS)) {
|
|
107
|
-
if (!raw || typeof raw !== "object")
|
|
108
|
-
continue;
|
|
109
|
-
const candidate = raw;
|
|
110
|
-
if (typeof candidate.name !== "string" || !candidate.name.trim())
|
|
111
|
-
continue;
|
|
112
|
-
tools.push({
|
|
113
|
-
name: redactConfiguredValues(candidate.name, server.env).slice(0, 200),
|
|
114
|
-
...(typeof candidate.description === "string"
|
|
115
|
-
? { description: redactConfiguredValues(candidate.description, server.env).slice(0, 500) }
|
|
116
|
-
: {}),
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
finish({ ok: true, tools });
|
|
168
|
+
finish({ ok: true, tools: publicTools(result.tools, server.env) });
|
|
120
169
|
});
|
|
121
170
|
timer = setTimeout(() => {
|
|
122
171
|
finish({ ok: false, error: publicProbeError("timeout") });
|