@leuria/cli 0.1.0
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/LICENSE +202 -0
- package/README.md +14 -0
- package/dist/chunk-BXSAYWR3.js +3433 -0
- package/dist/cli.js +1198 -0
- package/dist/index.d.ts +819 -0
- package/dist/index.js +42 -0
- package/package.json +49 -0
|
@@ -0,0 +1,3433 @@
|
|
|
1
|
+
// src/acp/registry.ts
|
|
2
|
+
var REGISTRY_URL = process.env.ACP_REGISTRY_URL ?? "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
|
|
3
|
+
function platformKey() {
|
|
4
|
+
const os = { darwin: "darwin", linux: "linux", win32: "windows" };
|
|
5
|
+
const arch = { arm64: "aarch64", x64: "x86_64" };
|
|
6
|
+
return `${os[process.platform] ?? process.platform}-${arch[process.arch] ?? process.arch}`;
|
|
7
|
+
}
|
|
8
|
+
var TTL_MS = 10 * 6e4;
|
|
9
|
+
var FAIL_TTL_MS = 6e4;
|
|
10
|
+
var cache = null;
|
|
11
|
+
var failedAt = 0;
|
|
12
|
+
async function fetchRegistry() {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
if (cache && now - cache.at < TTL_MS) return cache.agents;
|
|
15
|
+
if (now - failedAt < FAIL_TTL_MS) return [];
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(5e3) });
|
|
18
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
19
|
+
const payload = await res.json();
|
|
20
|
+
const agents = (payload.agents ?? []).filter(
|
|
21
|
+
(a) => typeof a === "object" && a !== null && typeof a.id === "string" && typeof a.distribution === "object"
|
|
22
|
+
);
|
|
23
|
+
cache = { at: now, agents };
|
|
24
|
+
failedAt = 0;
|
|
25
|
+
return agents;
|
|
26
|
+
} catch {
|
|
27
|
+
failedAt = now;
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
var ALIASES = {
|
|
32
|
+
"claude-acp": ["claude-code-acp"],
|
|
33
|
+
"claude-code-acp": ["claude-acp"]
|
|
34
|
+
};
|
|
35
|
+
async function getRegistryEntry(id) {
|
|
36
|
+
const agents = await fetchRegistry();
|
|
37
|
+
const ids = [id, ...ALIASES[id] ?? []];
|
|
38
|
+
for (const candidate of ids) {
|
|
39
|
+
const entry = agents.find((a) => a.id === candidate);
|
|
40
|
+
if (entry) return entry;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/home.ts
|
|
46
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
47
|
+
import { homedir } from "os";
|
|
48
|
+
import { join } from "path";
|
|
49
|
+
var DEFAULT_PORT = 19570;
|
|
50
|
+
var DEFAULT_AGENT = "claude-acp";
|
|
51
|
+
function parseEmbedChoice(value) {
|
|
52
|
+
if (value === "off") return "off";
|
|
53
|
+
const { provider, model } = value ?? {};
|
|
54
|
+
return typeof provider === "string" && typeof model === "string" && provider && model ? { provider, model } : void 0;
|
|
55
|
+
}
|
|
56
|
+
function leuriaHome() {
|
|
57
|
+
return process.env.LEURIA_HOME ?? join(homedir(), ".leuria");
|
|
58
|
+
}
|
|
59
|
+
function homePath(...parts) {
|
|
60
|
+
return join(leuriaHome(), ...parts);
|
|
61
|
+
}
|
|
62
|
+
function loadConfig() {
|
|
63
|
+
const stored = readJson(homePath("config.json")) ?? {};
|
|
64
|
+
return {
|
|
65
|
+
port: typeof stored.port === "number" ? stored.port : DEFAULT_PORT,
|
|
66
|
+
agent: typeof stored.agent === "string" && stored.agent ? stored.agent : DEFAULT_AGENT,
|
|
67
|
+
...Array.isArray(stored.ais) ? { ais: stored.ais.filter((a) => typeof a === "string") } : {},
|
|
68
|
+
...stored.models && typeof stored.models === "object" ? { models: stored.models } : {},
|
|
69
|
+
...stored.ready && typeof stored.ready === "object" ? { ready: stored.ready } : {},
|
|
70
|
+
...parseEmbedChoice(stored.embed) ? { embed: parseEmbedChoice(stored.embed) } : {}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function isKnownReady(id) {
|
|
74
|
+
return Boolean(loadConfig().ready?.[id]);
|
|
75
|
+
}
|
|
76
|
+
function rememberReady(id, ok) {
|
|
77
|
+
const config = loadConfig();
|
|
78
|
+
const ready = { ...config.ready };
|
|
79
|
+
const last = ready[id];
|
|
80
|
+
if (ok) {
|
|
81
|
+
if (last && Date.now() - Date.parse(last) < 864e5) return;
|
|
82
|
+
ready[id] = (/* @__PURE__ */ new Date()).toISOString();
|
|
83
|
+
} else {
|
|
84
|
+
if (!last) return;
|
|
85
|
+
delete ready[id];
|
|
86
|
+
}
|
|
87
|
+
saveConfig({ ...config, ready });
|
|
88
|
+
}
|
|
89
|
+
function saveConfig(config) {
|
|
90
|
+
writeJson(homePath("config.json"), config);
|
|
91
|
+
}
|
|
92
|
+
function readJson(path2) {
|
|
93
|
+
if (!existsSync(path2)) return null;
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(readFileSync(path2, "utf-8"));
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function writeJson(path2, data) {
|
|
101
|
+
mkdirSync(leuriaHome(), { recursive: true, mode: 448 });
|
|
102
|
+
const tmp = `${path2}.${process.pid}.tmp`;
|
|
103
|
+
writeFileSync(tmp, `${JSON.stringify(data, null, 2)}
|
|
104
|
+
`, { mode: 384 });
|
|
105
|
+
renameSync(tmp, path2);
|
|
106
|
+
try {
|
|
107
|
+
chmodSync(path2, 384);
|
|
108
|
+
} catch {
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/llm/providers.ts
|
|
113
|
+
function parseParams(value) {
|
|
114
|
+
if (typeof value !== "string") return void 0;
|
|
115
|
+
const moe = /^(\d+)x(\d+(?:\.\d+)?)\s*B$/i.exec(value.trim());
|
|
116
|
+
if (moe) return Number(moe[1]) * Number(moe[2]);
|
|
117
|
+
const plain = /^(\d+(?:\.\d+)?)\s*([BM])$/i.exec(value.trim());
|
|
118
|
+
if (!plain) return void 0;
|
|
119
|
+
return plain[2].toUpperCase() === "M" ? Number(plain[1]) / 1e3 : Number(plain[1]);
|
|
120
|
+
}
|
|
121
|
+
var BUILTIN = [
|
|
122
|
+
{ id: "lmstudio", kind: "lmstudio", name: "LM Studio", baseUrl: "http://127.0.0.1:1234/v1" },
|
|
123
|
+
{ id: "ollama", kind: "ollama", name: "Ollama", baseUrl: "http://127.0.0.1:11434/v1" }
|
|
124
|
+
];
|
|
125
|
+
var EMBEDDING = /embed|e5-|bge-|gte-|nomic-embed|jina-embed|mxbai-embed/i;
|
|
126
|
+
function isLlmId(id) {
|
|
127
|
+
return id.startsWith("llm:");
|
|
128
|
+
}
|
|
129
|
+
function llmId(providerId, model) {
|
|
130
|
+
return `llm:${providerId}/${model}`;
|
|
131
|
+
}
|
|
132
|
+
function parseLlmId(id) {
|
|
133
|
+
if (!isLlmId(id)) return null;
|
|
134
|
+
const rest = id.slice(4);
|
|
135
|
+
const slash = rest.indexOf("/");
|
|
136
|
+
if (slash === -1) return rest ? { providerId: rest } : null;
|
|
137
|
+
if (slash === 0 || slash === rest.length - 1) return null;
|
|
138
|
+
return { providerId: rest.slice(0, slash), model: rest.slice(slash + 1) };
|
|
139
|
+
}
|
|
140
|
+
function defaultModel(models) {
|
|
141
|
+
return (models.find((m) => m.loaded) ?? models.find((m) => !m.remote) ?? models[0])?.id;
|
|
142
|
+
}
|
|
143
|
+
function path() {
|
|
144
|
+
return homePath("providers.json");
|
|
145
|
+
}
|
|
146
|
+
function listProviders() {
|
|
147
|
+
const saved = readJson(path())?.providers ?? [];
|
|
148
|
+
const ids = new Set(saved.map((p) => p.id));
|
|
149
|
+
return [...BUILTIN.filter((b) => !ids.has(b.id)), ...saved];
|
|
150
|
+
}
|
|
151
|
+
function getProvider(id) {
|
|
152
|
+
return listProviders().find((p) => p.id === id);
|
|
153
|
+
}
|
|
154
|
+
function saveProvider(input) {
|
|
155
|
+
const baseUrl = normalizeBaseUrl(input.baseUrl);
|
|
156
|
+
const id = input.id ?? slug(input.name);
|
|
157
|
+
if (!id) throw new Error("A name is required");
|
|
158
|
+
const provider = {
|
|
159
|
+
id,
|
|
160
|
+
kind: input.kind ?? "openai",
|
|
161
|
+
name: input.name.trim(),
|
|
162
|
+
baseUrl,
|
|
163
|
+
...input.apiKey?.trim() ? { apiKey: input.apiKey.trim() } : {}
|
|
164
|
+
};
|
|
165
|
+
const saved = (readJson(path())?.providers ?? []).filter((p) => p.id !== id);
|
|
166
|
+
writeJson(path(), { providers: [...saved, provider] });
|
|
167
|
+
return provider;
|
|
168
|
+
}
|
|
169
|
+
function removeProvider(id) {
|
|
170
|
+
const saved = readJson(path())?.providers ?? [];
|
|
171
|
+
const kept = saved.filter((p) => p.id !== id);
|
|
172
|
+
if (kept.length === saved.length) return false;
|
|
173
|
+
writeJson(path(), { providers: kept });
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
function normalizeBaseUrl(url) {
|
|
177
|
+
let value = url.trim().replace(/\/+$/, "");
|
|
178
|
+
if (!/^https?:\/\//i.test(value)) value = `http://${value}`;
|
|
179
|
+
const parsed = new URL(value);
|
|
180
|
+
if (!/\/v\d+[a-z0-9]*(\/|$)/.test(parsed.pathname)) parsed.pathname = `${parsed.pathname.replace(/\/+$/, "")}/v1`;
|
|
181
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
182
|
+
}
|
|
183
|
+
function slug(name) {
|
|
184
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
185
|
+
}
|
|
186
|
+
function authHeaders(provider) {
|
|
187
|
+
return provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : {};
|
|
188
|
+
}
|
|
189
|
+
async function listModels(provider, timeoutMs = 3e3) {
|
|
190
|
+
if (provider.kind === "lmstudio") {
|
|
191
|
+
const root = provider.baseUrl.replace(/\/v\d+$/, "");
|
|
192
|
+
const native = await fetch(`${root}/api/v1/models`, { headers: authHeaders(provider), signal: AbortSignal.timeout(timeoutMs) }).then((r) => r.ok ? r.json() : null).catch(() => null);
|
|
193
|
+
if (native?.models) {
|
|
194
|
+
return native.models.filter((m) => m.type === "llm").map((m) => ({
|
|
195
|
+
id: m.key,
|
|
196
|
+
loaded: (m.loaded_instances?.length ?? 0) > 0,
|
|
197
|
+
meta: {
|
|
198
|
+
tools: m.capabilities?.trained_for_tool_use,
|
|
199
|
+
images: m.capabilities?.vision,
|
|
200
|
+
context: m.max_context_length,
|
|
201
|
+
params: parseParams(m.params_string)
|
|
202
|
+
}
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
let remote = /* @__PURE__ */ new Set();
|
|
207
|
+
const sizes = /* @__PURE__ */ new Map();
|
|
208
|
+
if (provider.kind === "ollama") {
|
|
209
|
+
const root = provider.baseUrl.replace(/\/v\d+$/, "");
|
|
210
|
+
const tags = await fetch(`${root}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) }).then((r) => r.ok ? r.json() : null).catch(() => null);
|
|
211
|
+
remote = new Set((tags?.models ?? []).filter((m) => m.remote_host).map((m) => m.name));
|
|
212
|
+
for (const m of tags?.models ?? []) {
|
|
213
|
+
const params = parseParams(m.details?.parameter_size);
|
|
214
|
+
if (params) sizes.set(m.name, params);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
let res;
|
|
218
|
+
try {
|
|
219
|
+
res = await fetch(`${provider.baseUrl}/models`, { headers: authHeaders(provider), signal: AbortSignal.timeout(timeoutMs) });
|
|
220
|
+
} catch {
|
|
221
|
+
throw new Error(unreachable(provider));
|
|
222
|
+
}
|
|
223
|
+
if (res.status === 401 || res.status === 403) throw new Error(`${provider.name} refused the API key.`);
|
|
224
|
+
if (res.status === 404) throw new Error(`Nothing answered at ${provider.baseUrl}. Check the address, then try again.`);
|
|
225
|
+
if (!res.ok) throw new Error(`${provider.name} had a problem. Try again in a moment.`);
|
|
226
|
+
const body = await res.json().catch(() => null);
|
|
227
|
+
if (!Array.isArray(body?.data)) throw new Error(`That address doesn't look like an AI service. Check it, then try again.`);
|
|
228
|
+
return body.data.filter((m) => typeof m.id === "string").filter((m) => m.type !== "embedding" && !EMBEDDING.test(m.id)).map((m) => ({
|
|
229
|
+
id: m.id,
|
|
230
|
+
...remote.has(m.id) ? { remote: true } : {},
|
|
231
|
+
...sizes.has(m.id) ? { meta: { params: sizes.get(m.id) } } : {}
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
function unreachable(provider) {
|
|
235
|
+
if (provider.kind === "lmstudio") return "Couldn't reach LM Studio. Open it and start its local server, then try again.";
|
|
236
|
+
if (provider.kind === "ollama") return "Couldn't reach Ollama. Open it, then try again.";
|
|
237
|
+
return `Couldn't reach ${provider.name} at ${provider.baseUrl}.`;
|
|
238
|
+
}
|
|
239
|
+
async function detectLlms() {
|
|
240
|
+
return Promise.all(
|
|
241
|
+
listProviders().map(async (provider) => {
|
|
242
|
+
try {
|
|
243
|
+
return { provider, running: true, models: await listModels(provider, 1500) };
|
|
244
|
+
} catch (error) {
|
|
245
|
+
return { provider, running: false, models: [], error: error instanceof Error ? error.message : String(error) };
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
function llmDisplayName(id) {
|
|
251
|
+
const parsed = parseLlmId(id);
|
|
252
|
+
if (!parsed) return id;
|
|
253
|
+
const provider = getProvider(parsed.providerId);
|
|
254
|
+
if (!parsed.model) return provider?.name ?? parsed.providerId;
|
|
255
|
+
const model = parsed.model.split("/").pop() ?? parsed.model;
|
|
256
|
+
return `${model} \xB7 ${provider?.name ?? parsed.providerId}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/policy.ts
|
|
260
|
+
var WEBMCP_SERVER_NAME = "webmcp";
|
|
261
|
+
var ALLOWED_TOOL_PREFIXES = [`mcp__${WEBMCP_SERVER_NAME}__`, `mcp.${WEBMCP_SERVER_NAME}.`];
|
|
262
|
+
var BUILT_IN_TOOLS = [
|
|
263
|
+
"Agent",
|
|
264
|
+
"Bash",
|
|
265
|
+
"BashOutput",
|
|
266
|
+
"Edit",
|
|
267
|
+
"ExitPlanMode",
|
|
268
|
+
"Glob",
|
|
269
|
+
"Grep",
|
|
270
|
+
"KillShell",
|
|
271
|
+
"MultiEdit",
|
|
272
|
+
"NotebookEdit",
|
|
273
|
+
"Read",
|
|
274
|
+
"Task",
|
|
275
|
+
"TodoWrite",
|
|
276
|
+
"WebFetch",
|
|
277
|
+
"WebSearch",
|
|
278
|
+
"Write"
|
|
279
|
+
];
|
|
280
|
+
function buildSessionMeta(config = {}) {
|
|
281
|
+
const options = {
|
|
282
|
+
tools: [],
|
|
283
|
+
allowedTools: [`mcp__${WEBMCP_SERVER_NAME}`],
|
|
284
|
+
disallowedTools: BUILT_IN_TOOLS,
|
|
285
|
+
settingSources: [],
|
|
286
|
+
strictMcpConfig: true,
|
|
287
|
+
allowDangerouslySkipPermissions: false
|
|
288
|
+
};
|
|
289
|
+
if (config.model) options.model = config.model;
|
|
290
|
+
if (config.maxTurns) options.maxTurns = config.maxTurns;
|
|
291
|
+
const meta = { claudeCode: { options } };
|
|
292
|
+
if (config.systemPrompt) meta.systemPrompt = config.systemPrompt;
|
|
293
|
+
return meta;
|
|
294
|
+
}
|
|
295
|
+
function decidePermission(params) {
|
|
296
|
+
const p = params ?? {};
|
|
297
|
+
const toolName = extractToolName(p);
|
|
298
|
+
const allow = ALLOWED_TOOL_PREFIXES.some(
|
|
299
|
+
(prefix) => toolName.startsWith(prefix)
|
|
300
|
+
);
|
|
301
|
+
const options = Array.isArray(p.options) ? p.options : [];
|
|
302
|
+
const wanted = allow ? ["allow_once", "allow_always"] : ["reject_once", "reject_always"];
|
|
303
|
+
const option = options.find(
|
|
304
|
+
(o) => typeof o.kind === "string" && wanted.includes(o.kind)
|
|
305
|
+
);
|
|
306
|
+
if (option && typeof option.optionId === "string") {
|
|
307
|
+
return {
|
|
308
|
+
allow,
|
|
309
|
+
toolName,
|
|
310
|
+
response: {
|
|
311
|
+
outcome: { outcome: "selected", optionId: option.optionId }
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
allow: false,
|
|
317
|
+
toolName,
|
|
318
|
+
response: { outcome: { outcome: "cancelled" } }
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function extractToolName(p) {
|
|
322
|
+
const toolCall = p.toolCall ?? {};
|
|
323
|
+
const meta = toolCall._meta ?? {};
|
|
324
|
+
const claudeCode = meta.claudeCode ?? {};
|
|
325
|
+
const candidates = [claudeCode.toolName, p.toolName, toolCall.title];
|
|
326
|
+
const name = candidates.find(
|
|
327
|
+
(c) => typeof c === "string" && c.length > 0
|
|
328
|
+
);
|
|
329
|
+
return name ?? "unknown";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/agents.ts
|
|
333
|
+
import { execFile as execFile2 } from "child_process";
|
|
334
|
+
import { createHash } from "crypto";
|
|
335
|
+
import { chmodSync as chmodSync2, existsSync as existsSync3, mkdirSync as mkdirSync4, readdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync3 } from "fs";
|
|
336
|
+
import { dirname, join as join3 } from "path";
|
|
337
|
+
import { promisify as promisify2 } from "util";
|
|
338
|
+
|
|
339
|
+
// src/codex.ts
|
|
340
|
+
import { execFile } from "child_process";
|
|
341
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
342
|
+
import { join as join2 } from "path";
|
|
343
|
+
import { promisify } from "util";
|
|
344
|
+
var execFileAsync = promisify(execFile);
|
|
345
|
+
var ENABLED_FEATURES = /* @__PURE__ */ new Set(["code_mode_host"]);
|
|
346
|
+
function codexHome() {
|
|
347
|
+
return homePath("codex");
|
|
348
|
+
}
|
|
349
|
+
async function prepareCodexHome(agent) {
|
|
350
|
+
const home = codexHome();
|
|
351
|
+
mkdirSync2(home, { recursive: true, mode: 448 });
|
|
352
|
+
const launcher = join2(agent.dir, "node_modules", "@openai", "codex", "bin", "codex.js");
|
|
353
|
+
const stamp = join2(home, ".leuria-config-for");
|
|
354
|
+
if (existsSync2(stamp) && readFileSync2(stamp, "utf-8") === agent.version && existsSync2(join2(home, "config.toml"))) return;
|
|
355
|
+
const { stdout } = await execFileAsync(process.execPath, [launcher, "features", "list"], {
|
|
356
|
+
env: { ...process.env, CODEX_HOME: home, BUN_BE_BUN: "1" },
|
|
357
|
+
timeout: 6e4
|
|
358
|
+
});
|
|
359
|
+
const features = stdout.split("\n").map((line) => line.trim().split(/\s+/)[0] ?? "").filter((name) => /^[a-z0-9_]+$/.test(name));
|
|
360
|
+
if (features.length < 10) throw new Error("Could not read Codex's feature list");
|
|
361
|
+
const lines = [
|
|
362
|
+
"# Generated by Leuria for its locked-down Codex. Do not edit: it is rewritten on upgrade.",
|
|
363
|
+
'sandbox_mode = "read-only"',
|
|
364
|
+
'approval_policy = "never"',
|
|
365
|
+
'web_search = "disabled"',
|
|
366
|
+
"",
|
|
367
|
+
"[features]",
|
|
368
|
+
...features.map((name) => `${name} = ${ENABLED_FEATURES.has(name)}`),
|
|
369
|
+
""
|
|
370
|
+
];
|
|
371
|
+
writeFileSync2(join2(home, "config.toml"), lines.join("\n"), { mode: 384 });
|
|
372
|
+
writeFileSync2(stamp, agent.version);
|
|
373
|
+
}
|
|
374
|
+
var codexProfile = {
|
|
375
|
+
home: codexHome,
|
|
376
|
+
prepare: prepareCodexHome,
|
|
377
|
+
configure: (setup) => ({
|
|
378
|
+
env: {
|
|
379
|
+
// Leuria's own Codex home: its sign-in (through ACP authenticate) lands here.
|
|
380
|
+
CODEX_HOME: codexHome(),
|
|
381
|
+
HOME: setup.sandbox,
|
|
382
|
+
USERPROFILE: setup.sandbox,
|
|
383
|
+
INITIAL_AGENT_MODE: "read-only",
|
|
384
|
+
CODEX_CONFIG: JSON.stringify({
|
|
385
|
+
mcp_servers: {
|
|
386
|
+
[WEBMCP_SERVER_NAME]: {
|
|
387
|
+
url: setup.mcpUrl,
|
|
388
|
+
http_headers: { Authorization: `Bearer ${setup.mcpToken}` },
|
|
389
|
+
default_tools_approval_mode: "approve"
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
...setup.systemPrompt ? { developer_instructions: setup.systemPrompt } : {}
|
|
393
|
+
})
|
|
394
|
+
},
|
|
395
|
+
// Declared through CODEX_CONFIG above, with approval; not through ACP.
|
|
396
|
+
mcpServers: [],
|
|
397
|
+
meta: {}
|
|
398
|
+
})
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// src/gemini.ts
|
|
402
|
+
import { mkdirSync as mkdirSync3 } from "fs";
|
|
403
|
+
function geminiHome() {
|
|
404
|
+
return homePath("gemini");
|
|
405
|
+
}
|
|
406
|
+
var geminiProfile = {
|
|
407
|
+
home: geminiHome,
|
|
408
|
+
configure: () => {
|
|
409
|
+
mkdirSync3(geminiHome(), { recursive: true, mode: 448 });
|
|
410
|
+
return { env: { GEMINI_CLI_HOME: geminiHome(), GEMINI_FORCE_FILE_STORAGE: "true" } };
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
// src/profiles.ts
|
|
415
|
+
var PROFILES = {
|
|
416
|
+
"codex-acp": codexProfile,
|
|
417
|
+
gemini: geminiProfile
|
|
418
|
+
};
|
|
419
|
+
function profileFor(id) {
|
|
420
|
+
return PROFILES[id];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/agents.ts
|
|
424
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
425
|
+
var MANIFEST = "leuria-agent.json";
|
|
426
|
+
function agentsDir() {
|
|
427
|
+
return homePath("agents");
|
|
428
|
+
}
|
|
429
|
+
function installedAgent(id) {
|
|
430
|
+
const root = agentsDir();
|
|
431
|
+
if (!existsSync3(root)) return null;
|
|
432
|
+
const versions = readdirSync(root).filter((name) => name.startsWith(`${id}@`) && existsSync3(join3(root, name, MANIFEST))).sort(compareVersions);
|
|
433
|
+
const latest = versions[versions.length - 1];
|
|
434
|
+
if (!latest) return null;
|
|
435
|
+
const agent = JSON.parse(readFileSync3(join3(root, latest, MANIFEST), "utf-8"));
|
|
436
|
+
return { ...agent, launchArgs: agent.launchArgs ?? [] };
|
|
437
|
+
}
|
|
438
|
+
function ensureAgent(id, options = {}) {
|
|
439
|
+
const key = `${id}:${options.update ? "update" : "any"}`;
|
|
440
|
+
const running = installing.get(key);
|
|
441
|
+
if (running) return running;
|
|
442
|
+
const work = ensureAgentOnce(id, options).finally(() => installing.delete(key));
|
|
443
|
+
installing.set(key, work);
|
|
444
|
+
return work;
|
|
445
|
+
}
|
|
446
|
+
var installing = /* @__PURE__ */ new Map();
|
|
447
|
+
async function ensureAgentOnce(id, options) {
|
|
448
|
+
const installed = installedAgent(id);
|
|
449
|
+
if (installed && !options.update) return installed;
|
|
450
|
+
const entry = await getRegistryEntry(id);
|
|
451
|
+
if (!entry) {
|
|
452
|
+
if (installed) return installed;
|
|
453
|
+
throw new Error(`"${id}" is not in the ACP registry (or the registry is unreachable). Run \`leuria agents\` to list agents.`);
|
|
454
|
+
}
|
|
455
|
+
if (installed?.version === entry.version) return installed;
|
|
456
|
+
options.onProgress?.(`Installing ${entry.name} ${entry.version} from the ACP registry\u2026`);
|
|
457
|
+
const agent = await install(entry);
|
|
458
|
+
await profileFor(id)?.prepare?.(agent);
|
|
459
|
+
return agent;
|
|
460
|
+
}
|
|
461
|
+
async function resolveAgentCommand(id, model) {
|
|
462
|
+
if (isLlmId(id)) {
|
|
463
|
+
const parsed = parseLlmId(id);
|
|
464
|
+
const provider = parsed && getProvider(parsed.providerId);
|
|
465
|
+
if (!parsed || !provider) throw new Error(`Unknown AI "${id}". Choose it again in Leuria.`);
|
|
466
|
+
const chosen = parsed.model ?? model ?? loadConfig().models?.[id] ?? defaultModel(await listModels(provider));
|
|
467
|
+
if (!chosen) throw new Error(`${provider.name} has no model to answer with. Open it and load one.`);
|
|
468
|
+
return { id, command: "", args: [], llm: { provider, model: chosen }, name: llmDisplayName(id) };
|
|
469
|
+
}
|
|
470
|
+
const agent = await ensureAgent(id);
|
|
471
|
+
return {
|
|
472
|
+
id,
|
|
473
|
+
command: agent.command,
|
|
474
|
+
args: [...agent.launchArgs, ...agent.args],
|
|
475
|
+
env: agent.env,
|
|
476
|
+
model: model ?? loadConfig().models?.[id],
|
|
477
|
+
name: agent.name,
|
|
478
|
+
configure: profileFor(id)?.configure
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function resolveSiteAgent(grant, defaultAgent) {
|
|
482
|
+
const id = grant?.agent ?? defaultAgent;
|
|
483
|
+
return resolveAgentCommand(id, grant?.model?.agent === id ? grant.model.id : void 0);
|
|
484
|
+
}
|
|
485
|
+
var DISPLAY_NAMES = {
|
|
486
|
+
"codex-acp": "ChatGPT (Codex)",
|
|
487
|
+
"claude-acp": "Claude",
|
|
488
|
+
gemini: "Gemini",
|
|
489
|
+
"github-copilot-cli": "GitHub Copilot"
|
|
490
|
+
};
|
|
491
|
+
function agentName(id) {
|
|
492
|
+
if (isLlmId(id)) return llmDisplayName(id);
|
|
493
|
+
return DISPLAY_NAMES[id] ?? installedAgent(id)?.name ?? id;
|
|
494
|
+
}
|
|
495
|
+
function resetAgent(id) {
|
|
496
|
+
if (isLlmId(id)) return;
|
|
497
|
+
const home = profileFor(id)?.home?.();
|
|
498
|
+
if (home) rmSync(home, { recursive: true, force: true });
|
|
499
|
+
const root = agentsDir();
|
|
500
|
+
if (!existsSync3(root)) return;
|
|
501
|
+
for (const name of readdirSync(root)) {
|
|
502
|
+
if (name.startsWith(`${id}@`)) rmSync(join3(root, name), { recursive: true, force: true });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
function isAvailable(id) {
|
|
506
|
+
if (isLlmId(id)) {
|
|
507
|
+
const parsed = parseLlmId(id);
|
|
508
|
+
return Boolean(parsed && getProvider(parsed.providerId));
|
|
509
|
+
}
|
|
510
|
+
return installedAgent(id) !== null;
|
|
511
|
+
}
|
|
512
|
+
async function listAgents() {
|
|
513
|
+
return (await fetchRegistry()).map((entry) => ({ ...entry, installed: installedAgent(entry.id)?.version }));
|
|
514
|
+
}
|
|
515
|
+
async function install(entry) {
|
|
516
|
+
const dir = join3(agentsDir(), `${entry.id}@${entry.version}`);
|
|
517
|
+
rmSync(dir, { recursive: true, force: true });
|
|
518
|
+
mkdirSync4(dir, { recursive: true });
|
|
519
|
+
const d = entry.distribution;
|
|
520
|
+
const binary = d.binary?.[platformKey()];
|
|
521
|
+
let launch;
|
|
522
|
+
try {
|
|
523
|
+
if (d.npx) launch = await installPackage(dir, d.npx.package, d.npx.args ?? [], d.npx.env ?? {});
|
|
524
|
+
else if (binary) launch = await installBinary(dir, binary);
|
|
525
|
+
else if (d.uvx) launch = { command: "uvx", launchArgs: [d.uvx.package], args: d.uvx.args ?? [], env: d.uvx.env ?? {} };
|
|
526
|
+
else throw new Error(`${entry.name} has no distribution for ${platformKey()}`);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
rmSync(dir, { recursive: true, force: true });
|
|
529
|
+
throw error;
|
|
530
|
+
}
|
|
531
|
+
const agent = { id: entry.id, name: entry.name, version: entry.version, dir, ...launch };
|
|
532
|
+
writeFileSync3(join3(dir, MANIFEST), `${JSON.stringify(agent, null, 2)}
|
|
533
|
+
`);
|
|
534
|
+
return agent;
|
|
535
|
+
}
|
|
536
|
+
async function installPackage(dir, spec, args, env) {
|
|
537
|
+
writeFileSync3(join3(dir, "package.json"), '{ "private": true }\n');
|
|
538
|
+
if (process.versions.bun) {
|
|
539
|
+
await execFileAsync2(process.execPath, ["add", spec], { cwd: dir, env: { ...process.env, BUN_BE_BUN: "1" }, timeout: 5 * 6e4 });
|
|
540
|
+
} else {
|
|
541
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
542
|
+
await execFileAsync2(npm, ["install", "--no-audit", "--no-fund", "--omit=dev", "--loglevel=error", spec], {
|
|
543
|
+
cwd: dir,
|
|
544
|
+
timeout: 5 * 6e4,
|
|
545
|
+
shell: process.platform === "win32"
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
const name = spec.lastIndexOf("@") > 0 ? spec.slice(0, spec.lastIndexOf("@")) : spec;
|
|
549
|
+
const pkgDir = join3(dir, "node_modules", ...name.split("/"));
|
|
550
|
+
const manifest = JSON.parse(readFileSync3(join3(pkgDir, "package.json"), "utf-8"));
|
|
551
|
+
const bins = typeof manifest.bin === "string" ? { [name]: manifest.bin } : manifest.bin ?? {};
|
|
552
|
+
const binName = Object.keys(bins).find((b) => name.endsWith(b)) ?? Object.keys(bins)[0];
|
|
553
|
+
if (!binName) throw new Error(`${spec} has no executable`);
|
|
554
|
+
const entry = join3(pkgDir, bins[binName]);
|
|
555
|
+
const isScript = /\.[cm]?js$/.test(entry) || /^#!.*\b(node|bun)\b/.test(readFileSync3(entry, "utf-8").slice(0, 100));
|
|
556
|
+
return isScript ? { command: process.execPath, launchArgs: [entry], args, env } : { command: entry, launchArgs: [], args, env };
|
|
557
|
+
}
|
|
558
|
+
async function installBinary(dir, target) {
|
|
559
|
+
const res = await fetch(target.archive, { signal: AbortSignal.timeout(5 * 6e4) });
|
|
560
|
+
if (!res.ok) throw new Error(`Download failed (${res.status}): ${target.archive}`);
|
|
561
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
562
|
+
if (target.sha256) {
|
|
563
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
564
|
+
if (actual !== target.sha256.toLowerCase()) throw new Error(`Checksum mismatch for ${target.archive}`);
|
|
565
|
+
}
|
|
566
|
+
const name = new URL(target.archive).pathname.split("/").pop() ?? "archive";
|
|
567
|
+
const command = join3(dir, target.cmd.replace(/^\.\//, ""));
|
|
568
|
+
if (/\.zip$/i.test(name)) {
|
|
569
|
+
const file = join3(dir, name);
|
|
570
|
+
writeFileSync3(file, bytes);
|
|
571
|
+
if (process.platform === "linux") await execFileAsync2("unzip", ["-q", file, "-d", dir]);
|
|
572
|
+
else await execFileAsync2("tar", ["-xf", file, "-C", dir]);
|
|
573
|
+
} else if (/\.(tar\.gz|tgz|tar\.bz2|tbz2)$/i.test(name)) {
|
|
574
|
+
const file = join3(dir, name);
|
|
575
|
+
writeFileSync3(file, bytes);
|
|
576
|
+
await execFileAsync2("tar", ["-xf", file, "-C", dir]);
|
|
577
|
+
} else {
|
|
578
|
+
mkdirSync4(dirname(command), { recursive: true });
|
|
579
|
+
writeFileSync3(command, bytes);
|
|
580
|
+
}
|
|
581
|
+
if (!existsSync3(command)) throw new Error(`${target.cmd} not found in ${target.archive}`);
|
|
582
|
+
if (process.platform !== "win32") chmodSync2(command, 493);
|
|
583
|
+
return { command, launchArgs: [], args: target.args ?? [], env: target.env ?? {} };
|
|
584
|
+
}
|
|
585
|
+
function compareVersions(a, b) {
|
|
586
|
+
const parse = (s) => (s.split("@").pop() ?? "").split(/[.-]/).map((p) => /^\d+$/.test(p) ? Number(p) : p);
|
|
587
|
+
const pa = parse(a);
|
|
588
|
+
const pb = parse(b);
|
|
589
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
590
|
+
const x = pa[i] ?? 0;
|
|
591
|
+
const y = pb[i] ?? 0;
|
|
592
|
+
if (x === y) continue;
|
|
593
|
+
if (typeof x === "number" && typeof y === "number") return x - y;
|
|
594
|
+
return String(x) < String(y) ? -1 : 1;
|
|
595
|
+
}
|
|
596
|
+
return 0;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// src/version.ts
|
|
600
|
+
var VERSION = true ? "0.1.0" : "0.0.0-dev";
|
|
601
|
+
|
|
602
|
+
// src/auth.ts
|
|
603
|
+
import { spawn as spawn2 } from "child_process";
|
|
604
|
+
import { mkdtempSync as mkdtempSync2, rmSync as rmSync3 } from "fs";
|
|
605
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
606
|
+
import { join as join5 } from "path";
|
|
607
|
+
|
|
608
|
+
// src/acp/acp-client.ts
|
|
609
|
+
import { spawn } from "child_process";
|
|
610
|
+
import { Readable, Writable } from "stream";
|
|
611
|
+
var rawParams = {
|
|
612
|
+
// biome-ignore lint/suspicious/noExplicitAny: ACP payloads are untyped here
|
|
613
|
+
parse: (value) => value
|
|
614
|
+
};
|
|
615
|
+
function readModels(source) {
|
|
616
|
+
const configOptions = Array.isArray(source?.configOptions) ? source.configOptions : [];
|
|
617
|
+
const option = configOptions.find((o) => o?.category === "model" && o?.type !== "boolean" && Array.isArray(o?.options));
|
|
618
|
+
if (option) {
|
|
619
|
+
const flat = option.options.flatMap((o) => Array.isArray(o?.options) ? o.options : [o]);
|
|
620
|
+
return {
|
|
621
|
+
configId: String(option.id),
|
|
622
|
+
current: typeof option.currentValue === "string" ? option.currentValue : void 0,
|
|
623
|
+
options: flat.filter((o) => typeof o?.value === "string").map((o) => ({ id: o.value, name: String(o.name ?? o.value), ...o.description ? { description: String(o.description) } : {} }))
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
const legacy = source?.models;
|
|
627
|
+
if (Array.isArray(legacy?.availableModels) && legacy.availableModels.length) {
|
|
628
|
+
return {
|
|
629
|
+
current: typeof legacy.currentModelId === "string" ? legacy.currentModelId : void 0,
|
|
630
|
+
options: legacy.availableModels.filter((m) => typeof m?.modelId === "string").map((m) => ({ id: m.modelId, name: String(m.name ?? m.modelId), ...m.description ? { description: String(m.description) } : {} }))
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
var REMOVED_ENV_VARS = ["CLAUDECODE"];
|
|
636
|
+
var AUTH_REQUIRED = -32e3;
|
|
637
|
+
function errorText(error) {
|
|
638
|
+
if (error instanceof Error) return error.message;
|
|
639
|
+
if (error && typeof error === "object" && "message" in error) return String(error.message);
|
|
640
|
+
return String(error);
|
|
641
|
+
}
|
|
642
|
+
var AcpLiveSession = class {
|
|
643
|
+
constructor(options) {
|
|
644
|
+
this.options = options;
|
|
645
|
+
}
|
|
646
|
+
options;
|
|
647
|
+
child = null;
|
|
648
|
+
// biome-ignore lint/suspicious/noExplicitAny: SDK connection type is internal
|
|
649
|
+
connection = null;
|
|
650
|
+
acpSessionId = null;
|
|
651
|
+
alive = false;
|
|
652
|
+
imagesSupported = false;
|
|
653
|
+
/** `initialize` result, once connected. */
|
|
654
|
+
info = null;
|
|
655
|
+
/** Models the agent offers, from `session/new` (and later updates). */
|
|
656
|
+
models = null;
|
|
657
|
+
get isAlive() {
|
|
658
|
+
return this.alive && this.child !== null && !this.child.killed;
|
|
659
|
+
}
|
|
660
|
+
/** Spawn the agent, initialize ACP and create a session. */
|
|
661
|
+
async start() {
|
|
662
|
+
const connected = await this.connect();
|
|
663
|
+
if (connected.error) return connected;
|
|
664
|
+
return this.newSession();
|
|
665
|
+
}
|
|
666
|
+
/** Spawn the agent and run `initialize`. */
|
|
667
|
+
async connect() {
|
|
668
|
+
const sdk = await import("@agentclientprotocol/sdk");
|
|
669
|
+
const env = { ...process.env, ...this.options.envVars };
|
|
670
|
+
for (const key of REMOVED_ENV_VARS) delete env[key];
|
|
671
|
+
if (process.versions.bun) env.BUN_BE_BUN = "1";
|
|
672
|
+
const child = spawn(this.options.command, this.options.args, {
|
|
673
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
674
|
+
env,
|
|
675
|
+
cwd: this.options.cwd,
|
|
676
|
+
shell: process.platform === "win32",
|
|
677
|
+
// Its own process group, so close() also stops what the agent starts
|
|
678
|
+
// (Gemini relaunches itself as a child process).
|
|
679
|
+
detached: process.platform !== "win32"
|
|
680
|
+
});
|
|
681
|
+
this.child = child;
|
|
682
|
+
child.on("error", (err) => this.options.onSpawnError?.(err));
|
|
683
|
+
child.stdin?.on("error", (err) => {
|
|
684
|
+
if (err.code !== "EPIPE" && err.code !== "ERR_STREAM_DESTROYED") {
|
|
685
|
+
this.options.onSpawnError?.(err);
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
child.on("exit", (code, signal) => {
|
|
689
|
+
this.alive = false;
|
|
690
|
+
this.options.onExit?.(code, signal);
|
|
691
|
+
});
|
|
692
|
+
child.stderr?.on("data", (chunk) => {
|
|
693
|
+
const text = chunk.toString().trim();
|
|
694
|
+
if (text) this.options.onStderr?.(text);
|
|
695
|
+
});
|
|
696
|
+
try {
|
|
697
|
+
const stream = sdk.ndJsonStream(
|
|
698
|
+
Writable.toWeb(child.stdin),
|
|
699
|
+
Readable.toWeb(child.stdout)
|
|
700
|
+
);
|
|
701
|
+
this.connection = this.buildClientApp(sdk).connect(stream);
|
|
702
|
+
const init = await this.connection.agent.request("initialize", {
|
|
703
|
+
protocolVersion: sdk.PROTOCOL_VERSION,
|
|
704
|
+
clientInfo: { name: "leuria", version: VERSION },
|
|
705
|
+
clientCapabilities: {
|
|
706
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
707
|
+
terminal: false,
|
|
708
|
+
auth: { terminal: this.options.terminalAuth === true }
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
this.imagesSupported = init?.agentCapabilities?.promptCapabilities?.image === true;
|
|
712
|
+
this.info = {
|
|
713
|
+
authMethods: init.authMethods ?? [],
|
|
714
|
+
agentCapabilities: init.agentCapabilities ?? {},
|
|
715
|
+
agentInfo: init.agentInfo
|
|
716
|
+
};
|
|
717
|
+
return {};
|
|
718
|
+
} catch (error) {
|
|
719
|
+
this.close();
|
|
720
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/** ACP `authenticate` with one of the advertised methods (the agent runs its own flow). */
|
|
724
|
+
async authenticate(methodId) {
|
|
725
|
+
if (!this.connection) return { error: "Not connected" };
|
|
726
|
+
try {
|
|
727
|
+
await this.connection.agent.request("authenticate", { methodId });
|
|
728
|
+
return {};
|
|
729
|
+
} catch (error) {
|
|
730
|
+
return { error: errorText(error) };
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
/** ACP `session/new`. An `auth_required` error means the visitor must sign in. */
|
|
734
|
+
async newSession() {
|
|
735
|
+
if (!this.connection) return { error: "Not connected" };
|
|
736
|
+
try {
|
|
737
|
+
const mcp = this.options.mcpServers;
|
|
738
|
+
const mcpCapabilities = this.info?.agentCapabilities.mcpCapabilities ?? {};
|
|
739
|
+
const session = await this.connection.agent.request("session/new", {
|
|
740
|
+
cwd: this.options.cwd,
|
|
741
|
+
mcpServers: typeof mcp === "function" ? mcp(mcpCapabilities) : mcp ?? [],
|
|
742
|
+
_meta: this.options.sessionMeta ?? buildSessionMeta(this.options.sessionConfig)
|
|
743
|
+
});
|
|
744
|
+
this.acpSessionId = session.sessionId;
|
|
745
|
+
this.alive = true;
|
|
746
|
+
this.models = readModels(session) ?? this.models;
|
|
747
|
+
const wanted = this.options.model;
|
|
748
|
+
if (wanted && wanted !== this.models?.current && this.models?.options.some((m) => m.id === wanted)) {
|
|
749
|
+
const set = await this.setModel(wanted);
|
|
750
|
+
if (set.error) this.options.onStderr?.(`could not switch to model ${wanted}: ${set.error}`);
|
|
751
|
+
}
|
|
752
|
+
return {};
|
|
753
|
+
} catch (error) {
|
|
754
|
+
const code = error?.code;
|
|
755
|
+
const text = errorText(error);
|
|
756
|
+
this.close();
|
|
757
|
+
const aboutSignIn = /auth|sign.?in|log.?in|api.?key|credential|unauthori[sz]ed/i.test(text);
|
|
758
|
+
return { error: text, authRequired: aboutSignIn || code === AUTH_REQUIRED && !text.trim() };
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
/** Switch the session's model: ACP v1 `session/set_config_option`, or the older `session/set_model`. */
|
|
762
|
+
async setModel(modelId) {
|
|
763
|
+
if (!this.connection || !this.acpSessionId) return { error: "No session" };
|
|
764
|
+
try {
|
|
765
|
+
if (this.models?.configId) {
|
|
766
|
+
const res = await this.connection.agent.request("session/set_config_option", {
|
|
767
|
+
sessionId: this.acpSessionId,
|
|
768
|
+
configId: this.models.configId,
|
|
769
|
+
value: modelId
|
|
770
|
+
});
|
|
771
|
+
this.models = readModels(res) ?? { ...this.models, current: modelId };
|
|
772
|
+
} else {
|
|
773
|
+
await this.connection.agent.request("session/set_model", { sessionId: this.acpSessionId, modelId });
|
|
774
|
+
if (this.models) this.models = { ...this.models, current: modelId };
|
|
775
|
+
}
|
|
776
|
+
return {};
|
|
777
|
+
} catch (error) {
|
|
778
|
+
return { error: errorText(error) };
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
/** Send one prompt turn; resolves when the agent ends the turn. */
|
|
782
|
+
async prompt(text, attachments = []) {
|
|
783
|
+
if (!this.isAlive || !this.connection || !this.acpSessionId) {
|
|
784
|
+
return { error: "Session is not alive" };
|
|
785
|
+
}
|
|
786
|
+
try {
|
|
787
|
+
await this.connection.agent.request("session/prompt", {
|
|
788
|
+
sessionId: this.acpSessionId,
|
|
789
|
+
prompt: this.promptBlocks(text, attachments)
|
|
790
|
+
});
|
|
791
|
+
return {};
|
|
792
|
+
} catch (error) {
|
|
793
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
/** ACP content blocks: the text, then each attachment. */
|
|
797
|
+
promptBlocks(text, attachments) {
|
|
798
|
+
const blocks = [];
|
|
799
|
+
if (text) blocks.push({ type: "text", text });
|
|
800
|
+
for (const file of attachments) {
|
|
801
|
+
if (file.type === "image" && this.imagesSupported) {
|
|
802
|
+
blocks.push({ type: "image", mimeType: file.mimeType, data: file.data });
|
|
803
|
+
} else if (file.type === "image") {
|
|
804
|
+
blocks.push({ type: "text", text: `[Image ${file.name ?? ""} omitted: this agent does not accept images]` });
|
|
805
|
+
} else {
|
|
806
|
+
blocks.push({ type: "text", text: `<file name="${file.name ?? "attachment"}">
|
|
807
|
+
${file.text}
|
|
808
|
+
</file>` });
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
return blocks;
|
|
812
|
+
}
|
|
813
|
+
/** Ask the agent to stop the current turn, keeping the session. */
|
|
814
|
+
async cancelTurn() {
|
|
815
|
+
if (!this.connection || !this.acpSessionId) return;
|
|
816
|
+
try {
|
|
817
|
+
await this.connection.agent.notify("session/cancel", {
|
|
818
|
+
sessionId: this.acpSessionId
|
|
819
|
+
});
|
|
820
|
+
} catch {
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
close() {
|
|
824
|
+
this.alive = false;
|
|
825
|
+
try {
|
|
826
|
+
this.connection?.close();
|
|
827
|
+
} catch {
|
|
828
|
+
}
|
|
829
|
+
if (this.child && !this.child.killed) killTree(this.child);
|
|
830
|
+
this.child = null;
|
|
831
|
+
this.connection = null;
|
|
832
|
+
this.acpSessionId = null;
|
|
833
|
+
}
|
|
834
|
+
buildClientApp(sdk) {
|
|
835
|
+
const { onChunk, onThought, onToolCall, onPermission } = this.options;
|
|
836
|
+
const handleSessionUpdate = (params) => {
|
|
837
|
+
const update = params?.update;
|
|
838
|
+
if (!update) return;
|
|
839
|
+
const type = update.sessionUpdate;
|
|
840
|
+
if (type === "config_option_update") {
|
|
841
|
+
this.models = readModels(update) ?? this.models;
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (type === "agent_message_chunk" && update.content?.type === "text") {
|
|
845
|
+
const text = update.content.text ?? "";
|
|
846
|
+
if (text) onChunk?.(text);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (type === "agent_thought_chunk" && update.content?.type === "text") {
|
|
850
|
+
const text = update.content.text ?? "";
|
|
851
|
+
if (text) onThought?.(text);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (type === "tool_call" || type === "tool_call_update") {
|
|
855
|
+
onToolCall?.({
|
|
856
|
+
toolCallId: String(update.toolCallId ?? ""),
|
|
857
|
+
title: typeof update.title === "string" ? update.title : void 0,
|
|
858
|
+
kind: typeof update.kind === "string" ? update.kind : void 0,
|
|
859
|
+
status: typeof update.status === "string" ? update.status : void 0,
|
|
860
|
+
toolName: typeof update._meta?.claudeCode?.toolName === "string" ? update._meta.claudeCode.toolName : void 0
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
return sdk.client({ name: "leuria" }).onNotification(
|
|
865
|
+
"session/update",
|
|
866
|
+
rawParams,
|
|
867
|
+
(ctx) => handleSessionUpdate(ctx.params)
|
|
868
|
+
).onRequest("session/request_permission", rawParams, (ctx) => {
|
|
869
|
+
const decision = decidePermission(ctx.params);
|
|
870
|
+
onPermission?.(decision);
|
|
871
|
+
return decision.response;
|
|
872
|
+
}).onRequest("session/prompt", rawParams, () => ({
|
|
873
|
+
stopReason: "cancelled"
|
|
874
|
+
}));
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
function killTree(child) {
|
|
878
|
+
try {
|
|
879
|
+
if (process.platform === "win32" && child.pid) {
|
|
880
|
+
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
881
|
+
} else if (child.pid) {
|
|
882
|
+
process.kill(-child.pid, "SIGTERM");
|
|
883
|
+
} else {
|
|
884
|
+
child.kill();
|
|
885
|
+
}
|
|
886
|
+
} catch {
|
|
887
|
+
try {
|
|
888
|
+
child.kill();
|
|
889
|
+
} catch {
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// src/browser-link.ts
|
|
895
|
+
import { execFileSync } from "child_process";
|
|
896
|
+
import { chmodSync as chmodSync3, existsSync as existsSync4, mkdirSync as mkdirSync5, mkdtempSync, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
897
|
+
import { tmpdir } from "os";
|
|
898
|
+
import { delimiter, join as join4 } from "path";
|
|
899
|
+
function realCommand(name) {
|
|
900
|
+
if (name === "open" && existsSync4("/usr/bin/open")) return "/usr/bin/open";
|
|
901
|
+
try {
|
|
902
|
+
return execFileSync("/bin/sh", ["-c", `command -v ${name}`], { encoding: "utf-8" }).trim() || null;
|
|
903
|
+
} catch {
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
function catchBrowserLinks(onUrl, options = {}) {
|
|
908
|
+
if (process.platform === "win32") return null;
|
|
909
|
+
const basePath = options.basePath ?? process.env.PATH ?? "";
|
|
910
|
+
const findReal = options.real ?? realCommand;
|
|
911
|
+
const dir = mkdtempSync(join4(tmpdir(), "leuria-open-"));
|
|
912
|
+
const bin = join4(dir, "bin");
|
|
913
|
+
const log = join4(dir, "opened.txt");
|
|
914
|
+
mkdirSync5(bin);
|
|
915
|
+
writeFileSync4(log, "");
|
|
916
|
+
for (const name of ["open", "xdg-open"]) {
|
|
917
|
+
const real = findReal(name);
|
|
918
|
+
if (!real) continue;
|
|
919
|
+
const script = `#!/bin/sh
|
|
920
|
+
for arg in "$@"; do printf '%s\\n' "$arg" >> '${log}'; done
|
|
921
|
+
exec '${real}' "$@"
|
|
922
|
+
`;
|
|
923
|
+
writeFileSync4(join4(bin, name), script);
|
|
924
|
+
chmodSync3(join4(bin, name), 493);
|
|
925
|
+
}
|
|
926
|
+
const seen = /* @__PURE__ */ new Set();
|
|
927
|
+
const timer = setInterval(() => {
|
|
928
|
+
let lines;
|
|
929
|
+
try {
|
|
930
|
+
lines = readFileSync4(log, "utf-8").split("\n");
|
|
931
|
+
} catch {
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
for (const line of lines) {
|
|
935
|
+
if (/^https?:\/\//.test(line) && !seen.has(line)) {
|
|
936
|
+
seen.add(line);
|
|
937
|
+
onUrl(line);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}, 300);
|
|
941
|
+
return {
|
|
942
|
+
env: { PATH: `${bin}${delimiter}${basePath}` },
|
|
943
|
+
stop() {
|
|
944
|
+
clearInterval(timer);
|
|
945
|
+
rmSync2(dir, { recursive: true, force: true });
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// src/auth.ts
|
|
951
|
+
function knownModels() {
|
|
952
|
+
return readJson(homePath("models.json")) ?? {};
|
|
953
|
+
}
|
|
954
|
+
function rememberModels(id, models) {
|
|
955
|
+
const all = knownModels();
|
|
956
|
+
if (JSON.stringify(all[id]) === JSON.stringify(models)) return;
|
|
957
|
+
writeJson(homePath("models.json"), { ...all, [id]: models });
|
|
958
|
+
}
|
|
959
|
+
async function agentModels(id) {
|
|
960
|
+
if (isLlmId(id)) {
|
|
961
|
+
const parsed = parseLlmId(id);
|
|
962
|
+
const provider = parsed && !parsed.model ? getProvider(parsed.providerId) : void 0;
|
|
963
|
+
if (!provider) return null;
|
|
964
|
+
const models = await listModels(provider).catch(() => null);
|
|
965
|
+
return models?.length ? withChoice(id, llmModels(models, id)).models ?? null : null;
|
|
966
|
+
}
|
|
967
|
+
if (!knownModels()[id]) await checkSignIn(id);
|
|
968
|
+
return withChoice(id, knownModels()[id] ?? null).models ?? null;
|
|
969
|
+
}
|
|
970
|
+
function seenAgentModels(id) {
|
|
971
|
+
return withChoice(id, knownModels()[id] ?? null).models ?? null;
|
|
972
|
+
}
|
|
973
|
+
function aiLabel(id, model) {
|
|
974
|
+
const name = agentName(id);
|
|
975
|
+
if (isLlmId(id) && parseLlmId(id)?.model) return name;
|
|
976
|
+
if (isLlmId(id)) return model ? `${name} \xB7 ${model.split("/").pop()}` : name;
|
|
977
|
+
if (!model) return name;
|
|
978
|
+
const known = knownModels()[id]?.options.find((m) => m.id === model);
|
|
979
|
+
return known ? `${name} \xB7 ${known.name}` : name;
|
|
980
|
+
}
|
|
981
|
+
function rememberAgentState(id, state) {
|
|
982
|
+
if (isLlmId(id)) return;
|
|
983
|
+
rememberReady(id, state.ok);
|
|
984
|
+
if (state.models?.options.length) rememberModels(id, state.models);
|
|
985
|
+
}
|
|
986
|
+
function llmModels(models, id) {
|
|
987
|
+
return {
|
|
988
|
+
current: loadConfig().models?.[id] ?? defaultModel(models),
|
|
989
|
+
options: models.map((m) => ({
|
|
990
|
+
id: m.id,
|
|
991
|
+
name: m.id.split("/").pop() ?? m.id,
|
|
992
|
+
...m.loaded ? { description: "Loaded: answers at once" } : m.remote ? { description: "Runs online" } : {}
|
|
993
|
+
}))
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
function withChoice(id, models) {
|
|
997
|
+
if (models?.options.length) rememberModels(id, models);
|
|
998
|
+
if (!models || models.options.length === 0) return {};
|
|
999
|
+
const chosen = loadConfig().models?.[id];
|
|
1000
|
+
const current = chosen && models.options.some((m) => m.id === chosen) ? chosen : models.current;
|
|
1001
|
+
return { models: { ...models, current } };
|
|
1002
|
+
}
|
|
1003
|
+
function isTerminal(method) {
|
|
1004
|
+
return "type" in method && method.type === "terminal";
|
|
1005
|
+
}
|
|
1006
|
+
function agentMethods(methods) {
|
|
1007
|
+
return methods.filter((m) => !isTerminal(m));
|
|
1008
|
+
}
|
|
1009
|
+
function preferredMethod(methods, options = {}) {
|
|
1010
|
+
const usable = options.terminal ? methods : agentMethods(methods);
|
|
1011
|
+
const isKey = (m) => /api.?key/i.test(`${m.id} ${m.name}`) || Boolean(m._meta?.["api-key"]);
|
|
1012
|
+
return usable.find((m) => !isKey(m)) ?? usable[0];
|
|
1013
|
+
}
|
|
1014
|
+
async function open(id, onProgress, terminalAuth = false, extraEnv = {}) {
|
|
1015
|
+
const agent = await ensureAgent(id, { onProgress });
|
|
1016
|
+
const sandbox = mkdtempSync2(join5(tmpdir2(), "leuria-auth-"));
|
|
1017
|
+
const custom = profileFor(id)?.configure?.({ sandbox, mcpUrl: "http://127.0.0.1:9/unused", mcpToken: "unused" });
|
|
1018
|
+
const env = { ...agent.env, ...custom?.env, ...extraEnv };
|
|
1019
|
+
const session = new AcpLiveSession({
|
|
1020
|
+
command: agent.command,
|
|
1021
|
+
args: [...agent.launchArgs, ...agent.args],
|
|
1022
|
+
cwd: sandbox,
|
|
1023
|
+
envVars: env,
|
|
1024
|
+
mcpServers: [],
|
|
1025
|
+
sessionMeta: custom?.meta,
|
|
1026
|
+
terminalAuth
|
|
1027
|
+
});
|
|
1028
|
+
const close = () => {
|
|
1029
|
+
session.close();
|
|
1030
|
+
rmSync3(sandbox, { recursive: true, force: true });
|
|
1031
|
+
};
|
|
1032
|
+
return { agent, session, close, env, sandbox };
|
|
1033
|
+
}
|
|
1034
|
+
async function checkLlm(id) {
|
|
1035
|
+
const parsed = parseLlmId(id);
|
|
1036
|
+
const provider = parsed && getProvider(parsed.providerId);
|
|
1037
|
+
if (!parsed || !provider) return { ok: false, detail: "This AI is not set up anymore. Choose it again.", methods: [] };
|
|
1038
|
+
try {
|
|
1039
|
+
const models = await listModels(provider);
|
|
1040
|
+
if (parsed.model && !models.some((m) => m.id === parsed.model)) {
|
|
1041
|
+
return { ok: false, detail: `${provider.name} doesn't have "${parsed.model}" anymore.`, methods: [] };
|
|
1042
|
+
}
|
|
1043
|
+
if (models.length === 0) return { ok: false, detail: `${provider.name} has no model to answer with. Open it and load one.`, methods: [] };
|
|
1044
|
+
return { ok: true, detail: "ready", methods: [], ...parsed.model ? {} : withChoice(id, llmModels(models, id)) };
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
return { ok: false, detail: error instanceof Error ? error.message : String(error), methods: [] };
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
function checkTimeoutMs() {
|
|
1050
|
+
const value = Number(process.env.LEURIA_CHECK_TIMEOUT_MS);
|
|
1051
|
+
return Number.isFinite(value) && value > 0 ? value : 15e3;
|
|
1052
|
+
}
|
|
1053
|
+
async function checkSignIn(id, onProgress) {
|
|
1054
|
+
if (isLlmId(id)) return checkLlm(id);
|
|
1055
|
+
const { session, close } = await open(id, onProgress);
|
|
1056
|
+
let timer;
|
|
1057
|
+
const timedOut = new Promise((resolve) => {
|
|
1058
|
+
timer = setTimeout(() => {
|
|
1059
|
+
resolve({ ok: false, detail: `${agentName(id)} didn't answer. It may need to be set up in its own app first.`, methods: [] });
|
|
1060
|
+
}, checkTimeoutMs());
|
|
1061
|
+
});
|
|
1062
|
+
try {
|
|
1063
|
+
const status = await Promise.race([timedOut, check()]);
|
|
1064
|
+
rememberReady(id, status.ok);
|
|
1065
|
+
return status;
|
|
1066
|
+
} finally {
|
|
1067
|
+
clearTimeout(timer);
|
|
1068
|
+
close();
|
|
1069
|
+
}
|
|
1070
|
+
async function check() {
|
|
1071
|
+
const connected = await session.connect();
|
|
1072
|
+
if (connected.error) return { ok: false, detail: `the agent did not start: ${connected.error}`, methods: [] };
|
|
1073
|
+
const methods = agentMethods(session.info?.authMethods ?? []);
|
|
1074
|
+
const created = await session.newSession();
|
|
1075
|
+
if (!created.error) return { ok: true, detail: "ready", methods, ...withChoice(id, session.models) };
|
|
1076
|
+
return { ok: false, detail: created.authRequired ? "not signed in" : created.error, methods };
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
async function signIn(id, options = {}) {
|
|
1080
|
+
if (isLlmId(id)) return checkLlm(id);
|
|
1081
|
+
const link = options.onUrl && !options.terminal ? catchBrowserLinks(options.onUrl) : null;
|
|
1082
|
+
let opened;
|
|
1083
|
+
try {
|
|
1084
|
+
opened = await open(id, options.onProgress, options.terminal, link?.env);
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
link?.stop();
|
|
1087
|
+
throw error;
|
|
1088
|
+
}
|
|
1089
|
+
const { agent, session, env, sandbox } = opened;
|
|
1090
|
+
const close = () => {
|
|
1091
|
+
opened.close();
|
|
1092
|
+
link?.stop();
|
|
1093
|
+
};
|
|
1094
|
+
const cancelled = new Promise((resolve) => {
|
|
1095
|
+
const onAbort = () => {
|
|
1096
|
+
resolve({ ok: false, detail: "cancelled", methods: [] });
|
|
1097
|
+
close();
|
|
1098
|
+
};
|
|
1099
|
+
if (options.signal?.aborted) onAbort();
|
|
1100
|
+
else options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1101
|
+
});
|
|
1102
|
+
try {
|
|
1103
|
+
return await Promise.race([cancelled, run()]);
|
|
1104
|
+
} finally {
|
|
1105
|
+
close();
|
|
1106
|
+
}
|
|
1107
|
+
async function run() {
|
|
1108
|
+
const connected = await session.connect();
|
|
1109
|
+
if (connected.error) return { ok: false, detail: `the agent did not start: ${connected.error}`, methods: [] };
|
|
1110
|
+
const all = session.info?.authMethods ?? [];
|
|
1111
|
+
const methods = options.terminal ? all : agentMethods(all);
|
|
1112
|
+
if (methods.length === 0) {
|
|
1113
|
+
return { ok: false, detail: `${agent.name} offers no sign-in method Leuria can run; sign in with its own tool.`, methods };
|
|
1114
|
+
}
|
|
1115
|
+
const method = options.methodId ? methods.find((m) => m.id === options.methodId) : preferredMethod(methods, { terminal: options.terminal });
|
|
1116
|
+
if (!method) return { ok: false, detail: `Unknown method. Available: ${methods.map((m) => m.id).join(", ")}`, methods };
|
|
1117
|
+
options.onProgress?.(`Signing in to ${agent.name} with ${method.name}${method.description ? ` (${method.description})` : ""}\u2026`);
|
|
1118
|
+
if (isTerminal(method)) {
|
|
1119
|
+
session.close();
|
|
1120
|
+
const code = await new Promise((resolve, reject) => {
|
|
1121
|
+
const child = spawn2(agent.command, [...agent.launchArgs, ...method.args ?? []], {
|
|
1122
|
+
stdio: "inherit",
|
|
1123
|
+
cwd: sandbox,
|
|
1124
|
+
env: { ...process.env, ...env, ...method.env ?? {} }
|
|
1125
|
+
});
|
|
1126
|
+
child.on("error", reject);
|
|
1127
|
+
child.on("exit", resolve);
|
|
1128
|
+
});
|
|
1129
|
+
if (code !== 0) return { ok: false, detail: `the sign-in ended with code ${code}`, methods };
|
|
1130
|
+
return checkSignIn(id, options.onProgress);
|
|
1131
|
+
}
|
|
1132
|
+
const auth = await session.authenticate(method.id);
|
|
1133
|
+
if (auth.error) return { ok: false, detail: auth.error, methods };
|
|
1134
|
+
const created = await session.newSession();
|
|
1135
|
+
if (created.error) return { ok: false, detail: created.error, methods };
|
|
1136
|
+
rememberReady(id, true);
|
|
1137
|
+
return { ok: true, detail: `signed in with ${method.name}`, methods, ...withChoice(id, session.models) };
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// src/detect.ts
|
|
1142
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
1143
|
+
var CLI_TO_REGISTRY_ID = {
|
|
1144
|
+
claude: "claude-acp",
|
|
1145
|
+
codex: "codex-acp",
|
|
1146
|
+
opencode: "opencode",
|
|
1147
|
+
vibe: "mistral-vibe",
|
|
1148
|
+
gemini: "gemini",
|
|
1149
|
+
copilot: "github-copilot-cli",
|
|
1150
|
+
pi: "pi-acp",
|
|
1151
|
+
goose: "goose",
|
|
1152
|
+
qwen: "qwen-code"
|
|
1153
|
+
};
|
|
1154
|
+
function onPath(command) {
|
|
1155
|
+
try {
|
|
1156
|
+
execFileSync2(process.platform === "win32" ? "where" : "which", [command], { stdio: "pipe" });
|
|
1157
|
+
return true;
|
|
1158
|
+
} catch {
|
|
1159
|
+
return false;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
function detectInstalledClis() {
|
|
1163
|
+
return Object.entries(CLI_TO_REGISTRY_ID).filter(([cli]) => onPath(cli)).map(([, id]) => id);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// src/grants.ts
|
|
1167
|
+
import { createHash as createHash2, randomBytes, timingSafeEqual } from "crypto";
|
|
1168
|
+
import { statSync } from "fs";
|
|
1169
|
+
var GrantStore = class {
|
|
1170
|
+
/** `path: null` keeps grants in memory only (tests, `leuria test`). */
|
|
1171
|
+
constructor(path2 = homePath("grants.json")) {
|
|
1172
|
+
this.path = path2;
|
|
1173
|
+
this.reload();
|
|
1174
|
+
}
|
|
1175
|
+
path;
|
|
1176
|
+
grants = [];
|
|
1177
|
+
loadedMtime = -1;
|
|
1178
|
+
removedListeners = /* @__PURE__ */ new Set();
|
|
1179
|
+
changedListeners = /* @__PURE__ */ new Set();
|
|
1180
|
+
/** Called with each origin whose grant disappears, here or in another process. */
|
|
1181
|
+
onRemoved(listener) {
|
|
1182
|
+
this.removedListeners.add(listener);
|
|
1183
|
+
}
|
|
1184
|
+
/** Called with each origin whose AI or model changes. */
|
|
1185
|
+
onChanged(listener) {
|
|
1186
|
+
this.changedListeners.add(listener);
|
|
1187
|
+
}
|
|
1188
|
+
list() {
|
|
1189
|
+
this.reload();
|
|
1190
|
+
return [...this.grants];
|
|
1191
|
+
}
|
|
1192
|
+
has(origin) {
|
|
1193
|
+
this.reload();
|
|
1194
|
+
return this.grants.some((g) => g.origin === normalizeOrigin(origin));
|
|
1195
|
+
}
|
|
1196
|
+
get(origin) {
|
|
1197
|
+
this.reload();
|
|
1198
|
+
try {
|
|
1199
|
+
const normalized = normalizeOrigin(origin);
|
|
1200
|
+
return this.grants.find((g) => g.origin === normalized);
|
|
1201
|
+
} catch {
|
|
1202
|
+
return void 0;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
/** Use `agent` for this site, or the default AI when `undefined`. */
|
|
1206
|
+
setAgent(origin, agent) {
|
|
1207
|
+
this.reload();
|
|
1208
|
+
const grant = this.grants.find((g) => g.origin === normalizeOrigin(origin));
|
|
1209
|
+
if (!grant) return false;
|
|
1210
|
+
if (agent) grant.agent = agent;
|
|
1211
|
+
else delete grant.agent;
|
|
1212
|
+
this.save();
|
|
1213
|
+
for (const listener of this.changedListeners) listener(grant.origin);
|
|
1214
|
+
return true;
|
|
1215
|
+
}
|
|
1216
|
+
/** Use `model` of `agent` for this site, or that AI's own choice when `undefined`. */
|
|
1217
|
+
setModel(origin, model) {
|
|
1218
|
+
this.reload();
|
|
1219
|
+
const grant = this.grants.find((g) => g.origin === normalizeOrigin(origin));
|
|
1220
|
+
if (!grant) return false;
|
|
1221
|
+
if (model) grant.model = model;
|
|
1222
|
+
else delete grant.model;
|
|
1223
|
+
this.save();
|
|
1224
|
+
for (const listener of this.changedListeners) listener(grant.origin);
|
|
1225
|
+
return true;
|
|
1226
|
+
}
|
|
1227
|
+
/** Create or replace the origin's grant; returns the new token. Keeps the site's AI and model choice; `needs` replaces what it declared. */
|
|
1228
|
+
create(origin, app, needs) {
|
|
1229
|
+
this.reload();
|
|
1230
|
+
const normalized = normalizeOrigin(origin);
|
|
1231
|
+
const token = randomBytes(32).toString("base64url");
|
|
1232
|
+
const previous = this.grants.find((g) => g.origin === normalized);
|
|
1233
|
+
this.grants = this.grants.filter((g) => g.origin !== normalized);
|
|
1234
|
+
this.grants.push({
|
|
1235
|
+
origin: normalized,
|
|
1236
|
+
app,
|
|
1237
|
+
tokenHash: hashToken(token),
|
|
1238
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1239
|
+
...previous?.agent ? { agent: previous.agent } : {},
|
|
1240
|
+
...previous?.model ? { model: previous.model } : {},
|
|
1241
|
+
...needs ? { needs } : {}
|
|
1242
|
+
});
|
|
1243
|
+
this.save();
|
|
1244
|
+
return token;
|
|
1245
|
+
}
|
|
1246
|
+
/** True when `token` is the origin's current token. */
|
|
1247
|
+
verify(origin, token) {
|
|
1248
|
+
if (!token) return false;
|
|
1249
|
+
this.reload();
|
|
1250
|
+
const grant = this.grants.find((g) => g.origin === normalizeOrigin(origin));
|
|
1251
|
+
if (!grant) return false;
|
|
1252
|
+
const expected = Buffer.from(grant.tokenHash, "hex");
|
|
1253
|
+
const actual = Buffer.from(hashToken(token), "hex");
|
|
1254
|
+
if (!timingSafeEqual(expected, actual)) return false;
|
|
1255
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1256
|
+
if (!grant.lastUsedAt || Date.parse(now) - Date.parse(grant.lastUsedAt) > 6e4) {
|
|
1257
|
+
grant.lastUsedAt = now;
|
|
1258
|
+
this.save();
|
|
1259
|
+
}
|
|
1260
|
+
return true;
|
|
1261
|
+
}
|
|
1262
|
+
revoke(origin) {
|
|
1263
|
+
this.reload();
|
|
1264
|
+
const normalized = normalizeOrigin(origin);
|
|
1265
|
+
const before = this.grants.length;
|
|
1266
|
+
this.grants = this.grants.filter((g) => g.origin !== normalized);
|
|
1267
|
+
if (this.grants.length === before) return false;
|
|
1268
|
+
this.save();
|
|
1269
|
+
for (const listener of this.removedListeners) listener(normalized);
|
|
1270
|
+
return true;
|
|
1271
|
+
}
|
|
1272
|
+
/** Pick up changes made by another process, such as `leuria sites revoke`. */
|
|
1273
|
+
reload() {
|
|
1274
|
+
if (!this.path) return;
|
|
1275
|
+
let mtime;
|
|
1276
|
+
try {
|
|
1277
|
+
mtime = statSync(this.path).mtimeMs;
|
|
1278
|
+
} catch {
|
|
1279
|
+
mtime = 0;
|
|
1280
|
+
}
|
|
1281
|
+
if (mtime === this.loadedMtime) return;
|
|
1282
|
+
const previous = new Set(this.grants.map((g) => g.origin));
|
|
1283
|
+
this.grants = readJson(this.path)?.grants ?? [];
|
|
1284
|
+
const firstLoad = this.loadedMtime === -1;
|
|
1285
|
+
this.loadedMtime = mtime;
|
|
1286
|
+
if (firstLoad) return;
|
|
1287
|
+
for (const grant of this.grants) previous.delete(grant.origin);
|
|
1288
|
+
for (const origin of previous) {
|
|
1289
|
+
for (const listener of this.removedListeners) listener(origin);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
save() {
|
|
1293
|
+
if (!this.path) return;
|
|
1294
|
+
writeJson(this.path, { grants: this.grants });
|
|
1295
|
+
try {
|
|
1296
|
+
this.loadedMtime = statSync(this.path).mtimeMs;
|
|
1297
|
+
} catch {
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
};
|
|
1301
|
+
function hashToken(token) {
|
|
1302
|
+
return createHash2("sha256").update(token).digest("hex");
|
|
1303
|
+
}
|
|
1304
|
+
function normalizeOrigin(origin) {
|
|
1305
|
+
const url = new URL(origin.trim());
|
|
1306
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1307
|
+
throw new Error(`Not a web origin: ${origin}`);
|
|
1308
|
+
}
|
|
1309
|
+
return url.origin.toLowerCase();
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// src/server.ts
|
|
1313
|
+
import { timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
1314
|
+
import * as http from "http";
|
|
1315
|
+
|
|
1316
|
+
// src/http-utils.ts
|
|
1317
|
+
var MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
1318
|
+
function sendJson(res, status, data) {
|
|
1319
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1320
|
+
res.end(JSON.stringify(data));
|
|
1321
|
+
}
|
|
1322
|
+
function parseBody(req) {
|
|
1323
|
+
return new Promise((resolve, reject) => {
|
|
1324
|
+
const chunks = [];
|
|
1325
|
+
let size = 0;
|
|
1326
|
+
req.on("data", (chunk) => {
|
|
1327
|
+
size += chunk.length;
|
|
1328
|
+
if (size > MAX_BODY_BYTES) {
|
|
1329
|
+
reject(new Error("Body too large"));
|
|
1330
|
+
req.destroy();
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
chunks.push(chunk);
|
|
1334
|
+
});
|
|
1335
|
+
req.on("end", () => {
|
|
1336
|
+
try {
|
|
1337
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
1338
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
1339
|
+
} catch {
|
|
1340
|
+
reject(new Error("Invalid JSON body"));
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
req.on("error", reject);
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
function writeSse(res, event, data) {
|
|
1347
|
+
try {
|
|
1348
|
+
res.write(`event: ${event}
|
|
1349
|
+
data: ${JSON.stringify(data ?? null)}
|
|
1350
|
+
|
|
1351
|
+
`);
|
|
1352
|
+
} catch {
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// src/llm/embeddings.ts
|
|
1357
|
+
var NoEmbedderError = class extends Error {
|
|
1358
|
+
constructor() {
|
|
1359
|
+
super("No embedding model on this computer. Load one in LM Studio or Ollama.");
|
|
1360
|
+
this.name = "NoEmbedderError";
|
|
1361
|
+
}
|
|
1362
|
+
};
|
|
1363
|
+
function isLocal(provider) {
|
|
1364
|
+
if (provider.kind !== "lmstudio" && provider.kind !== "ollama") return false;
|
|
1365
|
+
const host = new URL(provider.baseUrl).hostname;
|
|
1366
|
+
return host === "127.0.0.1" || host === "localhost" || host === "[::1]";
|
|
1367
|
+
}
|
|
1368
|
+
async function listEmbeddingModels(provider, timeoutMs = 1500) {
|
|
1369
|
+
const root = provider.baseUrl.replace(/\/v\d+$/, "");
|
|
1370
|
+
const get = (url) => fetch(url, { headers: authHeaders(provider), signal: AbortSignal.timeout(timeoutMs) }).then((r) => r.ok ? r.json() : null).catch(() => null);
|
|
1371
|
+
if (provider.kind === "lmstudio") {
|
|
1372
|
+
const native = await get(`${root}/api/v1/models`);
|
|
1373
|
+
if (native?.models) {
|
|
1374
|
+
return native.models.filter((m) => m.type === "embedding").map((m) => ({ id: m.key, loaded: (m.loaded_instances?.length ?? 0) > 0 })).sort((a, b) => Number(b.loaded) - Number(a.loaded));
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
if (provider.kind === "ollama") {
|
|
1378
|
+
const tags = await get(`${root}/api/tags`);
|
|
1379
|
+
return (tags?.models ?? []).filter((m) => !m.remote_host && EMBEDDING.test(m.name)).map((m) => ({ id: m.name }));
|
|
1380
|
+
}
|
|
1381
|
+
const list = await get(`${provider.baseUrl}/models`);
|
|
1382
|
+
return (list?.data ?? []).filter((m) => typeof m.id === "string").filter((m) => m.type === "embedding" || EMBEDDING.test(m.id)).map((m) => ({ id: m.id }));
|
|
1383
|
+
}
|
|
1384
|
+
async function listLocalEmbedders() {
|
|
1385
|
+
const found = await Promise.all(
|
|
1386
|
+
listProviders().filter(isLocal).map(async (provider) => (await listEmbeddingModels(provider)).map((model) => ({ provider, model })))
|
|
1387
|
+
);
|
|
1388
|
+
return found.flat();
|
|
1389
|
+
}
|
|
1390
|
+
function withPrefix(model, text, kind) {
|
|
1391
|
+
if (/nomic-embed/i.test(model)) return `${kind === "query" ? "search_query" : "search_document"}: ${text}`;
|
|
1392
|
+
if (/(^|[/-])e5-/i.test(model)) return `${kind === "query" ? "query" : "passage"}: ${text}`;
|
|
1393
|
+
if (kind === "query" && /bge-.*en|mxbai-embed/i.test(model)) return `Represent this sentence for searching relevant passages: ${text}`;
|
|
1394
|
+
return text;
|
|
1395
|
+
}
|
|
1396
|
+
var CACHE_MS = 1e4;
|
|
1397
|
+
function localEmbeddings(choice = () => loadConfig().embed) {
|
|
1398
|
+
let cached;
|
|
1399
|
+
const find = () => {
|
|
1400
|
+
const chosen = choice();
|
|
1401
|
+
const key = JSON.stringify(chosen ?? "auto");
|
|
1402
|
+
if (!cached || cached.key !== key || Date.now() - cached.at > CACHE_MS) {
|
|
1403
|
+
const value = (async () => {
|
|
1404
|
+
if (chosen === "off") return void 0;
|
|
1405
|
+
if (chosen) {
|
|
1406
|
+
const provider = listProviders().find((p) => p.id === chosen.provider);
|
|
1407
|
+
if (!provider || !isLocal(provider)) return void 0;
|
|
1408
|
+
const models = await listEmbeddingModels(provider);
|
|
1409
|
+
return models.some((m) => m.id === chosen.model) ? { provider, model: chosen.model } : void 0;
|
|
1410
|
+
}
|
|
1411
|
+
for (const provider of listProviders().filter(isLocal)) {
|
|
1412
|
+
const [model] = await listEmbeddingModels(provider);
|
|
1413
|
+
if (model) return { provider, model: model.id };
|
|
1414
|
+
}
|
|
1415
|
+
return void 0;
|
|
1416
|
+
})();
|
|
1417
|
+
cached = { at: Date.now(), key, value };
|
|
1418
|
+
}
|
|
1419
|
+
return cached.value;
|
|
1420
|
+
};
|
|
1421
|
+
return {
|
|
1422
|
+
find,
|
|
1423
|
+
embed: async (texts, kind) => {
|
|
1424
|
+
const embedder = await find();
|
|
1425
|
+
if (!embedder) throw new NoEmbedderError();
|
|
1426
|
+
const { provider, model } = embedder;
|
|
1427
|
+
const res = await fetch(`${provider.baseUrl}/embeddings`, {
|
|
1428
|
+
method: "POST",
|
|
1429
|
+
headers: { "Content-Type": "application/json", ...authHeaders(provider) },
|
|
1430
|
+
body: JSON.stringify({ model, input: texts.map((t) => withPrefix(model, t, kind)) }),
|
|
1431
|
+
signal: AbortSignal.timeout(12e4)
|
|
1432
|
+
}).catch(() => null);
|
|
1433
|
+
if (!res?.ok) {
|
|
1434
|
+
cached = void 0;
|
|
1435
|
+
throw new Error(`${provider.name} couldn't embed the texts.`);
|
|
1436
|
+
}
|
|
1437
|
+
const body = await res.json();
|
|
1438
|
+
const data = [...body.data ?? []].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
|
|
1439
|
+
if (data.length !== texts.length || data.some((d) => !Array.isArray(d.embedding))) {
|
|
1440
|
+
throw new Error(`${provider.name} returned an unexpected answer.`);
|
|
1441
|
+
}
|
|
1442
|
+
return { model, vectors: data.map((d) => d.embedding) };
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
var MAX_EMBED_TEXTS = 256;
|
|
1447
|
+
var MAX_EMBED_CHARS = 8e3;
|
|
1448
|
+
function parseEmbedBody(body) {
|
|
1449
|
+
const { texts, kind } = body ?? {};
|
|
1450
|
+
if (!Array.isArray(texts) || texts.length === 0 || !texts.every((t) => typeof t === "string")) {
|
|
1451
|
+
throw new Error("texts must be a non-empty array of strings");
|
|
1452
|
+
}
|
|
1453
|
+
if (texts.length > MAX_EMBED_TEXTS) throw new Error(`At most ${MAX_EMBED_TEXTS} texts per request`);
|
|
1454
|
+
if (kind !== void 0 && kind !== "query" && kind !== "document") throw new Error('kind must be "query" or "document"');
|
|
1455
|
+
return { texts: texts.map((t) => t.slice(0, MAX_EMBED_CHARS)), kind: kind ?? "document" };
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// src/pairing.ts
|
|
1459
|
+
import { createHash as createHash3, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
1460
|
+
|
|
1461
|
+
// src/needs.ts
|
|
1462
|
+
var EFFORTS = ["light", "standard", "deep"];
|
|
1463
|
+
function parseNeeds(input) {
|
|
1464
|
+
if (!input || typeof input !== "object") return void 0;
|
|
1465
|
+
const raw = input;
|
|
1466
|
+
const needs = {};
|
|
1467
|
+
const flag = (value) => value === true || value === "1" || value === "true";
|
|
1468
|
+
if (flag(raw.tools)) needs.tools = true;
|
|
1469
|
+
if (flag(raw.images)) needs.images = true;
|
|
1470
|
+
if (typeof raw.effort === "string" && EFFORTS.includes(raw.effort)) needs.effort = raw.effort;
|
|
1471
|
+
const context = Number(raw.context);
|
|
1472
|
+
if (Number.isFinite(context) && context > 0) needs.context = Math.min(Math.round(context), 1e7);
|
|
1473
|
+
return Object.keys(needs).length ? needs : void 0;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// src/pairing.ts
|
|
1477
|
+
var REQUEST_TTL_MS = 5 * 6e4;
|
|
1478
|
+
var MAX_PENDING = 20;
|
|
1479
|
+
var WAIT_MS = 25e3;
|
|
1480
|
+
var DENIED_COOLDOWN_MS = 3e4;
|
|
1481
|
+
var NONCE = /^[A-Za-z0-9_-]{22,128}$/;
|
|
1482
|
+
var hashNonce = (nonce) => createHash3("sha256").update(nonce).digest();
|
|
1483
|
+
var Pairing = class {
|
|
1484
|
+
constructor(options) {
|
|
1485
|
+
this.options = options;
|
|
1486
|
+
}
|
|
1487
|
+
options;
|
|
1488
|
+
requests = /* @__PURE__ */ new Map();
|
|
1489
|
+
deniedAt = /* @__PURE__ */ new Map();
|
|
1490
|
+
/** A link named this origin and it hasn't collected its answer yet: it may be answered. */
|
|
1491
|
+
hasRequest(origin) {
|
|
1492
|
+
this.expire();
|
|
1493
|
+
return [...this.requests.values()].some((r) => r.origin === origin);
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* A `leuria://connect` link reached the desktop app. Returns the request,
|
|
1497
|
+
* or why it was refused. A repeated link for the same site replaces the
|
|
1498
|
+
* nonce (the page tried again) instead of asking twice.
|
|
1499
|
+
*/
|
|
1500
|
+
link(input) {
|
|
1501
|
+
this.expire();
|
|
1502
|
+
let origin;
|
|
1503
|
+
try {
|
|
1504
|
+
origin = normalizeOrigin(String(input.origin ?? ""));
|
|
1505
|
+
} catch {
|
|
1506
|
+
return { error: "The link doesn't name a website." };
|
|
1507
|
+
}
|
|
1508
|
+
if (this.options.selfOrigins.has(origin)) return { error: "The link doesn't name a website." };
|
|
1509
|
+
if (typeof input.nonce !== "string" || !NONCE.test(input.nonce)) return { error: "The link is incomplete." };
|
|
1510
|
+
const app = typeof input.app === "string" && input.app.trim() ? input.app.trim().slice(0, 80) : void 0;
|
|
1511
|
+
const request = this.open(origin, app, input.nonce, parseNeeds(input.needs));
|
|
1512
|
+
return "error" in request ? request : { requestId: request.id };
|
|
1513
|
+
}
|
|
1514
|
+
/** Returns true when the request was a pairing route. */
|
|
1515
|
+
async handle(req, res, pathname) {
|
|
1516
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
1517
|
+
if (segments[0] !== "connect") return false;
|
|
1518
|
+
this.expire();
|
|
1519
|
+
const origin = req.headers.origin;
|
|
1520
|
+
const id = segments[1];
|
|
1521
|
+
const action = segments[2];
|
|
1522
|
+
if (req.method === "POST" && id === "claim" && segments.length === 2) {
|
|
1523
|
+
await this.claim(req, res, origin);
|
|
1524
|
+
return true;
|
|
1525
|
+
}
|
|
1526
|
+
if (!id) return false;
|
|
1527
|
+
const request = this.requests.get(id);
|
|
1528
|
+
if (req.method === "GET" && segments.length === 2) {
|
|
1529
|
+
this.renderPage(res, request);
|
|
1530
|
+
return true;
|
|
1531
|
+
}
|
|
1532
|
+
if (req.method === "GET" && action === "state") {
|
|
1533
|
+
if (origin && !this.options.selfOrigins.has(origin)) {
|
|
1534
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
1535
|
+
return true;
|
|
1536
|
+
}
|
|
1537
|
+
sendJson(res, 200, { decision: request?.decision ?? "expired" });
|
|
1538
|
+
return true;
|
|
1539
|
+
}
|
|
1540
|
+
if (req.method === "POST" && action === "decide") {
|
|
1541
|
+
if (!origin || !this.options.selfOrigins.has(origin)) {
|
|
1542
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
1543
|
+
return true;
|
|
1544
|
+
}
|
|
1545
|
+
if (!request || request.decision !== "pending") {
|
|
1546
|
+
sendJson(res, 410, { error: "This request has expired. Ask the site again." });
|
|
1547
|
+
return true;
|
|
1548
|
+
}
|
|
1549
|
+
const body = await parseBody(req);
|
|
1550
|
+
this.decide(request, body.allow === true);
|
|
1551
|
+
sendJson(res, 200, { decision: request.decision, origin: request.origin });
|
|
1552
|
+
return true;
|
|
1553
|
+
}
|
|
1554
|
+
return false;
|
|
1555
|
+
}
|
|
1556
|
+
/** The page collects the visitor's answer, with the nonce of its link. */
|
|
1557
|
+
async claim(req, res, rawOrigin) {
|
|
1558
|
+
let origin;
|
|
1559
|
+
try {
|
|
1560
|
+
origin = rawOrigin && !this.options.selfOrigins.has(rawOrigin) ? normalizeOrigin(rawOrigin) : void 0;
|
|
1561
|
+
} catch {
|
|
1562
|
+
origin = void 0;
|
|
1563
|
+
}
|
|
1564
|
+
if (!origin) {
|
|
1565
|
+
sendJson(res, 400, { error: "Pairing must come from a web page" });
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
const body = await parseBody(req);
|
|
1569
|
+
const nonce = body.nonce;
|
|
1570
|
+
if (typeof nonce !== "string" || !NONCE.test(nonce)) {
|
|
1571
|
+
sendJson(res, 400, { error: "A nonce is required" });
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
const hash = hashNonce(nonce);
|
|
1575
|
+
let request = [...this.requests.values()].find((r) => r.origin === origin && timingSafeEqual2(r.nonce, hash));
|
|
1576
|
+
if (!request) {
|
|
1577
|
+
if (this.options.linksOnly) {
|
|
1578
|
+
sendJson(res, 404, { error: "Unknown pairing request" });
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
const app = typeof body.app === "string" && body.app.trim() ? body.app.trim().slice(0, 80) : void 0;
|
|
1582
|
+
const opened = this.open(origin, app, nonce, parseNeeds(body.needs));
|
|
1583
|
+
if ("error" in opened) {
|
|
1584
|
+
sendJson(res, opened.status, { error: opened.error });
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
request = opened;
|
|
1588
|
+
}
|
|
1589
|
+
await this.wait(request);
|
|
1590
|
+
if (!timingSafeEqual2(request.nonce, hash)) {
|
|
1591
|
+
sendJson(res, 404, { error: "Unknown pairing request" });
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
if (request.decision === "allowed" && request.token) {
|
|
1595
|
+
const token = request.token;
|
|
1596
|
+
request.token = void 0;
|
|
1597
|
+
this.requests.delete(request.id);
|
|
1598
|
+
sendJson(res, 200, { status: "allowed", token });
|
|
1599
|
+
} else if (request.decision === "denied") {
|
|
1600
|
+
this.requests.delete(request.id);
|
|
1601
|
+
sendJson(res, 200, { status: "denied" });
|
|
1602
|
+
} else {
|
|
1603
|
+
sendJson(res, 200, { status: "pending" });
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
/** Ask the visitor about `origin`, or update the question already asked. */
|
|
1607
|
+
open(origin, app, nonce, needs) {
|
|
1608
|
+
const denied = this.deniedAt.get(origin);
|
|
1609
|
+
if (denied && Date.now() - denied < DENIED_COOLDOWN_MS) return { error: "The visitor just said no to this site", status: 429 };
|
|
1610
|
+
const existing = [...this.requests.values()].find((r) => r.origin === origin && r.decision === "pending");
|
|
1611
|
+
if (existing) {
|
|
1612
|
+
existing.nonce = hashNonce(nonce);
|
|
1613
|
+
if (app) existing.app = app;
|
|
1614
|
+
if (needs) existing.needs = needs;
|
|
1615
|
+
this.options.onRequest?.(this.info(existing));
|
|
1616
|
+
return existing;
|
|
1617
|
+
}
|
|
1618
|
+
if ([...this.requests.values()].filter((r) => r.decision === "pending").length >= MAX_PENDING) {
|
|
1619
|
+
return { error: "Too many pending pairing requests", status: 429 };
|
|
1620
|
+
}
|
|
1621
|
+
const request = {
|
|
1622
|
+
id: randomBytes2(18).toString("base64url"),
|
|
1623
|
+
origin,
|
|
1624
|
+
app,
|
|
1625
|
+
createdAt: Date.now(),
|
|
1626
|
+
decision: "pending",
|
|
1627
|
+
nonce: hashNonce(nonce),
|
|
1628
|
+
needs,
|
|
1629
|
+
waiters: /* @__PURE__ */ new Set()
|
|
1630
|
+
};
|
|
1631
|
+
this.requests.set(request.id, request);
|
|
1632
|
+
this.options.logger.info("pairing requested", { origin, app });
|
|
1633
|
+
this.options.onRequest?.(this.info(request));
|
|
1634
|
+
return request;
|
|
1635
|
+
}
|
|
1636
|
+
info(request) {
|
|
1637
|
+
return {
|
|
1638
|
+
requestId: request.id,
|
|
1639
|
+
origin: request.origin,
|
|
1640
|
+
app: request.app,
|
|
1641
|
+
...request.needs ? { needs: request.needs } : {},
|
|
1642
|
+
approveUrl: `http://127.0.0.1:${this.options.port}/connect/${request.id}`
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
/** Requests waiting for the visitor, e.g. for the desktop app. */
|
|
1646
|
+
pending() {
|
|
1647
|
+
this.expire();
|
|
1648
|
+
return [...this.requests.values()].filter((r) => r.decision === "pending").map((r) => this.info(r));
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* Decide from a trusted place (the desktop app's native window).
|
|
1652
|
+
* `granted` runs on Allow once the grant exists, before the site gets
|
|
1653
|
+
* its token: e.g. to set the site's AI and model from the first message.
|
|
1654
|
+
*/
|
|
1655
|
+
decideById(requestId, allow, granted) {
|
|
1656
|
+
this.expire();
|
|
1657
|
+
const request = this.requests.get(requestId);
|
|
1658
|
+
if (!request || request.decision !== "pending") return false;
|
|
1659
|
+
this.decide(request, allow, granted);
|
|
1660
|
+
return true;
|
|
1661
|
+
}
|
|
1662
|
+
decide(request, allow, granted) {
|
|
1663
|
+
if (allow) {
|
|
1664
|
+
request.token = this.options.grants.create(request.origin, request.app, request.needs);
|
|
1665
|
+
granted?.(request.origin);
|
|
1666
|
+
request.decision = "allowed";
|
|
1667
|
+
} else {
|
|
1668
|
+
request.decision = "denied";
|
|
1669
|
+
this.deniedAt.set(request.origin, Date.now());
|
|
1670
|
+
}
|
|
1671
|
+
this.options.logger.info("pairing decided", {
|
|
1672
|
+
origin: request.origin,
|
|
1673
|
+
decision: request.decision
|
|
1674
|
+
});
|
|
1675
|
+
for (const wake of request.waiters) wake();
|
|
1676
|
+
request.waiters.clear();
|
|
1677
|
+
this.options.onDecided?.({ requestId: request.id, origin: request.origin, allowed: allow });
|
|
1678
|
+
}
|
|
1679
|
+
wait(request) {
|
|
1680
|
+
if (request.decision !== "pending") return Promise.resolve();
|
|
1681
|
+
return new Promise((resolve) => {
|
|
1682
|
+
const wake = () => {
|
|
1683
|
+
clearTimeout(timer);
|
|
1684
|
+
resolve();
|
|
1685
|
+
};
|
|
1686
|
+
const timer = setTimeout(() => {
|
|
1687
|
+
request.waiters.delete(wake);
|
|
1688
|
+
resolve();
|
|
1689
|
+
}, WAIT_MS);
|
|
1690
|
+
request.waiters.add(wake);
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
expire() {
|
|
1694
|
+
const now = Date.now();
|
|
1695
|
+
for (const [id, request] of this.requests) {
|
|
1696
|
+
if (now - request.createdAt > REQUEST_TTL_MS) {
|
|
1697
|
+
for (const wake of request.waiters) wake();
|
|
1698
|
+
this.requests.delete(id);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
for (const [origin, at] of this.deniedAt) if (now - at > DENIED_COOLDOWN_MS) this.deniedAt.delete(origin);
|
|
1702
|
+
}
|
|
1703
|
+
renderPage(res, request) {
|
|
1704
|
+
const body = request && request.decision === "pending" ? approvePage(request, this.options.agentName()) : messagePage("This request has expired", "Go back to the site and try connecting again.");
|
|
1705
|
+
res.writeHead(200, {
|
|
1706
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1707
|
+
"Cache-Control": "no-store",
|
|
1708
|
+
// Never framed, so a site cannot overlay or click-jack it.
|
|
1709
|
+
"X-Frame-Options": "DENY",
|
|
1710
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
|
|
1711
|
+
"Referrer-Policy": "no-referrer"
|
|
1712
|
+
});
|
|
1713
|
+
res.end(body);
|
|
1714
|
+
}
|
|
1715
|
+
};
|
|
1716
|
+
function escapeHtml(value) {
|
|
1717
|
+
return value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
1718
|
+
}
|
|
1719
|
+
var STYLE = `
|
|
1720
|
+
:root { color-scheme: light dark; --bg: #fcfcfe; --surface: #ffffff; --text: #1d2035; --muted: #5f6275; --border: #d6d8e5; --sunken: #f7f7fb;
|
|
1721
|
+
--ink: #14172b; --ink-hover: #262a45; --on-ink: #ffffff; --live: #019163; --focus: #8169dd;
|
|
1722
|
+
--atmo-base: #eef0f7; --atmo-lavender: #d9d0ff; --atmo-ice: #cde8f7; --atmo-mint: #d3f3e6; --atmo-blush: #f9dce6;
|
|
1723
|
+
--shadow: 0 32px 72px -20px rgba(20, 23, 43, 0.35), 0 0 0 1px rgba(20, 23, 43, 0.05); }
|
|
1724
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #0d0e13; --surface: #13141a; --text: #ebecf2; --muted: #babdcb; --border: #323443; --sunken: #0d0e13;
|
|
1725
|
+
--ink: #eef0f7; --ink-hover: #d9dcea; --on-ink: #14172b; --live: #19a572; --focus: #907aeb;
|
|
1726
|
+
--atmo-base: #0f1120; --atmo-lavender: #2a2548; --atmo-ice: #183040; --atmo-mint: #183329; --atmo-blush: #3a2231;
|
|
1727
|
+
--shadow: 0 32px 72px -20px rgba(0, 0, 0, 0.8), 0 0 0 1px rgba(255, 255, 255, 0.08); } }
|
|
1728
|
+
* { box-sizing: border-box; }
|
|
1729
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 16px; color: var(--text);
|
|
1730
|
+
font: 16px/1.5 "Plus Jakarta Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased;
|
|
1731
|
+
background: radial-gradient(circle at 12% 12%, var(--atmo-blush) 0%, transparent 52%), radial-gradient(circle at 88% 18%, var(--atmo-ice) 0%, transparent 54%),
|
|
1732
|
+
radial-gradient(circle at 82% 92%, var(--atmo-mint) 0%, transparent 56%), radial-gradient(circle at 14% 92%, var(--atmo-lavender) 0%, transparent 56%), var(--atmo-base); }
|
|
1733
|
+
main { width: 100%; max-width: 420px; background: var(--surface); border-radius: 24px; padding: 32px; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 16px; }
|
|
1734
|
+
.logo { display: inline-flex; align-items: center; gap: 8px; color: var(--ink); font-weight: 800; letter-spacing: -0.04em; font-size: 19px; }
|
|
1735
|
+
.badge { align-self: flex-start; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 9999px; background: var(--sunken); border: 1px solid var(--border); font-size: 13px; color: var(--muted); }
|
|
1736
|
+
h1 { font-size: 24px; line-height: 30px; font-weight: 700; letter-spacing: -0.02em; margin: 0; }
|
|
1737
|
+
p { margin: 0; color: var(--muted); font-size: 14px; }
|
|
1738
|
+
.eyebrow { font-size: 12px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); margin-bottom: 8px; }
|
|
1739
|
+
ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; font-size: 14px; }
|
|
1740
|
+
li { display: flex; align-items: center; gap: 8px; }
|
|
1741
|
+
li svg { flex-shrink: 0; }
|
|
1742
|
+
.can svg { color: var(--live); }
|
|
1743
|
+
.cant svg { color: var(--muted); }
|
|
1744
|
+
.warn { font-size: 14px; font-weight: 600; }
|
|
1745
|
+
.actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 8px; }
|
|
1746
|
+
button { font: inherit; font-size: 16px; font-weight: 600; min-height: 48px; padding: 0 22px; border-radius: 9999px; cursor: pointer; border: none; }
|
|
1747
|
+
button.soft { background: var(--sunken); color: var(--text); border: 1px solid var(--border); }
|
|
1748
|
+
button.ink { background: var(--ink); color: var(--on-ink); }
|
|
1749
|
+
button.ink:hover { background: var(--ink-hover); }
|
|
1750
|
+
button:disabled { opacity: 0.5; cursor: default; }
|
|
1751
|
+
button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
1752
|
+
.small { font-size: 12px; }
|
|
1753
|
+
`;
|
|
1754
|
+
var MARK = "M181 61 C184 74 191 82 206 86 C191 90 184 98 181 111 C178 98 171 90 156 86 C171 82 178 74 181 61 Z M59 92 C60 109 67 128 79 143 C91 158 105 168 124 174 C149 182 164 193 171 207 C177 219 177 239 177 255 C182 255 182 240 184 226 C188 199 201 187 223 179 C247 171 270 158 284 142 C298 127 303 109 303 92 C298 91 292 101 284 108 C265 125 245 130 226 126 C217 124 211 114 205 112 C199 110 199 116 199 121 C198 127 191 130 181 130 C171 130 165 127 163 121 C162 116 165 110 159 111 C151 112 148 122 137 126 C118 131 98 125 78 109 C69 102 65 91 59 92 Z";
|
|
1755
|
+
var LOGO = `<span class="logo" role="img" aria-label="Leuria"><svg width="22" height="22" viewBox="53 30 256 256" aria-hidden="true"><path d="${MARK}" fill="currentColor"/></svg>leuria</span>`;
|
|
1756
|
+
var icon = (d) => `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${d}"/></svg>`;
|
|
1757
|
+
var CHECK = icon("M5 12.5l4.5 4.5L19 7.5");
|
|
1758
|
+
var CROSS = icon("M6 6l12 12M18 6L6 18");
|
|
1759
|
+
var GLOBE = icon("M12 21a9 9 0 100-18 9 9 0 000 18zM3.5 9h17M3.5 15h17M12 3c2.5 2.6 3.5 5.6 3.5 9s-1 6.4-3.5 9c-2.5-2.6-3.5-5.6-3.5-9s1-6.4 3.5-9z");
|
|
1760
|
+
function page(title, body, script = "") {
|
|
1761
|
+
return `<!doctype html>
|
|
1762
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1763
|
+
<title>${escapeHtml(title)} \xB7 Leuria</title><style>${STYLE}</style></head>
|
|
1764
|
+
<body><main>${LOGO}${body}</main>${script ? `<script>${script}</script>` : ""}</body></html>`;
|
|
1765
|
+
}
|
|
1766
|
+
function approvePage(request, agentName2) {
|
|
1767
|
+
const host = escapeHtml(new URL(request.origin).host);
|
|
1768
|
+
const name = request.app ? escapeHtml(request.app) : host;
|
|
1769
|
+
const agent = escapeHtml(agentName2);
|
|
1770
|
+
const insecure = request.origin.startsWith("http:") && !/^http:\/\/(localhost|127\.0\.0\.1)(:|$)/.test(request.origin);
|
|
1771
|
+
const body = `
|
|
1772
|
+
<span class="badge">${GLOBE}${host}</span>
|
|
1773
|
+
<h1>${name} wants to use your AI</h1>
|
|
1774
|
+
<p>It will talk to ${agent}, on this computer.</p>
|
|
1775
|
+
<div><div class="eyebrow">It can</div><ul class="can"><li>${CHECK}Ask your AI to answer you</li><li>${CHECK}Let your AI use its own page's tools</li></ul></div>
|
|
1776
|
+
<div><div class="eyebrow">It can't</div><ul class="cant"><li>${CROSS}See your files or run programs</li><li>${CROSS}See what you do on other websites</li></ul></div>
|
|
1777
|
+
${insecure ? `<p class="warn">This site doesn't use a secure connection.</p>` : ""}
|
|
1778
|
+
<p class="small">You can disconnect it at any time with <code>leuria sites revoke ${escapeHtml(request.origin)}</code>.</p>
|
|
1779
|
+
<div class="actions"><button type="button" class="soft" id="deny">Not now</button><button type="button" class="ink" id="allow">Allow</button></div>`;
|
|
1780
|
+
const script = `
|
|
1781
|
+
const main = document.querySelector("main");
|
|
1782
|
+
const buttons = document.querySelectorAll("button");
|
|
1783
|
+
const done = (title, text) => {
|
|
1784
|
+
main.innerHTML = main.querySelector(".logo").outerHTML + "<h1>" + title + "</h1><p>" + text + "</p>";
|
|
1785
|
+
setTimeout(() => window.close(), 1500);
|
|
1786
|
+
};
|
|
1787
|
+
async function decide(allow) {
|
|
1788
|
+
buttons.forEach((b) => (b.disabled = true));
|
|
1789
|
+
const res = await fetch(location.pathname + "/decide", {
|
|
1790
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ allow }),
|
|
1791
|
+
}).catch(() => null);
|
|
1792
|
+
if (!res || !res.ok) return done("This request has expired", "Go back to the site and try connecting again.");
|
|
1793
|
+
allow ? done("Connected", "You can close this window and go back to the site.") : done("Not connected", "The site can ask again later.");
|
|
1794
|
+
}
|
|
1795
|
+
document.getElementById("allow").addEventListener("click", () => decide(true));
|
|
1796
|
+
document.getElementById("deny").addEventListener("click", () => decide(false));
|
|
1797
|
+
// Answered in the Leuria app instead: close this window too.
|
|
1798
|
+
setInterval(async () => {
|
|
1799
|
+
const res = await fetch(location.pathname + "/state").catch(() => null);
|
|
1800
|
+
const state = res && res.ok ? await res.json() : null;
|
|
1801
|
+
if (state && state.decision !== "pending") done("Answered in Leuria", "You can close this window.");
|
|
1802
|
+
}, 1500);`;
|
|
1803
|
+
return page("Connect your AI", body, script);
|
|
1804
|
+
}
|
|
1805
|
+
function messagePage(title, text) {
|
|
1806
|
+
return page(title, `<h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p>`);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// src/routes.ts
|
|
1810
|
+
var MAX_ATTACHMENTS = 10;
|
|
1811
|
+
function parseAttachments(value) {
|
|
1812
|
+
if (value === void 0) return [];
|
|
1813
|
+
if (!Array.isArray(value)) throw new Error("attachments must be an array");
|
|
1814
|
+
if (value.length > MAX_ATTACHMENTS) throw new Error(`At most ${MAX_ATTACHMENTS} attachments`);
|
|
1815
|
+
return value.map((item, i) => {
|
|
1816
|
+
const a = item ?? {};
|
|
1817
|
+
const name = typeof a.name === "string" ? a.name.slice(0, 200) : void 0;
|
|
1818
|
+
if (a.type === "image" && typeof a.mimeType === "string" && /^image\//.test(a.mimeType) && typeof a.data === "string") {
|
|
1819
|
+
return { type: "image", mimeType: a.mimeType, data: a.data, name };
|
|
1820
|
+
}
|
|
1821
|
+
if (a.type === "text" && typeof a.text === "string") {
|
|
1822
|
+
return { type: "text", text: a.text, name, mimeType: typeof a.mimeType === "string" ? a.mimeType : void 0 };
|
|
1823
|
+
}
|
|
1824
|
+
throw new Error(`attachments[${i}] must be { type: "image", mimeType, data } or { type: "text", text }`);
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
var TERMINAL_EVENTS = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]);
|
|
1828
|
+
var POST_ACTIONS = /* @__PURE__ */ new Set([
|
|
1829
|
+
"approve",
|
|
1830
|
+
"prompt",
|
|
1831
|
+
"cancel-turn",
|
|
1832
|
+
"cancel",
|
|
1833
|
+
"close"
|
|
1834
|
+
]);
|
|
1835
|
+
async function handleSessionRequest(req, res, pathname, sm, requester) {
|
|
1836
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
1837
|
+
if (segments[0] !== "session") return false;
|
|
1838
|
+
try {
|
|
1839
|
+
if (req.method === "POST" && segments.length === 2 && segments[1] === "prepare") {
|
|
1840
|
+
await handlePrepare(req, res, sm, requester);
|
|
1841
|
+
return true;
|
|
1842
|
+
}
|
|
1843
|
+
const sessionId = segments[1];
|
|
1844
|
+
const action = segments[2];
|
|
1845
|
+
if (!sessionId) return false;
|
|
1846
|
+
const info = sm.get(sessionId);
|
|
1847
|
+
if (!info || info.origin !== requester) {
|
|
1848
|
+
sendJson(res, 404, { error: "Session not found" });
|
|
1849
|
+
return true;
|
|
1850
|
+
}
|
|
1851
|
+
if (req.method === "GET" && segments.length === 2) {
|
|
1852
|
+
sendJson(res, 200, info);
|
|
1853
|
+
return true;
|
|
1854
|
+
}
|
|
1855
|
+
if (req.method === "GET" && action === "stream") {
|
|
1856
|
+
handleStream(req, res, sm, sessionId);
|
|
1857
|
+
return true;
|
|
1858
|
+
}
|
|
1859
|
+
if (req.method === "POST" && action && POST_ACTIONS.has(action)) {
|
|
1860
|
+
const updated = await runAction(req, sm, sessionId, action);
|
|
1861
|
+
sendJson(res, 200, { sessionId: updated.id, status: updated.status });
|
|
1862
|
+
return true;
|
|
1863
|
+
}
|
|
1864
|
+
} catch (err) {
|
|
1865
|
+
sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
1866
|
+
return true;
|
|
1867
|
+
}
|
|
1868
|
+
return false;
|
|
1869
|
+
}
|
|
1870
|
+
async function runAction(req, sm, sessionId, action) {
|
|
1871
|
+
switch (action) {
|
|
1872
|
+
case "approve":
|
|
1873
|
+
return sm.approve(sessionId);
|
|
1874
|
+
case "prompt": {
|
|
1875
|
+
const body = await parseBody(req);
|
|
1876
|
+
if (typeof body.prompt !== "string" || !body.prompt.trim()) {
|
|
1877
|
+
throw new Error("Missing required field: prompt");
|
|
1878
|
+
}
|
|
1879
|
+
return sm.promptTurn(sessionId, body.prompt, parseAttachments(body.attachments));
|
|
1880
|
+
}
|
|
1881
|
+
case "cancel-turn":
|
|
1882
|
+
return sm.cancelTurn(sessionId);
|
|
1883
|
+
case "cancel":
|
|
1884
|
+
return sm.cancel(sessionId);
|
|
1885
|
+
default:
|
|
1886
|
+
return sm.close(sessionId);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
async function handlePrepare(req, res, sm, origin) {
|
|
1890
|
+
const body = await parseBody(req);
|
|
1891
|
+
if (body.prompt !== void 0 && (typeof body.prompt !== "string" || !body.prompt.trim())) {
|
|
1892
|
+
sendJson(res, 400, { error: "prompt must be a non-empty string when given" });
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
const info = sm.prepare({
|
|
1896
|
+
prompt: body.prompt,
|
|
1897
|
+
attachments: parseAttachments(body.attachments),
|
|
1898
|
+
systemPrompt: typeof body.systemPrompt === "string" && body.systemPrompt ? body.systemPrompt : void 0,
|
|
1899
|
+
maxTurns: typeof body.maxTurns === "number" ? body.maxTurns : void 0,
|
|
1900
|
+
origin
|
|
1901
|
+
});
|
|
1902
|
+
sendJson(res, 201, {
|
|
1903
|
+
sessionId: info.id,
|
|
1904
|
+
status: info.status,
|
|
1905
|
+
registrationToken: info.registrationToken,
|
|
1906
|
+
webmcpUrl: info.webmcpUrl
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
function handleStream(req, res, sm, sessionId) {
|
|
1910
|
+
const info = sm.get(sessionId);
|
|
1911
|
+
res.writeHead(200, {
|
|
1912
|
+
"Content-Type": "text/event-stream",
|
|
1913
|
+
"Cache-Control": "no-cache",
|
|
1914
|
+
Connection: "keep-alive"
|
|
1915
|
+
});
|
|
1916
|
+
res.flushHeaders();
|
|
1917
|
+
if (info.status === "completed" || info.status === "failed" || info.status === "cancelled") {
|
|
1918
|
+
writeSse(res, info.status, info.status === "failed" ? info.error ?? null : null);
|
|
1919
|
+
res.end();
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
if (info.status !== "pending_approval") {
|
|
1923
|
+
writeSse(res, "webmcp_ready", sm.webmcpReady(sessionId));
|
|
1924
|
+
}
|
|
1925
|
+
if (info.status === "idle") writeSse(res, "ready", null);
|
|
1926
|
+
for (const chunk of sm.turnChunks(sessionId)) writeSse(res, "chunk", chunk);
|
|
1927
|
+
const listener = (event, data) => {
|
|
1928
|
+
writeSse(res, event, data);
|
|
1929
|
+
if (TERMINAL_EVENTS.has(event)) {
|
|
1930
|
+
sm.removeListener(sessionId, listener);
|
|
1931
|
+
res.end();
|
|
1932
|
+
}
|
|
1933
|
+
};
|
|
1934
|
+
sm.addListener(sessionId, listener);
|
|
1935
|
+
req.on("close", () => sm.removeListener(sessionId, listener));
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// src/session-manager.ts
|
|
1939
|
+
import { randomUUID } from "crypto";
|
|
1940
|
+
import { mkdirSync as mkdirSync6, rmSync as rmSync4 } from "fs";
|
|
1941
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
1942
|
+
import { join as join6 } from "path";
|
|
1943
|
+
|
|
1944
|
+
// src/llm/llm-session.ts
|
|
1945
|
+
var LlmSession = class {
|
|
1946
|
+
constructor(options) {
|
|
1947
|
+
this.options = options;
|
|
1948
|
+
if (options.systemPrompt) this.messages.push({ role: "system", content: options.systemPrompt });
|
|
1949
|
+
}
|
|
1950
|
+
options;
|
|
1951
|
+
messages = [];
|
|
1952
|
+
turn = null;
|
|
1953
|
+
alive = false;
|
|
1954
|
+
get isAlive() {
|
|
1955
|
+
return this.alive;
|
|
1956
|
+
}
|
|
1957
|
+
/** Nothing to spawn: check that the provider answers. */
|
|
1958
|
+
async start() {
|
|
1959
|
+
const { provider } = this.options;
|
|
1960
|
+
try {
|
|
1961
|
+
const res = await (this.options.fetch ?? fetch)(`${provider.baseUrl}/models`, {
|
|
1962
|
+
headers: authHeaders(provider),
|
|
1963
|
+
signal: AbortSignal.timeout(5e3)
|
|
1964
|
+
});
|
|
1965
|
+
if (res.status === 401 || res.status === 403) return { error: `${provider.name} refused the API key.` };
|
|
1966
|
+
} catch {
|
|
1967
|
+
return { error: unreachable(provider) };
|
|
1968
|
+
}
|
|
1969
|
+
this.alive = true;
|
|
1970
|
+
return {};
|
|
1971
|
+
}
|
|
1972
|
+
async prompt(text, attachments = []) {
|
|
1973
|
+
if (!this.alive) return { error: "Session is not alive" };
|
|
1974
|
+
this.messages.push({ role: "user", content: userContent(text, attachments) });
|
|
1975
|
+
const controller = new AbortController();
|
|
1976
|
+
this.turn = controller;
|
|
1977
|
+
const maxSteps = this.options.maxSteps ?? 10;
|
|
1978
|
+
try {
|
|
1979
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
1980
|
+
const { content, toolCalls } = await this.complete(controller.signal, step === maxSteps - 1);
|
|
1981
|
+
this.messages.push({ role: "assistant", content: content || null, ...toolCalls.length ? { tool_calls: toolCalls } : {} });
|
|
1982
|
+
if (toolCalls.length === 0) return {};
|
|
1983
|
+
for (const call of toolCalls) {
|
|
1984
|
+
if (controller.signal.aborted) return {};
|
|
1985
|
+
this.messages.push({ role: "tool", tool_call_id: call.id, content: await this.runTool(call) });
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
return {};
|
|
1989
|
+
} catch (error) {
|
|
1990
|
+
if (controller.signal.aborted) return {};
|
|
1991
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
1992
|
+
} finally {
|
|
1993
|
+
this.turn = null;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
async cancelTurn() {
|
|
1997
|
+
this.turn?.abort();
|
|
1998
|
+
}
|
|
1999
|
+
close() {
|
|
2000
|
+
this.turn?.abort();
|
|
2001
|
+
this.alive = false;
|
|
2002
|
+
}
|
|
2003
|
+
async runTool(call) {
|
|
2004
|
+
const notify = (status) => this.options.onToolCall?.({ toolCallId: call.id, title: call.function.name, toolName: `mcp__webmcp__${call.function.name}`, kind: "other", status });
|
|
2005
|
+
notify("in_progress");
|
|
2006
|
+
let args = {};
|
|
2007
|
+
try {
|
|
2008
|
+
args = call.function.arguments ? JSON.parse(call.function.arguments) : {};
|
|
2009
|
+
} catch {
|
|
2010
|
+
notify("failed");
|
|
2011
|
+
return JSON.stringify({ error: "The arguments were not valid JSON." });
|
|
2012
|
+
}
|
|
2013
|
+
try {
|
|
2014
|
+
const result = await this.options.tools.call(call.function.name, args);
|
|
2015
|
+
notify("completed");
|
|
2016
|
+
return typeof result === "string" ? result : JSON.stringify(result ?? null);
|
|
2017
|
+
} catch (error) {
|
|
2018
|
+
notify("failed");
|
|
2019
|
+
return JSON.stringify({ error: error instanceof Error ? error.message : String(error) });
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
/** One streamed model call; text and reasoning go out as they arrive. */
|
|
2023
|
+
async complete(signal, lastStep) {
|
|
2024
|
+
const { provider, model } = this.options;
|
|
2025
|
+
const tools = this.options.tools.list();
|
|
2026
|
+
const body = { model, messages: this.messages, stream: true };
|
|
2027
|
+
if (tools.length) {
|
|
2028
|
+
body.tools = tools.map((t) => ({
|
|
2029
|
+
type: "function",
|
|
2030
|
+
function: { name: t.name, description: t.description ?? "", parameters: t.inputSchema }
|
|
2031
|
+
}));
|
|
2032
|
+
if (lastStep) body.tool_choice = "none";
|
|
2033
|
+
}
|
|
2034
|
+
let res;
|
|
2035
|
+
try {
|
|
2036
|
+
res = await (this.options.fetch ?? fetch)(`${provider.baseUrl}/chat/completions`, {
|
|
2037
|
+
method: "POST",
|
|
2038
|
+
headers: { "Content-Type": "application/json", Accept: "text/event-stream", ...authHeaders(provider) },
|
|
2039
|
+
body: JSON.stringify(body),
|
|
2040
|
+
signal
|
|
2041
|
+
});
|
|
2042
|
+
} catch (error) {
|
|
2043
|
+
if (signal.aborted) throw error;
|
|
2044
|
+
throw new Error(unreachable(provider));
|
|
2045
|
+
}
|
|
2046
|
+
if (!res.ok || !res.body) {
|
|
2047
|
+
const raw = await res.text().catch(() => "");
|
|
2048
|
+
let detail = raw.slice(0, 300);
|
|
2049
|
+
try {
|
|
2050
|
+
const parsed = JSON.parse(raw);
|
|
2051
|
+
detail = typeof parsed.error === "string" ? parsed.error : parsed.error?.message ?? detail;
|
|
2052
|
+
} catch {
|
|
2053
|
+
}
|
|
2054
|
+
if (res.status === 404 && /model/i.test(detail)) throw new Error(`${provider.name} does not have the model "${model}".`);
|
|
2055
|
+
if (res.status === 401 || res.status === 403) throw new Error(`${provider.name} refused the API key.`);
|
|
2056
|
+
throw new Error(detail ? `${provider.name}: ${detail}` : `${provider.name} answered with an error (${res.status}).`);
|
|
2057
|
+
}
|
|
2058
|
+
let content = "";
|
|
2059
|
+
const calls = /* @__PURE__ */ new Map();
|
|
2060
|
+
for await (const data of sseData(res.body)) {
|
|
2061
|
+
if (data === "[DONE]") break;
|
|
2062
|
+
let chunk;
|
|
2063
|
+
try {
|
|
2064
|
+
chunk = JSON.parse(data);
|
|
2065
|
+
} catch {
|
|
2066
|
+
continue;
|
|
2067
|
+
}
|
|
2068
|
+
if (chunk.error?.message) throw new Error(`${provider.name}: ${chunk.error.message}`);
|
|
2069
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
2070
|
+
if (!delta) continue;
|
|
2071
|
+
const thought = delta.reasoning_content ?? delta.reasoning;
|
|
2072
|
+
if (thought) this.options.onThought?.(thought);
|
|
2073
|
+
if (delta.content) {
|
|
2074
|
+
content += delta.content;
|
|
2075
|
+
this.options.onChunk?.(delta.content);
|
|
2076
|
+
}
|
|
2077
|
+
for (const part of delta.tool_calls ?? []) {
|
|
2078
|
+
const index = part.index ?? calls.size;
|
|
2079
|
+
const call = calls.get(index) ?? { id: "", type: "function", function: { name: "", arguments: "" } };
|
|
2080
|
+
if (part.id) call.id = part.id;
|
|
2081
|
+
if (part.function?.name) call.function.name += part.function.name;
|
|
2082
|
+
if (part.function?.arguments) call.function.arguments += part.function.arguments;
|
|
2083
|
+
calls.set(index, call);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
const toolCalls = [...calls.values()].map((c, i) => ({ ...c, id: c.id || `call_${i}_${Date.now()}` }));
|
|
2087
|
+
return { content, toolCalls };
|
|
2088
|
+
}
|
|
2089
|
+
};
|
|
2090
|
+
function userContent(text, attachments) {
|
|
2091
|
+
if (attachments.length === 0) return text;
|
|
2092
|
+
const parts = [{ type: "text", text }];
|
|
2093
|
+
for (const file of attachments) {
|
|
2094
|
+
if (file.type === "image") parts.push({ type: "image_url", image_url: { url: `data:${file.mimeType};base64,${file.data}` } });
|
|
2095
|
+
else parts.push({ type: "text", text: `<file name="${file.name ?? "attachment"}">
|
|
2096
|
+
${file.text}
|
|
2097
|
+
</file>` });
|
|
2098
|
+
}
|
|
2099
|
+
return parts;
|
|
2100
|
+
}
|
|
2101
|
+
async function* sseData(body) {
|
|
2102
|
+
const reader = body.getReader();
|
|
2103
|
+
const decoder = new TextDecoder();
|
|
2104
|
+
let buffer = "";
|
|
2105
|
+
try {
|
|
2106
|
+
for (; ; ) {
|
|
2107
|
+
const { value, done } = await reader.read();
|
|
2108
|
+
if (done) break;
|
|
2109
|
+
buffer += decoder.decode(value, { stream: true });
|
|
2110
|
+
let index;
|
|
2111
|
+
while ((index = buffer.search(/\r?\n\r?\n/)) >= 0) {
|
|
2112
|
+
const block = buffer.slice(0, index);
|
|
2113
|
+
buffer = buffer.slice(index).replace(/^\r?\n\r?\n/, "");
|
|
2114
|
+
const data = block.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
|
|
2115
|
+
if (data) yield data;
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
} finally {
|
|
2119
|
+
reader.releaseLock();
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// src/session-manager.ts
|
|
2124
|
+
var MAX_SESSIONS_PER_ORIGIN = 4;
|
|
2125
|
+
var MAX_TURNS_LIMIT = 50;
|
|
2126
|
+
var FORGET_AFTER_MS = 5 * 6e4;
|
|
2127
|
+
var TERMINAL = /* @__PURE__ */ new Set([
|
|
2128
|
+
"completed",
|
|
2129
|
+
"failed",
|
|
2130
|
+
"cancelled"
|
|
2131
|
+
]);
|
|
2132
|
+
var SessionManager = class {
|
|
2133
|
+
constructor(options) {
|
|
2134
|
+
this.options = options;
|
|
2135
|
+
}
|
|
2136
|
+
options;
|
|
2137
|
+
sessions = /* @__PURE__ */ new Map();
|
|
2138
|
+
// ── Lifecycle ───────────────────────────────────────────────────────
|
|
2139
|
+
prepare(params) {
|
|
2140
|
+
const live = [...this.sessions.values()].filter(
|
|
2141
|
+
(s) => s.params.origin === params.origin && !TERMINAL.has(s.status)
|
|
2142
|
+
);
|
|
2143
|
+
if (live.length >= MAX_SESSIONS_PER_ORIGIN) {
|
|
2144
|
+
throw new Error(
|
|
2145
|
+
`Too many open sessions for this site (${MAX_SESSIONS_PER_ORIGIN}). Close one first.`
|
|
2146
|
+
);
|
|
2147
|
+
}
|
|
2148
|
+
if (params.maxTurns !== void 0) {
|
|
2149
|
+
params.maxTurns = Math.min(Math.max(1, Math.floor(params.maxTurns)), MAX_TURNS_LIMIT);
|
|
2150
|
+
}
|
|
2151
|
+
const id = randomUUID();
|
|
2152
|
+
const channel = this.options.webMcpServer.createChannel(id);
|
|
2153
|
+
const session = {
|
|
2154
|
+
id,
|
|
2155
|
+
status: "pending_approval",
|
|
2156
|
+
params,
|
|
2157
|
+
cwd: this.allocateSandbox(id),
|
|
2158
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
2159
|
+
turnChunks: [],
|
|
2160
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
2161
|
+
registrationToken: channel.registrationToken,
|
|
2162
|
+
channelToken: channel.channelToken
|
|
2163
|
+
};
|
|
2164
|
+
this.sessions.set(id, session);
|
|
2165
|
+
this.options.logger.info("session prepared", { sessionId: id, origin: params.origin });
|
|
2166
|
+
return this.toInfo(session);
|
|
2167
|
+
}
|
|
2168
|
+
approve(sessionId) {
|
|
2169
|
+
const session = this.require(sessionId);
|
|
2170
|
+
if (session.status !== "pending_approval") {
|
|
2171
|
+
throw new Error(`Session is ${session.status}, expected pending_approval`);
|
|
2172
|
+
}
|
|
2173
|
+
session.status = "running";
|
|
2174
|
+
this.notify(session, "webmcp_ready", this.webmcpReady(session));
|
|
2175
|
+
void this.execute(session);
|
|
2176
|
+
return this.toInfo(session);
|
|
2177
|
+
}
|
|
2178
|
+
promptTurn(sessionId, prompt, attachments = []) {
|
|
2179
|
+
const session = this.require(sessionId);
|
|
2180
|
+
if (!session.live?.isAlive) {
|
|
2181
|
+
throw new Error("Agent session has ended. Start a new session.");
|
|
2182
|
+
}
|
|
2183
|
+
if (session.status !== "idle") {
|
|
2184
|
+
throw new Error(`Session is ${session.status}, expected idle`);
|
|
2185
|
+
}
|
|
2186
|
+
void this.runTurn(session, prompt, attachments);
|
|
2187
|
+
return this.toInfo(session);
|
|
2188
|
+
}
|
|
2189
|
+
async cancelTurn(sessionId) {
|
|
2190
|
+
const session = this.require(sessionId);
|
|
2191
|
+
if (session.status === "running") await session.live?.cancelTurn();
|
|
2192
|
+
return this.toInfo(session);
|
|
2193
|
+
}
|
|
2194
|
+
cancel(sessionId) {
|
|
2195
|
+
const session = this.require(sessionId);
|
|
2196
|
+
if (!TERMINAL.has(session.status)) {
|
|
2197
|
+
session.status = "cancelled";
|
|
2198
|
+
this.teardown(session);
|
|
2199
|
+
this.notify(session, "cancelled", null);
|
|
2200
|
+
}
|
|
2201
|
+
return this.toInfo(session);
|
|
2202
|
+
}
|
|
2203
|
+
close(sessionId) {
|
|
2204
|
+
const session = this.require(sessionId);
|
|
2205
|
+
if (!TERMINAL.has(session.status)) {
|
|
2206
|
+
session.status = "completed";
|
|
2207
|
+
this.teardown(session);
|
|
2208
|
+
this.notify(session, "completed", null);
|
|
2209
|
+
}
|
|
2210
|
+
return this.toInfo(session);
|
|
2211
|
+
}
|
|
2212
|
+
get(sessionId) {
|
|
2213
|
+
const session = this.sessions.get(sessionId);
|
|
2214
|
+
return session ? this.toInfo(session) : null;
|
|
2215
|
+
}
|
|
2216
|
+
/** Current-turn text, for SSE replay. */
|
|
2217
|
+
turnChunks(sessionId) {
|
|
2218
|
+
return this.sessions.get(sessionId)?.turnChunks ?? [];
|
|
2219
|
+
}
|
|
2220
|
+
webmcpReady(session) {
|
|
2221
|
+
const s = typeof session === "string" ? this.require(session) : session;
|
|
2222
|
+
return {
|
|
2223
|
+
registrationToken: s.registrationToken,
|
|
2224
|
+
webmcpUrl: `http://127.0.0.1:${this.options.port}`
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
addListener(sessionId, listener) {
|
|
2228
|
+
this.sessions.get(sessionId)?.listeners.add(listener);
|
|
2229
|
+
}
|
|
2230
|
+
removeListener(sessionId, listener) {
|
|
2231
|
+
this.sessions.get(sessionId)?.listeners.delete(listener);
|
|
2232
|
+
}
|
|
2233
|
+
/** End every session of an origin, e.g. when its grant is revoked. */
|
|
2234
|
+
/** End the sessions of the origins that match (their AI or model changed): the next message starts a new one. */
|
|
2235
|
+
closeWhere(match) {
|
|
2236
|
+
for (const session of this.sessions.values()) {
|
|
2237
|
+
if (match(session.params.origin)) this.cancel(session.id);
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
closeOrigin(origin) {
|
|
2241
|
+
for (const session of this.sessions.values()) {
|
|
2242
|
+
if (session.params.origin === origin) this.cancel(session.id);
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
shutdown() {
|
|
2246
|
+
for (const session of this.sessions.values()) {
|
|
2247
|
+
this.teardown(session);
|
|
2248
|
+
session.listeners.clear();
|
|
2249
|
+
}
|
|
2250
|
+
this.sessions.clear();
|
|
2251
|
+
}
|
|
2252
|
+
// ── Internals ───────────────────────────────────────────────────────
|
|
2253
|
+
async execute(session) {
|
|
2254
|
+
const { logger } = this.options;
|
|
2255
|
+
let launch;
|
|
2256
|
+
try {
|
|
2257
|
+
launch = await this.options.resolveAgent(session.params.origin);
|
|
2258
|
+
} catch (err) {
|
|
2259
|
+
this.fail(session, err instanceof Error ? err.message : String(err));
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
if (TERMINAL.has(session.status)) return;
|
|
2263
|
+
const live = launch.llm ? this.llmSession(session, launch.llm) : this.acpSession(session, launch);
|
|
2264
|
+
session.live = live;
|
|
2265
|
+
const timeoutMs = this.options.startTimeoutMs ?? 6e4;
|
|
2266
|
+
let timer;
|
|
2267
|
+
const started = await Promise.race([
|
|
2268
|
+
live.start(),
|
|
2269
|
+
new Promise((resolve) => {
|
|
2270
|
+
timer = setTimeout(
|
|
2271
|
+
() => resolve({ error: `The AI did not start within ${Math.round(timeoutMs / 1e3)} s` }),
|
|
2272
|
+
timeoutMs
|
|
2273
|
+
);
|
|
2274
|
+
})
|
|
2275
|
+
]);
|
|
2276
|
+
clearTimeout(timer);
|
|
2277
|
+
if (started.error) {
|
|
2278
|
+
if (launch.id && "authRequired" in started && started.authRequired) this.options.onAgentState?.(launch.id, { ok: false });
|
|
2279
|
+
this.fail(session, started.error);
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
if (launch.id) this.options.onAgentState?.(launch.id, { ok: true, models: live.models });
|
|
2283
|
+
logger.info("agent started", { sessionId: session.id, llm: Boolean(launch.llm) });
|
|
2284
|
+
if (session.params.prompt) {
|
|
2285
|
+
await this.runTurn(session, session.params.prompt, session.params.attachments ?? []);
|
|
2286
|
+
} else if (session.status === "running") {
|
|
2287
|
+
session.status = "idle";
|
|
2288
|
+
this.notify(session, "ready", null);
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
/** An LLM provider, driven by the engine with the page's tools. */
|
|
2292
|
+
llmSession(session, llm) {
|
|
2293
|
+
const { webMcpServer } = this.options;
|
|
2294
|
+
return new LlmSession({
|
|
2295
|
+
provider: llm.provider,
|
|
2296
|
+
model: llm.model,
|
|
2297
|
+
systemPrompt: session.params.systemPrompt,
|
|
2298
|
+
maxSteps: session.params.maxTurns,
|
|
2299
|
+
tools: {
|
|
2300
|
+
list: () => webMcpServer.listTools(session.id),
|
|
2301
|
+
call: (name, args) => webMcpServer.callTool(session.id, name, args)
|
|
2302
|
+
},
|
|
2303
|
+
onChunk: (text) => {
|
|
2304
|
+
session.turnChunks.push(text);
|
|
2305
|
+
this.notify(session, "chunk", text);
|
|
2306
|
+
},
|
|
2307
|
+
onThought: (text) => this.notify(session, "thought", text),
|
|
2308
|
+
onToolCall: (event) => this.notify(session, "tool_call", event)
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
/** An ACP agent process under its policy. */
|
|
2312
|
+
acpSession(session, launch) {
|
|
2313
|
+
const { logger, port } = this.options;
|
|
2314
|
+
const mcpUrl = `http://127.0.0.1:${port}/webmcp/mcp`;
|
|
2315
|
+
const custom = launch.configure?.({
|
|
2316
|
+
sandbox: session.cwd,
|
|
2317
|
+
mcpUrl,
|
|
2318
|
+
mcpToken: session.channelToken,
|
|
2319
|
+
systemPrompt: session.params.systemPrompt,
|
|
2320
|
+
maxTurns: session.params.maxTurns
|
|
2321
|
+
});
|
|
2322
|
+
const live = new AcpLiveSession({
|
|
2323
|
+
command: launch.command,
|
|
2324
|
+
args: launch.args,
|
|
2325
|
+
cwd: session.cwd,
|
|
2326
|
+
envVars: { ...launch.env, ...custom?.env },
|
|
2327
|
+
model: launch.model,
|
|
2328
|
+
mcpServers: custom?.mcpServers ?? ((caps) => {
|
|
2329
|
+
const stdio = this.options.stdioMcpCommand;
|
|
2330
|
+
if (caps.http === false && stdio) {
|
|
2331
|
+
return [
|
|
2332
|
+
{
|
|
2333
|
+
name: WEBMCP_SERVER_NAME,
|
|
2334
|
+
command: stdio.command,
|
|
2335
|
+
args: [...stdio.args, mcpUrl],
|
|
2336
|
+
env: [{ name: "LEURIA_MCP_TOKEN", value: session.channelToken }]
|
|
2337
|
+
}
|
|
2338
|
+
];
|
|
2339
|
+
}
|
|
2340
|
+
return [
|
|
2341
|
+
{
|
|
2342
|
+
type: "http",
|
|
2343
|
+
name: WEBMCP_SERVER_NAME,
|
|
2344
|
+
url: mcpUrl,
|
|
2345
|
+
headers: [{ name: "Authorization", value: `Bearer ${session.channelToken}` }]
|
|
2346
|
+
}
|
|
2347
|
+
];
|
|
2348
|
+
}),
|
|
2349
|
+
sessionConfig: {
|
|
2350
|
+
systemPrompt: session.params.systemPrompt,
|
|
2351
|
+
maxTurns: session.params.maxTurns
|
|
2352
|
+
},
|
|
2353
|
+
sessionMeta: custom?.meta,
|
|
2354
|
+
onChunk: (text) => {
|
|
2355
|
+
session.turnChunks.push(text);
|
|
2356
|
+
this.notify(session, "chunk", text);
|
|
2357
|
+
},
|
|
2358
|
+
onThought: (text) => this.notify(session, "thought", text),
|
|
2359
|
+
onToolCall: (event) => this.notify(session, "tool_call", event),
|
|
2360
|
+
onPermission: (decision) => {
|
|
2361
|
+
const level = decision.allow ? "info" : "warn";
|
|
2362
|
+
logger[level]("permission", {
|
|
2363
|
+
sessionId: session.id,
|
|
2364
|
+
tool: decision.toolName,
|
|
2365
|
+
allow: decision.allow
|
|
2366
|
+
});
|
|
2367
|
+
this.notify(session, "permission", {
|
|
2368
|
+
toolName: decision.toolName,
|
|
2369
|
+
allow: decision.allow
|
|
2370
|
+
});
|
|
2371
|
+
},
|
|
2372
|
+
onStderr: (text) => this.notify(session, "log", text),
|
|
2373
|
+
onSpawnError: (err) => this.fail(session, `Failed to start the agent: ${err.message}`),
|
|
2374
|
+
onExit: (code, signal) => {
|
|
2375
|
+
if (TERMINAL.has(session.status)) return;
|
|
2376
|
+
if (code !== 0 && code !== null) {
|
|
2377
|
+
this.fail(session, `Agent exited with code ${code}${signal ? ` (${signal})` : ""}`);
|
|
2378
|
+
} else {
|
|
2379
|
+
session.status = "completed";
|
|
2380
|
+
this.teardown(session);
|
|
2381
|
+
this.notify(session, "completed", null);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
});
|
|
2385
|
+
return live;
|
|
2386
|
+
}
|
|
2387
|
+
async runTurn(session, prompt, attachments) {
|
|
2388
|
+
session.status = "running";
|
|
2389
|
+
session.turnChunks = [];
|
|
2390
|
+
this.notify(session, "turn_start", null);
|
|
2391
|
+
const startedAt = Date.now();
|
|
2392
|
+
const result = await session.live.prompt(prompt, attachments);
|
|
2393
|
+
if (session.status !== "running") return;
|
|
2394
|
+
if (result.error) {
|
|
2395
|
+
this.fail(session, result.error);
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
session.status = "idle";
|
|
2399
|
+
this.options.logger.info("turn completed", {
|
|
2400
|
+
sessionId: session.id,
|
|
2401
|
+
durationMs: Date.now() - startedAt
|
|
2402
|
+
});
|
|
2403
|
+
this.notify(session, "turn_completed", {
|
|
2404
|
+
text: session.turnChunks.join(""),
|
|
2405
|
+
durationMs: Date.now() - startedAt
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
fail(session, detail) {
|
|
2409
|
+
if (TERMINAL.has(session.status)) return;
|
|
2410
|
+
session.status = "failed";
|
|
2411
|
+
session.error = detail;
|
|
2412
|
+
this.options.logger.error("session failed", { sessionId: session.id, detail });
|
|
2413
|
+
this.teardown(session);
|
|
2414
|
+
this.notify(session, "failed", detail);
|
|
2415
|
+
}
|
|
2416
|
+
/**
|
|
2417
|
+
* Fresh empty directory per session, so the agent finds no project
|
|
2418
|
+
* files (CLAUDE.md, repo) to act on.
|
|
2419
|
+
*/
|
|
2420
|
+
allocateSandbox(sessionId) {
|
|
2421
|
+
const dir = join6(tmpdir3(), `leuria-${sessionId}`);
|
|
2422
|
+
mkdirSync6(dir, { recursive: true });
|
|
2423
|
+
return dir;
|
|
2424
|
+
}
|
|
2425
|
+
teardown(session) {
|
|
2426
|
+
try {
|
|
2427
|
+
session.live?.close();
|
|
2428
|
+
} catch {
|
|
2429
|
+
}
|
|
2430
|
+
session.live = void 0;
|
|
2431
|
+
this.options.webMcpServer.removeChannel(session.id);
|
|
2432
|
+
setTimeout(() => this.sessions.delete(session.id), FORGET_AFTER_MS).unref();
|
|
2433
|
+
try {
|
|
2434
|
+
rmSync4(session.cwd, { recursive: true, force: true });
|
|
2435
|
+
} catch (err) {
|
|
2436
|
+
this.options.logger.warn("failed to remove sandbox", {
|
|
2437
|
+
cwd: session.cwd,
|
|
2438
|
+
err: err instanceof Error ? err.message : String(err)
|
|
2439
|
+
});
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
notify(session, event, data) {
|
|
2443
|
+
for (const listener of session.listeners) {
|
|
2444
|
+
try {
|
|
2445
|
+
listener(event, data);
|
|
2446
|
+
} catch {
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
require(sessionId) {
|
|
2451
|
+
const session = this.sessions.get(sessionId);
|
|
2452
|
+
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
|
2453
|
+
return session;
|
|
2454
|
+
}
|
|
2455
|
+
toInfo(session) {
|
|
2456
|
+
return {
|
|
2457
|
+
id: session.id,
|
|
2458
|
+
status: session.status,
|
|
2459
|
+
origin: session.params.origin,
|
|
2460
|
+
createdAt: session.createdAt.toISOString(),
|
|
2461
|
+
error: session.error,
|
|
2462
|
+
...this.webmcpReady(session)
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
};
|
|
2466
|
+
|
|
2467
|
+
// src/webmcp-server.ts
|
|
2468
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2469
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
2470
|
+
var REQUEST_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
2471
|
+
var HTTP_KEEPALIVE_INTERVAL_MS = 2e4;
|
|
2472
|
+
var PING_INTERVAL_MS = 15e3;
|
|
2473
|
+
var SERVER_NAME = "leuria-webmcp";
|
|
2474
|
+
var SUPPORTED_PROTOCOL_VERSIONS = ["2024-11-05", "2025-03-26", "2025-06-18"];
|
|
2475
|
+
var SERVER_VERSION = "1.0.0";
|
|
2476
|
+
var WebMcpServer = class {
|
|
2477
|
+
constructor(logger, port) {
|
|
2478
|
+
this.logger = logger;
|
|
2479
|
+
this.port = port;
|
|
2480
|
+
this.registerWss = new WebSocketServer({ noServer: true });
|
|
2481
|
+
this.channelWss = new WebSocketServer({ noServer: true });
|
|
2482
|
+
}
|
|
2483
|
+
logger;
|
|
2484
|
+
port;
|
|
2485
|
+
channels = /* @__PURE__ */ new Map();
|
|
2486
|
+
registrationTokens = /* @__PURE__ */ new Map();
|
|
2487
|
+
sessionChannels = /* @__PURE__ */ new Map();
|
|
2488
|
+
tokenToChannel = /* @__PURE__ */ new Map();
|
|
2489
|
+
registerWss;
|
|
2490
|
+
channelWss;
|
|
2491
|
+
// ── Channel lifecycle ────────────────────────────────────────────────
|
|
2492
|
+
/**
|
|
2493
|
+
* Allocate a channel for an ACP session. Returns:
|
|
2494
|
+
* - `registrationToken` — base64-encoded `{ server, token }` blob the
|
|
2495
|
+
* browser presents on the `/webmcp/register` WS to claim the channel.
|
|
2496
|
+
* - `channelToken` — Bearer credential the agent presents on the
|
|
2497
|
+
* `/webmcp/mcp` HTTP endpoint.
|
|
2498
|
+
* - `channelId` — server-side handle (used in the channel WS path).
|
|
2499
|
+
*/
|
|
2500
|
+
createChannel(sessionId) {
|
|
2501
|
+
const channelId = randomUUID2();
|
|
2502
|
+
const channelToken = randomUUID2();
|
|
2503
|
+
const regTokenRaw = randomUUID2();
|
|
2504
|
+
const channel = {
|
|
2505
|
+
id: channelId,
|
|
2506
|
+
sessionId,
|
|
2507
|
+
token: channelToken,
|
|
2508
|
+
browserWs: null,
|
|
2509
|
+
tools: /* @__PURE__ */ new Map(),
|
|
2510
|
+
resources: /* @__PURE__ */ new Map(),
|
|
2511
|
+
prompts: /* @__PURE__ */ new Map(),
|
|
2512
|
+
pendingRequests: /* @__PURE__ */ new Map(),
|
|
2513
|
+
pingTimer: null
|
|
2514
|
+
};
|
|
2515
|
+
this.channels.set(channelId, channel);
|
|
2516
|
+
this.sessionChannels.set(sessionId, channelId);
|
|
2517
|
+
this.tokenToChannel.set(channelToken, channelId);
|
|
2518
|
+
this.registrationTokens.set(regTokenRaw, { channelId, sessionId });
|
|
2519
|
+
this.logger.info("WebMCP channel created", { channelId, sessionId });
|
|
2520
|
+
const registrationPayload = {
|
|
2521
|
+
server: `ws://127.0.0.1:${this.port}`,
|
|
2522
|
+
token: regTokenRaw
|
|
2523
|
+
};
|
|
2524
|
+
const registrationToken = Buffer.from(
|
|
2525
|
+
JSON.stringify(registrationPayload)
|
|
2526
|
+
).toString("base64");
|
|
2527
|
+
return { registrationToken, channelToken, channelId };
|
|
2528
|
+
}
|
|
2529
|
+
getChannelSummaries() {
|
|
2530
|
+
return Array.from(this.channels.values()).map((ch) => ({
|
|
2531
|
+
id: ch.id,
|
|
2532
|
+
sessionId: ch.sessionId,
|
|
2533
|
+
connected: ch.browserWs !== null && ch.browserWs.readyState === WebSocket.OPEN,
|
|
2534
|
+
tools: Array.from(ch.tools.values()).map((t) => ({
|
|
2535
|
+
name: t.name,
|
|
2536
|
+
description: t.description
|
|
2537
|
+
})),
|
|
2538
|
+
resources: Array.from(ch.resources.values()).map((r) => ({
|
|
2539
|
+
uri: r.uri,
|
|
2540
|
+
name: r.name,
|
|
2541
|
+
description: r.description
|
|
2542
|
+
})),
|
|
2543
|
+
prompts: Array.from(ch.prompts.values()).map((p) => ({
|
|
2544
|
+
name: p.name,
|
|
2545
|
+
description: p.description
|
|
2546
|
+
}))
|
|
2547
|
+
}));
|
|
2548
|
+
}
|
|
2549
|
+
/**
|
|
2550
|
+
* Tear everything down — close the two WebSocketServers, kill every
|
|
2551
|
+
* ping timer, reject every pending request, force-close every browser
|
|
2552
|
+
* channel WS. Called from `daemon.ts`'s `shutdown` so the process can
|
|
2553
|
+
* exit on Ctrl-C; without it the WS servers keep the event loop alive
|
|
2554
|
+
* indefinitely.
|
|
2555
|
+
*/
|
|
2556
|
+
close() {
|
|
2557
|
+
for (const channel of this.channels.values()) {
|
|
2558
|
+
if (channel.pingTimer) {
|
|
2559
|
+
clearInterval(channel.pingTimer);
|
|
2560
|
+
channel.pingTimer = null;
|
|
2561
|
+
}
|
|
2562
|
+
for (const [, pending] of channel.pendingRequests) {
|
|
2563
|
+
clearTimeout(pending.timeout);
|
|
2564
|
+
try {
|
|
2565
|
+
pending.reject(new Error("WebMCP server shutting down"));
|
|
2566
|
+
} catch {
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
channel.pendingRequests.clear();
|
|
2570
|
+
if (channel.browserWs) {
|
|
2571
|
+
try {
|
|
2572
|
+
channel.browserWs.terminate();
|
|
2573
|
+
} catch {
|
|
2574
|
+
}
|
|
2575
|
+
channel.browserWs = null;
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
this.channels.clear();
|
|
2579
|
+
this.tokenToChannel.clear();
|
|
2580
|
+
this.sessionChannels.clear();
|
|
2581
|
+
this.registrationTokens.clear();
|
|
2582
|
+
try {
|
|
2583
|
+
this.registerWss.close();
|
|
2584
|
+
} catch {
|
|
2585
|
+
}
|
|
2586
|
+
try {
|
|
2587
|
+
this.channelWss.close();
|
|
2588
|
+
} catch {
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
removeChannel(sessionId) {
|
|
2592
|
+
const channelId = this.sessionChannels.get(sessionId);
|
|
2593
|
+
if (!channelId) return;
|
|
2594
|
+
const channel = this.channels.get(channelId);
|
|
2595
|
+
if (channel) {
|
|
2596
|
+
if (channel.pingTimer) {
|
|
2597
|
+
clearInterval(channel.pingTimer);
|
|
2598
|
+
channel.pingTimer = null;
|
|
2599
|
+
}
|
|
2600
|
+
for (const [, pending] of channel.pendingRequests) {
|
|
2601
|
+
clearTimeout(pending.timeout);
|
|
2602
|
+
pending.reject(new Error("WebMCP channel removed"));
|
|
2603
|
+
}
|
|
2604
|
+
channel.pendingRequests.clear();
|
|
2605
|
+
if (channel.browserWs && channel.browserWs.readyState === WebSocket.OPEN) {
|
|
2606
|
+
channel.browserWs.close(1e3, "channel removed");
|
|
2607
|
+
}
|
|
2608
|
+
this.tokenToChannel.delete(channel.token);
|
|
2609
|
+
this.channels.delete(channelId);
|
|
2610
|
+
}
|
|
2611
|
+
for (const [token, entry] of this.registrationTokens) {
|
|
2612
|
+
if (entry.channelId === channelId) {
|
|
2613
|
+
this.registrationTokens.delete(token);
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
this.sessionChannels.delete(sessionId);
|
|
2617
|
+
this.logger.info("WebMCP channel removed", { sessionId, channelId });
|
|
2618
|
+
}
|
|
2619
|
+
// ── In-process access (the engine's own agent loop for LLM providers) ──
|
|
2620
|
+
/** The page tools a session's browser has declared. */
|
|
2621
|
+
listTools(sessionId) {
|
|
2622
|
+
const channelId = this.sessionChannels.get(sessionId);
|
|
2623
|
+
const channel = channelId ? this.channels.get(channelId) : void 0;
|
|
2624
|
+
return channel ? Array.from(channel.tools.values()) : [];
|
|
2625
|
+
}
|
|
2626
|
+
/** Run a page tool in the browser; resolves with its JSON result. Throws on tool errors. */
|
|
2627
|
+
async callTool(sessionId, name, args) {
|
|
2628
|
+
const channelId = this.sessionChannels.get(sessionId);
|
|
2629
|
+
const channel = channelId ? this.channels.get(channelId) : void 0;
|
|
2630
|
+
if (!channel) throw new Error("The page is not connected");
|
|
2631
|
+
if (!channel.tools.has(name)) throw new Error(`Unknown tool: ${name}`);
|
|
2632
|
+
return this.forwardToBrowser(channel, "callTool", { tool: name, arguments: args });
|
|
2633
|
+
}
|
|
2634
|
+
// ── HTTP / WS dispatch (called from daemon) ──────────────────────────
|
|
2635
|
+
/** Returns true when the request path belongs to WebMCP. */
|
|
2636
|
+
matches(pathname) {
|
|
2637
|
+
return pathname.startsWith("/webmcp/");
|
|
2638
|
+
}
|
|
2639
|
+
handleUpgrade(req, socket, head) {
|
|
2640
|
+
const url = new URL(
|
|
2641
|
+
req.url ?? "/",
|
|
2642
|
+
`http://${req.headers.host ?? "127.0.0.1"}`
|
|
2643
|
+
);
|
|
2644
|
+
if (url.pathname === "/webmcp/register") {
|
|
2645
|
+
this.registerWss.handleUpgrade(
|
|
2646
|
+
req,
|
|
2647
|
+
socket,
|
|
2648
|
+
head,
|
|
2649
|
+
(ws) => this.handleRegistrationConnection(ws)
|
|
2650
|
+
);
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
const channelMatch = url.pathname.match(/^\/webmcp\/channel\/([^/]+)$/);
|
|
2654
|
+
if (channelMatch) {
|
|
2655
|
+
const channelId = channelMatch[1];
|
|
2656
|
+
const token = url.searchParams.get("token");
|
|
2657
|
+
const channel = this.channels.get(channelId);
|
|
2658
|
+
if (!channel || channel.token !== token) {
|
|
2659
|
+
this.logger.warn("WebMCP channel auth failed", { channelId });
|
|
2660
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
2661
|
+
socket.destroy();
|
|
2662
|
+
return;
|
|
2663
|
+
}
|
|
2664
|
+
this.channelWss.handleUpgrade(
|
|
2665
|
+
req,
|
|
2666
|
+
socket,
|
|
2667
|
+
head,
|
|
2668
|
+
(ws) => this.handleChannelConnection(channel, ws)
|
|
2669
|
+
);
|
|
2670
|
+
return;
|
|
2671
|
+
}
|
|
2672
|
+
socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
|
|
2673
|
+
socket.destroy();
|
|
2674
|
+
}
|
|
2675
|
+
async handleHttp(req, res) {
|
|
2676
|
+
const url = new URL(
|
|
2677
|
+
req.url ?? "/",
|
|
2678
|
+
`http://${req.headers.host ?? "127.0.0.1"}`
|
|
2679
|
+
);
|
|
2680
|
+
if (url.pathname !== "/webmcp/mcp") {
|
|
2681
|
+
this.sendJsonRpcError(res, null, -32601, "Not found", 404);
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
if (req.method !== "POST") {
|
|
2685
|
+
this.sendJsonRpcError(res, null, -32600, "Method not allowed", 405);
|
|
2686
|
+
return;
|
|
2687
|
+
}
|
|
2688
|
+
const authHeader = req.headers.authorization;
|
|
2689
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
2690
|
+
this.sendJsonRpcError(res, null, -32e3, "Unauthorized", 401);
|
|
2691
|
+
return;
|
|
2692
|
+
}
|
|
2693
|
+
const channelToken = authHeader.slice(7);
|
|
2694
|
+
const channelId = this.tokenToChannel.get(channelToken);
|
|
2695
|
+
if (!channelId) {
|
|
2696
|
+
this.sendJsonRpcError(res, null, -32e3, "Invalid token", 401);
|
|
2697
|
+
return;
|
|
2698
|
+
}
|
|
2699
|
+
const channel = this.channels.get(channelId);
|
|
2700
|
+
if (!channel) {
|
|
2701
|
+
this.sendJsonRpcError(res, null, -32e3, "Channel not found", 404);
|
|
2702
|
+
return;
|
|
2703
|
+
}
|
|
2704
|
+
let body;
|
|
2705
|
+
try {
|
|
2706
|
+
body = await this.parseBody(req);
|
|
2707
|
+
} catch {
|
|
2708
|
+
this.sendJsonRpcError(res, null, -32700, "Parse error", 400);
|
|
2709
|
+
return;
|
|
2710
|
+
}
|
|
2711
|
+
if (!body.method) {
|
|
2712
|
+
this.sendJsonRpcError(
|
|
2713
|
+
res,
|
|
2714
|
+
body.id ?? null,
|
|
2715
|
+
-32600,
|
|
2716
|
+
"Invalid request",
|
|
2717
|
+
400
|
|
2718
|
+
);
|
|
2719
|
+
return;
|
|
2720
|
+
}
|
|
2721
|
+
this.logger.info("WebMCP MCP request", {
|
|
2722
|
+
channelId,
|
|
2723
|
+
method: body.method,
|
|
2724
|
+
toolName: body.method === "tools/call" ? body.params?.name : void 0,
|
|
2725
|
+
toolCount: body.method === "tools/list" ? channel.tools.size : void 0
|
|
2726
|
+
});
|
|
2727
|
+
if (body.id === void 0 || body.id === null) {
|
|
2728
|
+
await this.dispatch(channel, body).catch(() => void 0);
|
|
2729
|
+
res.writeHead(202);
|
|
2730
|
+
res.end();
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
const blocking = body.method === "tools/call" || body.method === "resources/read" || body.method === "prompts/get";
|
|
2734
|
+
if (blocking) {
|
|
2735
|
+
res.writeHead(200, {
|
|
2736
|
+
"Content-Type": "application/json",
|
|
2737
|
+
"Cache-Control": "no-cache"
|
|
2738
|
+
});
|
|
2739
|
+
res.flushHeaders();
|
|
2740
|
+
const keepAlive = setInterval(() => {
|
|
2741
|
+
if (!res.destroyed) res.write("\n");
|
|
2742
|
+
}, HTTP_KEEPALIVE_INTERVAL_MS);
|
|
2743
|
+
try {
|
|
2744
|
+
const result = await this.dispatch(channel, body);
|
|
2745
|
+
clearInterval(keepAlive);
|
|
2746
|
+
this.logger.info("WebMCP MCP response", {
|
|
2747
|
+
channelId,
|
|
2748
|
+
method: body.method,
|
|
2749
|
+
ok: true
|
|
2750
|
+
});
|
|
2751
|
+
const payload = {
|
|
2752
|
+
jsonrpc: "2.0",
|
|
2753
|
+
id: body.id ?? null,
|
|
2754
|
+
result
|
|
2755
|
+
};
|
|
2756
|
+
res.end(JSON.stringify(payload));
|
|
2757
|
+
} catch (err) {
|
|
2758
|
+
clearInterval(keepAlive);
|
|
2759
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2760
|
+
const unsupported = message.startsWith("Unsupported method");
|
|
2761
|
+
const code = unsupported ? -32601 : message.includes("not connected") ? -32001 : -32603;
|
|
2762
|
+
this.logger[unsupported ? "info" : "warn"]("WebMCP MCP error", {
|
|
2763
|
+
channelId,
|
|
2764
|
+
method: body.method,
|
|
2765
|
+
message
|
|
2766
|
+
});
|
|
2767
|
+
const payload = {
|
|
2768
|
+
jsonrpc: "2.0",
|
|
2769
|
+
id: body.id ?? null,
|
|
2770
|
+
error: { code, message }
|
|
2771
|
+
};
|
|
2772
|
+
res.end(JSON.stringify(payload));
|
|
2773
|
+
}
|
|
2774
|
+
return;
|
|
2775
|
+
}
|
|
2776
|
+
try {
|
|
2777
|
+
const result = await this.dispatch(channel, body);
|
|
2778
|
+
this.sendJsonRpcResult(res, body.id ?? null, result);
|
|
2779
|
+
} catch (err) {
|
|
2780
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2781
|
+
const unsupported = message.startsWith("Unsupported method");
|
|
2782
|
+
const code = unsupported ? -32601 : message.includes("not connected") ? -32001 : -32603;
|
|
2783
|
+
this.logger[unsupported ? "info" : "warn"]("WebMCP MCP error", {
|
|
2784
|
+
channelId,
|
|
2785
|
+
method: body.method,
|
|
2786
|
+
message
|
|
2787
|
+
});
|
|
2788
|
+
this.sendJsonRpcError(res, body.id ?? null, code, message, 500);
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
// ── Browser registration WS ─────────────────────────────────────────
|
|
2792
|
+
handleRegistrationConnection(ws) {
|
|
2793
|
+
ws.on("message", (data) => {
|
|
2794
|
+
let msg;
|
|
2795
|
+
try {
|
|
2796
|
+
msg = JSON.parse(data.toString());
|
|
2797
|
+
} catch {
|
|
2798
|
+
ws.send(
|
|
2799
|
+
JSON.stringify({ type: "error", message: "Invalid JSON" })
|
|
2800
|
+
);
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
if (msg.type !== "register" || typeof msg.token !== "string") {
|
|
2804
|
+
ws.send(
|
|
2805
|
+
JSON.stringify({
|
|
2806
|
+
type: "error",
|
|
2807
|
+
message: "Expected register message"
|
|
2808
|
+
})
|
|
2809
|
+
);
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
let regToken = msg.token;
|
|
2813
|
+
try {
|
|
2814
|
+
const decoded = JSON.parse(
|
|
2815
|
+
Buffer.from(regToken, "base64").toString("utf-8")
|
|
2816
|
+
);
|
|
2817
|
+
if (typeof decoded.token === "string") regToken = decoded.token;
|
|
2818
|
+
} catch {
|
|
2819
|
+
}
|
|
2820
|
+
const entry = this.registrationTokens.get(regToken);
|
|
2821
|
+
if (!entry) {
|
|
2822
|
+
ws.send(
|
|
2823
|
+
JSON.stringify({
|
|
2824
|
+
type: "error",
|
|
2825
|
+
message: "Invalid registration token"
|
|
2826
|
+
})
|
|
2827
|
+
);
|
|
2828
|
+
return;
|
|
2829
|
+
}
|
|
2830
|
+
const channel = this.channels.get(entry.channelId);
|
|
2831
|
+
if (!channel) {
|
|
2832
|
+
ws.send(
|
|
2833
|
+
JSON.stringify({ type: "error", message: "Channel not found" })
|
|
2834
|
+
);
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
this.registrationTokens.delete(regToken);
|
|
2838
|
+
ws.send(
|
|
2839
|
+
JSON.stringify({
|
|
2840
|
+
type: "registerSuccess",
|
|
2841
|
+
channel: `/webmcp/channel/${channel.id}`,
|
|
2842
|
+
token: channel.token
|
|
2843
|
+
})
|
|
2844
|
+
);
|
|
2845
|
+
this.logger.info("WebMCP registration success", {
|
|
2846
|
+
channelId: channel.id
|
|
2847
|
+
});
|
|
2848
|
+
});
|
|
2849
|
+
}
|
|
2850
|
+
// ── Browser channel WS ──────────────────────────────────────────────
|
|
2851
|
+
handleChannelConnection(channel, ws) {
|
|
2852
|
+
if (channel.pingTimer) {
|
|
2853
|
+
clearInterval(channel.pingTimer);
|
|
2854
|
+
channel.pingTimer = null;
|
|
2855
|
+
}
|
|
2856
|
+
channel.browserWs = ws;
|
|
2857
|
+
this.logger.info("WebMCP browser connected", { channelId: channel.id });
|
|
2858
|
+
channel.pingTimer = setInterval(() => {
|
|
2859
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
2860
|
+
try {
|
|
2861
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
2862
|
+
} catch {
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
}, PING_INTERVAL_MS);
|
|
2866
|
+
ws.on("message", (data) => {
|
|
2867
|
+
let msg;
|
|
2868
|
+
try {
|
|
2869
|
+
msg = JSON.parse(data.toString());
|
|
2870
|
+
} catch {
|
|
2871
|
+
return;
|
|
2872
|
+
}
|
|
2873
|
+
this.handleChannelMessage(channel, msg);
|
|
2874
|
+
});
|
|
2875
|
+
ws.on("close", () => {
|
|
2876
|
+
if (channel.pingTimer) {
|
|
2877
|
+
clearInterval(channel.pingTimer);
|
|
2878
|
+
channel.pingTimer = null;
|
|
2879
|
+
}
|
|
2880
|
+
channel.browserWs = null;
|
|
2881
|
+
this.logger.info("WebMCP browser disconnected", {
|
|
2882
|
+
channelId: channel.id
|
|
2883
|
+
});
|
|
2884
|
+
});
|
|
2885
|
+
ws.on("error", (err) => {
|
|
2886
|
+
this.logger.error("WebMCP channel WS error", {
|
|
2887
|
+
channelId: channel.id,
|
|
2888
|
+
err: err.message
|
|
2889
|
+
});
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
handleChannelMessage(channel, msg) {
|
|
2893
|
+
const type = msg.type;
|
|
2894
|
+
switch (type) {
|
|
2895
|
+
case "registerTool": {
|
|
2896
|
+
const tool = this.parseTool(msg);
|
|
2897
|
+
if (tool) {
|
|
2898
|
+
channel.tools.set(tool.name, tool);
|
|
2899
|
+
this.logger.info("WebMCP tool registered", {
|
|
2900
|
+
channelId: channel.id,
|
|
2901
|
+
name: tool.name,
|
|
2902
|
+
toolCount: channel.tools.size
|
|
2903
|
+
});
|
|
2904
|
+
this.sendAck(channel, "registerTool", tool.name);
|
|
2905
|
+
}
|
|
2906
|
+
return;
|
|
2907
|
+
}
|
|
2908
|
+
case "registerResource": {
|
|
2909
|
+
const resource = this.parseResource(msg);
|
|
2910
|
+
if (resource) {
|
|
2911
|
+
channel.resources.set(resource.uri, resource);
|
|
2912
|
+
this.sendAck(channel, "registerResource", resource.uri);
|
|
2913
|
+
}
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
case "registerPrompt": {
|
|
2917
|
+
const prompt = this.parsePrompt(msg);
|
|
2918
|
+
if (prompt) {
|
|
2919
|
+
channel.prompts.set(prompt.name, prompt);
|
|
2920
|
+
this.sendAck(channel, "registerPrompt", prompt.name);
|
|
2921
|
+
}
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
case "deregisterTool": {
|
|
2925
|
+
const name = msg.name;
|
|
2926
|
+
if (name) channel.tools.delete(name);
|
|
2927
|
+
return;
|
|
2928
|
+
}
|
|
2929
|
+
case "toolResponse":
|
|
2930
|
+
case "resourceResponse":
|
|
2931
|
+
case "promptResponse": {
|
|
2932
|
+
const id = msg.id;
|
|
2933
|
+
if (id) {
|
|
2934
|
+
this.resolveRequest(
|
|
2935
|
+
channel,
|
|
2936
|
+
id,
|
|
2937
|
+
msg.result,
|
|
2938
|
+
msg.error
|
|
2939
|
+
);
|
|
2940
|
+
}
|
|
2941
|
+
return;
|
|
2942
|
+
}
|
|
2943
|
+
case "ping":
|
|
2944
|
+
if (channel.browserWs?.readyState === WebSocket.OPEN) {
|
|
2945
|
+
try {
|
|
2946
|
+
channel.browserWs.send(JSON.stringify({ type: "pong" }));
|
|
2947
|
+
} catch {
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
return;
|
|
2951
|
+
case "pong":
|
|
2952
|
+
return;
|
|
2953
|
+
default:
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
parseTool(msg) {
|
|
2958
|
+
const nested = msg.tool;
|
|
2959
|
+
const src = nested && typeof nested.name === "string" ? nested : msg;
|
|
2960
|
+
if (typeof src.name !== "string") {
|
|
2961
|
+
this.logger.warn("WebMCP registerTool missing name", {});
|
|
2962
|
+
return null;
|
|
2963
|
+
}
|
|
2964
|
+
return {
|
|
2965
|
+
name: src.name,
|
|
2966
|
+
description: src.description,
|
|
2967
|
+
inputSchema: src.inputSchema ?? {
|
|
2968
|
+
type: "object",
|
|
2969
|
+
properties: {}
|
|
2970
|
+
},
|
|
2971
|
+
annotations: src.annotations
|
|
2972
|
+
};
|
|
2973
|
+
}
|
|
2974
|
+
parseResource(msg) {
|
|
2975
|
+
const nested = msg.resource;
|
|
2976
|
+
const src = nested && typeof nested.uri === "string" ? nested : msg;
|
|
2977
|
+
if (typeof src.uri !== "string") {
|
|
2978
|
+
this.logger.warn("WebMCP registerResource missing uri", {});
|
|
2979
|
+
return null;
|
|
2980
|
+
}
|
|
2981
|
+
return {
|
|
2982
|
+
uri: src.uri,
|
|
2983
|
+
name: src.name ?? src.uri,
|
|
2984
|
+
description: src.description,
|
|
2985
|
+
mimeType: src.mimeType
|
|
2986
|
+
};
|
|
2987
|
+
}
|
|
2988
|
+
parsePrompt(msg) {
|
|
2989
|
+
const nested = msg.prompt;
|
|
2990
|
+
const src = nested && typeof nested.name === "string" ? nested : msg;
|
|
2991
|
+
if (typeof src.name !== "string") {
|
|
2992
|
+
this.logger.warn("WebMCP registerPrompt missing name", {});
|
|
2993
|
+
return null;
|
|
2994
|
+
}
|
|
2995
|
+
return {
|
|
2996
|
+
name: src.name,
|
|
2997
|
+
description: src.description,
|
|
2998
|
+
arguments: src.arguments
|
|
2999
|
+
};
|
|
3000
|
+
}
|
|
3001
|
+
sendAck(channel, registration, name) {
|
|
3002
|
+
if (channel.browserWs?.readyState !== WebSocket.OPEN) return;
|
|
3003
|
+
try {
|
|
3004
|
+
channel.browserWs.send(
|
|
3005
|
+
JSON.stringify({ type: "ack", registration, name })
|
|
3006
|
+
);
|
|
3007
|
+
} catch {
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
resolveRequest(channel, id, result, error) {
|
|
3011
|
+
const pending = channel.pendingRequests.get(id);
|
|
3012
|
+
if (!pending) return;
|
|
3013
|
+
clearTimeout(pending.timeout);
|
|
3014
|
+
channel.pendingRequests.delete(id);
|
|
3015
|
+
if (error) {
|
|
3016
|
+
const message = typeof error === "string" ? error : error.message ?? "Browser returned error";
|
|
3017
|
+
pending.reject(new Error(message));
|
|
3018
|
+
} else {
|
|
3019
|
+
pending.resolve(result);
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
// ── MCP JSON-RPC dispatch ───────────────────────────────────────────
|
|
3023
|
+
async dispatch(channel, req) {
|
|
3024
|
+
switch (req.method) {
|
|
3025
|
+
case "initialize": {
|
|
3026
|
+
const requested = req.params?.protocolVersion;
|
|
3027
|
+
return {
|
|
3028
|
+
protocolVersion: typeof requested === "string" && SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : SUPPORTED_PROTOCOL_VERSIONS[SUPPORTED_PROTOCOL_VERSIONS.length - 1],
|
|
3029
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
|
3030
|
+
capabilities: {
|
|
3031
|
+
tools: { listChanged: false },
|
|
3032
|
+
resources: { subscribe: false, listChanged: false },
|
|
3033
|
+
prompts: { listChanged: false }
|
|
3034
|
+
}
|
|
3035
|
+
};
|
|
3036
|
+
}
|
|
3037
|
+
case "tools/list":
|
|
3038
|
+
return {
|
|
3039
|
+
tools: Array.from(channel.tools.values()).map((t) => ({
|
|
3040
|
+
name: t.name,
|
|
3041
|
+
description: t.description,
|
|
3042
|
+
inputSchema: t.inputSchema,
|
|
3043
|
+
annotations: t.annotations
|
|
3044
|
+
}))
|
|
3045
|
+
};
|
|
3046
|
+
case "tools/call": {
|
|
3047
|
+
const name = req.params?.name;
|
|
3048
|
+
const args = req.params?.arguments;
|
|
3049
|
+
if (!name) throw new Error("Missing tool name");
|
|
3050
|
+
if (!channel.tools.has(name)) {
|
|
3051
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
3052
|
+
}
|
|
3053
|
+
const raw = await this.forwardToBrowser(channel, "callTool", {
|
|
3054
|
+
tool: name,
|
|
3055
|
+
arguments: args
|
|
3056
|
+
});
|
|
3057
|
+
const text = typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
3058
|
+
return { content: [{ type: "text", text }] };
|
|
3059
|
+
}
|
|
3060
|
+
case "resources/list":
|
|
3061
|
+
return {
|
|
3062
|
+
resources: Array.from(channel.resources.values()).map((r) => ({
|
|
3063
|
+
uri: r.uri,
|
|
3064
|
+
name: r.name,
|
|
3065
|
+
description: r.description,
|
|
3066
|
+
mimeType: r.mimeType
|
|
3067
|
+
}))
|
|
3068
|
+
};
|
|
3069
|
+
case "resources/read": {
|
|
3070
|
+
const uri = req.params?.uri;
|
|
3071
|
+
if (!uri) throw new Error("Missing resource URI");
|
|
3072
|
+
if (!channel.resources.has(uri)) {
|
|
3073
|
+
throw new Error(`Unknown resource: ${uri}`);
|
|
3074
|
+
}
|
|
3075
|
+
const raw = await this.forwardToBrowser(channel, "readResource", {
|
|
3076
|
+
uri
|
|
3077
|
+
});
|
|
3078
|
+
const text = typeof raw === "string" ? raw : raw?.content ?? JSON.stringify(raw);
|
|
3079
|
+
const mimeType = typeof raw === "object" && raw && "mimeType" in raw ? raw.mimeType ?? "text/plain" : "text/plain";
|
|
3080
|
+
return { contents: [{ uri, text, mimeType }] };
|
|
3081
|
+
}
|
|
3082
|
+
case "prompts/list":
|
|
3083
|
+
return {
|
|
3084
|
+
prompts: Array.from(channel.prompts.values()).map((p) => ({
|
|
3085
|
+
name: p.name,
|
|
3086
|
+
description: p.description,
|
|
3087
|
+
arguments: p.arguments
|
|
3088
|
+
}))
|
|
3089
|
+
};
|
|
3090
|
+
case "prompts/get": {
|
|
3091
|
+
const name = req.params?.name;
|
|
3092
|
+
const args = req.params?.arguments;
|
|
3093
|
+
if (!name) throw new Error("Missing prompt name");
|
|
3094
|
+
if (!channel.prompts.has(name)) {
|
|
3095
|
+
throw new Error(`Unknown prompt: ${name}`);
|
|
3096
|
+
}
|
|
3097
|
+
return await this.forwardToBrowser(channel, "getPrompt", {
|
|
3098
|
+
name,
|
|
3099
|
+
arguments: args
|
|
3100
|
+
});
|
|
3101
|
+
}
|
|
3102
|
+
case "notifications/initialized":
|
|
3103
|
+
return void 0;
|
|
3104
|
+
default:
|
|
3105
|
+
throw new Error(`Unsupported method: ${req.method}`);
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
// ── Forwarding agent → browser ──────────────────────────────────────
|
|
3109
|
+
forwardToBrowser(channel, type, payload) {
|
|
3110
|
+
if (!channel.browserWs || channel.browserWs.readyState !== WebSocket.OPEN) {
|
|
3111
|
+
return Promise.reject(
|
|
3112
|
+
new Error("Browser WebSocket not connected to channel")
|
|
3113
|
+
);
|
|
3114
|
+
}
|
|
3115
|
+
const id = randomUUID2();
|
|
3116
|
+
return new Promise((resolve, reject) => {
|
|
3117
|
+
const timeout = setTimeout(() => {
|
|
3118
|
+
channel.pendingRequests.delete(id);
|
|
3119
|
+
reject(
|
|
3120
|
+
new Error(
|
|
3121
|
+
`WebMCP request timed out after ${REQUEST_TIMEOUT_MS}ms (type=${type})`
|
|
3122
|
+
)
|
|
3123
|
+
);
|
|
3124
|
+
}, REQUEST_TIMEOUT_MS);
|
|
3125
|
+
channel.pendingRequests.set(id, { resolve, reject, timeout });
|
|
3126
|
+
try {
|
|
3127
|
+
channel.browserWs.send(
|
|
3128
|
+
JSON.stringify({ type, id, ...payload })
|
|
3129
|
+
);
|
|
3130
|
+
} catch (err) {
|
|
3131
|
+
clearTimeout(timeout);
|
|
3132
|
+
channel.pendingRequests.delete(id);
|
|
3133
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
3134
|
+
}
|
|
3135
|
+
});
|
|
3136
|
+
}
|
|
3137
|
+
// ── HTTP helpers ────────────────────────────────────────────────────
|
|
3138
|
+
parseBody(req) {
|
|
3139
|
+
return new Promise((resolve, reject) => {
|
|
3140
|
+
const chunks = [];
|
|
3141
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
3142
|
+
req.on("end", () => {
|
|
3143
|
+
try {
|
|
3144
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
3145
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
3146
|
+
} catch {
|
|
3147
|
+
reject(new Error("Invalid JSON body"));
|
|
3148
|
+
}
|
|
3149
|
+
});
|
|
3150
|
+
req.on("error", reject);
|
|
3151
|
+
});
|
|
3152
|
+
}
|
|
3153
|
+
sendJsonRpcResult(res, id, result) {
|
|
3154
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3155
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", id, result }));
|
|
3156
|
+
}
|
|
3157
|
+
sendJsonRpcError(res, id, code, message, httpStatus = 500) {
|
|
3158
|
+
res.writeHead(httpStatus, { "Content-Type": "application/json" });
|
|
3159
|
+
res.end(
|
|
3160
|
+
JSON.stringify({
|
|
3161
|
+
jsonrpc: "2.0",
|
|
3162
|
+
id,
|
|
3163
|
+
error: { code, message }
|
|
3164
|
+
})
|
|
3165
|
+
);
|
|
3166
|
+
}
|
|
3167
|
+
};
|
|
3168
|
+
|
|
3169
|
+
// src/server.ts
|
|
3170
|
+
var ADMIN_ORIGINS = /^(tauri:\/\/localhost|https?:\/\/tauri\.localhost|http:\/\/(localhost|127\.0\.0\.1):\d+)$/;
|
|
3171
|
+
async function startEngine(options) {
|
|
3172
|
+
const port = options.port ?? DEFAULT_PORT;
|
|
3173
|
+
const { logger, grants } = options;
|
|
3174
|
+
const embeddings = options.embeddings ?? localEmbeddings();
|
|
3175
|
+
const selfOrigins = /* @__PURE__ */ new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
|
|
3176
|
+
const allowedHosts = /* @__PURE__ */ new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
|
|
3177
|
+
const silent = options.silent ?? Boolean(options.admin);
|
|
3178
|
+
const webMcpServer = new WebMcpServer(logger, port);
|
|
3179
|
+
const sessions = new SessionManager({
|
|
3180
|
+
webMcpServer,
|
|
3181
|
+
port,
|
|
3182
|
+
logger,
|
|
3183
|
+
resolveAgent: options.resolveAgent,
|
|
3184
|
+
startTimeoutMs: options.startTimeoutMs,
|
|
3185
|
+
stdioMcpCommand: options.stdioMcpCommand,
|
|
3186
|
+
onAgentState: options.onAgentState
|
|
3187
|
+
});
|
|
3188
|
+
grants.onRemoved((origin) => sessions.closeOrigin(origin));
|
|
3189
|
+
grants.onChanged((origin) => sessions.closeOrigin(origin));
|
|
3190
|
+
const pairing = new Pairing({
|
|
3191
|
+
grants,
|
|
3192
|
+
logger,
|
|
3193
|
+
selfOrigins,
|
|
3194
|
+
port,
|
|
3195
|
+
linksOnly: silent,
|
|
3196
|
+
agentName: () => agentName2(),
|
|
3197
|
+
onRequest: options.onPairingRequest,
|
|
3198
|
+
onDecided: options.onPairingDecided
|
|
3199
|
+
});
|
|
3200
|
+
const agentName2 = typeof options.agentName === "function" ? options.agentName : () => options.agentName;
|
|
3201
|
+
const handleAdmin = async (req, res) => {
|
|
3202
|
+
const admin = options.admin;
|
|
3203
|
+
const origin = req.headers.origin;
|
|
3204
|
+
if (origin) {
|
|
3205
|
+
if (!ADMIN_ORIGINS.test(origin)) {
|
|
3206
|
+
res.statusCode = 403;
|
|
3207
|
+
res.end();
|
|
3208
|
+
return;
|
|
3209
|
+
}
|
|
3210
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
3211
|
+
res.setHeader("Vary", "Origin");
|
|
3212
|
+
}
|
|
3213
|
+
if (req.method === "OPTIONS") {
|
|
3214
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
3215
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
3216
|
+
res.statusCode = 204;
|
|
3217
|
+
res.end();
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
3220
|
+
const auth = req.headers.authorization ?? "";
|
|
3221
|
+
const given = Buffer.from(auth.startsWith("Bearer ") ? auth.slice(7) : "");
|
|
3222
|
+
const expected = Buffer.from(admin.token);
|
|
3223
|
+
if (given.length !== expected.length || !timingSafeEqual3(given, expected)) {
|
|
3224
|
+
sendJson(res, 401, { error: "Unauthorized" });
|
|
3225
|
+
return;
|
|
3226
|
+
}
|
|
3227
|
+
const { pathname } = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
3228
|
+
try {
|
|
3229
|
+
if (!await admin.handle(req, res, pathname, pairing, sessions)) sendJson(res, 404, { error: "Not found" });
|
|
3230
|
+
} catch (err) {
|
|
3231
|
+
logger.error("admin request failed", { url: req.url, err: err instanceof Error ? err.message : String(err) });
|
|
3232
|
+
if (!res.headersSent) sendJson(res, 500, { error: err instanceof Error ? err.message : "Internal error" });
|
|
3233
|
+
}
|
|
3234
|
+
};
|
|
3235
|
+
const identify = (req) => {
|
|
3236
|
+
const origin = req.headers.origin;
|
|
3237
|
+
if (!origin) return { kind: "local" };
|
|
3238
|
+
if (selfOrigins.has(origin)) return { kind: "self" };
|
|
3239
|
+
let normalized;
|
|
3240
|
+
try {
|
|
3241
|
+
normalized = normalizeOrigin(origin);
|
|
3242
|
+
} catch {
|
|
3243
|
+
return null;
|
|
3244
|
+
}
|
|
3245
|
+
const auth = req.headers.authorization;
|
|
3246
|
+
const token = auth?.startsWith("Bearer ") ? auth.slice(7) : void 0;
|
|
3247
|
+
return { kind: "site", origin: normalized, authorized: grants.verify(normalized, token) };
|
|
3248
|
+
};
|
|
3249
|
+
const server = http.createServer(async (req, res) => {
|
|
3250
|
+
if (!allowedHosts.has(req.headers.host ?? "")) {
|
|
3251
|
+
sendJson(res, 403, { error: "host not allowed" });
|
|
3252
|
+
return;
|
|
3253
|
+
}
|
|
3254
|
+
if (options.admin && (req.url ?? "").startsWith("/admin/")) {
|
|
3255
|
+
await handleAdmin(req, res);
|
|
3256
|
+
return;
|
|
3257
|
+
}
|
|
3258
|
+
const caller = identify(req);
|
|
3259
|
+
if (!caller) {
|
|
3260
|
+
sendJson(res, 403, { error: "origin not allowed" });
|
|
3261
|
+
return;
|
|
3262
|
+
}
|
|
3263
|
+
const { pathname } = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
3264
|
+
if (caller.kind === "site") {
|
|
3265
|
+
if (silent && !grants.has(caller.origin) && !pairing.hasRequest(caller.origin)) {
|
|
3266
|
+
res.statusCode = 403;
|
|
3267
|
+
res.end();
|
|
3268
|
+
return;
|
|
3269
|
+
}
|
|
3270
|
+
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
|
|
3271
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
3272
|
+
res.setHeader("Vary", "Origin");
|
|
3273
|
+
}
|
|
3274
|
+
if (req.method === "OPTIONS") {
|
|
3275
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
3276
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
3277
|
+
res.setHeader("Access-Control-Max-Age", "600");
|
|
3278
|
+
res.statusCode = 204;
|
|
3279
|
+
res.end();
|
|
3280
|
+
return;
|
|
3281
|
+
}
|
|
3282
|
+
try {
|
|
3283
|
+
if (pathname === "/health" && req.method === "GET") {
|
|
3284
|
+
sendJson(res, 200, {
|
|
3285
|
+
leuria: VERSION,
|
|
3286
|
+
ok: true,
|
|
3287
|
+
// A connected site hears about its own AI and model.
|
|
3288
|
+
agent: caller.kind === "site" && caller.authorized && options.siteAgentName ? options.siteAgentName(caller.origin) : agentName2(),
|
|
3289
|
+
// "app": the desktop app asks the visitor natively; "page": the engine's approval page.
|
|
3290
|
+
approvals: options.admin ? "app" : "page",
|
|
3291
|
+
...caller.kind === "site" ? { paired: caller.authorized } : {},
|
|
3292
|
+
// A connected site hears which embedding model it would get.
|
|
3293
|
+
...caller.kind !== "site" || caller.authorized ? { embed: (await embeddings.find())?.model } : {}
|
|
3294
|
+
});
|
|
3295
|
+
return;
|
|
3296
|
+
}
|
|
3297
|
+
if (await pairing.handle(req, res, pathname)) return;
|
|
3298
|
+
if (caller.kind === "self") {
|
|
3299
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3300
|
+
return;
|
|
3301
|
+
}
|
|
3302
|
+
if (caller.kind === "site" && !caller.authorized) {
|
|
3303
|
+
sendJson(res, 401, { error: "This site is not connected to Leuria", code: "not_paired" });
|
|
3304
|
+
return;
|
|
3305
|
+
}
|
|
3306
|
+
if (webMcpServer.matches(pathname)) {
|
|
3307
|
+
if (caller.kind === "site") {
|
|
3308
|
+
sendJson(res, 403, { error: "Forbidden" });
|
|
3309
|
+
return;
|
|
3310
|
+
}
|
|
3311
|
+
await webMcpServer.handleHttp(req, res);
|
|
3312
|
+
return;
|
|
3313
|
+
}
|
|
3314
|
+
if (pathname === "/embed" && req.method === "POST") {
|
|
3315
|
+
let request;
|
|
3316
|
+
try {
|
|
3317
|
+
request = parseEmbedBody(await parseBody(req));
|
|
3318
|
+
} catch (error) {
|
|
3319
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
3320
|
+
return;
|
|
3321
|
+
}
|
|
3322
|
+
try {
|
|
3323
|
+
sendJson(res, 200, await embeddings.embed(request.texts, request.kind));
|
|
3324
|
+
} catch (error) {
|
|
3325
|
+
const none = error instanceof NoEmbedderError;
|
|
3326
|
+
sendJson(res, none ? 503 : 502, { error: error instanceof Error ? error.message : String(error), ...none ? { code: "no_embedder" } : {} });
|
|
3327
|
+
}
|
|
3328
|
+
return;
|
|
3329
|
+
}
|
|
3330
|
+
const requester = caller.kind === "site" ? caller.origin : "local";
|
|
3331
|
+
if (await handleSessionRequest(req, res, pathname, sessions, requester)) return;
|
|
3332
|
+
sendJson(res, 404, { error: "Not found" });
|
|
3333
|
+
} catch (err) {
|
|
3334
|
+
logger.error("request failed", {
|
|
3335
|
+
url: req.url,
|
|
3336
|
+
err: err instanceof Error ? err.message : String(err)
|
|
3337
|
+
});
|
|
3338
|
+
if (!res.headersSent) sendJson(res, 500, { error: "Internal error" });
|
|
3339
|
+
}
|
|
3340
|
+
});
|
|
3341
|
+
server.on("upgrade", (req, socket, head) => {
|
|
3342
|
+
const { pathname } = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
3343
|
+
const caller = allowedHosts.has(req.headers.host ?? "") ? identify(req) : null;
|
|
3344
|
+
const allowed = caller !== null && (caller.kind === "local" || caller.kind === "site" && grants.has(caller.origin));
|
|
3345
|
+
if (!allowed || !webMcpServer.matches(pathname)) {
|
|
3346
|
+
logger.warn("upgrade rejected", { origin: req.headers.origin, url: pathname });
|
|
3347
|
+
socket.write(`HTTP/1.1 ${allowed ? "404 Not Found" : "403 Forbidden"}\r
|
|
3348
|
+
\r
|
|
3349
|
+
`);
|
|
3350
|
+
socket.destroy();
|
|
3351
|
+
return;
|
|
3352
|
+
}
|
|
3353
|
+
webMcpServer.handleUpgrade(req, socket, head);
|
|
3354
|
+
});
|
|
3355
|
+
await new Promise((resolve, reject) => {
|
|
3356
|
+
server.once("error", reject);
|
|
3357
|
+
server.listen(port, "127.0.0.1", () => resolve());
|
|
3358
|
+
});
|
|
3359
|
+
const boundPort = server.address().port;
|
|
3360
|
+
return {
|
|
3361
|
+
port: boundPort,
|
|
3362
|
+
pairing,
|
|
3363
|
+
sessions,
|
|
3364
|
+
revoke: (origin) => grants.revoke(origin),
|
|
3365
|
+
close: async () => {
|
|
3366
|
+
sessions.shutdown();
|
|
3367
|
+
webMcpServer.close();
|
|
3368
|
+
server.closeAllConnections();
|
|
3369
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
3370
|
+
}
|
|
3371
|
+
};
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
// src/logger.ts
|
|
3375
|
+
function createLogger(verbose = false) {
|
|
3376
|
+
const write = (level) => (msg, meta) => {
|
|
3377
|
+
if (level === "info" && !verbose) return;
|
|
3378
|
+
const suffix = meta ? ` ${JSON.stringify(meta)}` : "";
|
|
3379
|
+
process.stderr.write(
|
|
3380
|
+
`${(/* @__PURE__ */ new Date()).toISOString()} ${level.padEnd(5)} ${msg}${suffix}
|
|
3381
|
+
`
|
|
3382
|
+
);
|
|
3383
|
+
};
|
|
3384
|
+
return { info: write("info"), warn: write("warn"), error: write("error") };
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
export {
|
|
3388
|
+
fetchRegistry,
|
|
3389
|
+
getRegistryEntry,
|
|
3390
|
+
DEFAULT_PORT,
|
|
3391
|
+
leuriaHome,
|
|
3392
|
+
homePath,
|
|
3393
|
+
loadConfig,
|
|
3394
|
+
isKnownReady,
|
|
3395
|
+
saveConfig,
|
|
3396
|
+
isLlmId,
|
|
3397
|
+
llmId,
|
|
3398
|
+
parseLlmId,
|
|
3399
|
+
getProvider,
|
|
3400
|
+
saveProvider,
|
|
3401
|
+
removeProvider,
|
|
3402
|
+
normalizeBaseUrl,
|
|
3403
|
+
listModels,
|
|
3404
|
+
detectLlms,
|
|
3405
|
+
WEBMCP_SERVER_NAME,
|
|
3406
|
+
buildSessionMeta,
|
|
3407
|
+
decidePermission,
|
|
3408
|
+
installedAgent,
|
|
3409
|
+
ensureAgent,
|
|
3410
|
+
resolveAgentCommand,
|
|
3411
|
+
resolveSiteAgent,
|
|
3412
|
+
agentName,
|
|
3413
|
+
resetAgent,
|
|
3414
|
+
isAvailable,
|
|
3415
|
+
listAgents,
|
|
3416
|
+
VERSION,
|
|
3417
|
+
agentModels,
|
|
3418
|
+
seenAgentModels,
|
|
3419
|
+
aiLabel,
|
|
3420
|
+
rememberAgentState,
|
|
3421
|
+
checkSignIn,
|
|
3422
|
+
signIn,
|
|
3423
|
+
parseNeeds,
|
|
3424
|
+
detectInstalledClis,
|
|
3425
|
+
listLocalEmbedders,
|
|
3426
|
+
localEmbeddings,
|
|
3427
|
+
sendJson,
|
|
3428
|
+
parseBody,
|
|
3429
|
+
GrantStore,
|
|
3430
|
+
normalizeOrigin,
|
|
3431
|
+
startEngine,
|
|
3432
|
+
createLogger
|
|
3433
|
+
};
|