@epoch-agent/server 0.18.0 → 0.20.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/README.md +80 -0
- package/dist/index.d.ts +117 -2
- package/dist/index.js +1593 -1433
- package/dist/web/assets/index-RBjSTw6I.js +378 -0
- package/dist/web/index.html +1 -1
- package/package.json +3 -3
- package/dist/web/assets/index-ClZP4tmB.js +0 -378
package/dist/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { createServer } from 'http';
|
|
2
2
|
import { createLogger, t, ApprovalRelay, QuestionRelay, serializeStream, withRoleScope, withModelScope, withToolScope, nextRunAt, observeBackgroundTasks, skillIndexDescription, SANDBOX_EXCLUDES, SANDBOX_COVERS, canProbeGit, guardSpawn, isSystemOpenable, backgroundTaskOutput, listBackgroundTasks, providerLabel, MAX_REASON_CHARS, MAX_OBJECTIVE_CHARS, MAX_GOAL_MAX_ROUNDS, DEFAULT_GOAL_MAX_ROUNDS, SCHEDULE_DEFAULT_ALLOWLIST, SCHEDULE_FORM_BUDGET_USD, SCHEDULE_DEFAULTS, coalesceRecording, readRecording } from '@epoch-agent/runtime';
|
|
3
|
-
import { timingSafeEqual, randomBytes } from 'crypto';
|
|
3
|
+
import { timingSafeEqual, randomBytes, randomUUID } from 'crypto';
|
|
4
4
|
import { PLAN_OUTCOMES, WIRE_AUTH_COOKIE, WIRE_AUTH_COOKIE_ATTRS, WIRE_AUTH_TOKEN_PARAM, extractAllMentions, MAX_ATTACH_FILES, utf8ByteLength, MAX_SESSION_REFS, attachSessionSurface, isWireRewindScope, WIRE_REWIND_SCOPES, mcpConnectorState, MAX_ATTACH_TOTAL_BYTES, MAX_ATTACH_BYTES, truncateToBudget, WIRE_LANG_PARAM, isLang, conformsToWire, WIRE_LAST_SESSION_FIELDS, isQuestionAnswerMap, isProviderType, apiKeyEnvVar, WIRE_FILE_STAT_MAX, WIRE_SETTING_WRITE_LAYERS, isWirePlanAction, WIRE_PLAN_ACTIONS, isPermissionLevel, PERMISSION_LEVELS, WIRE_TRUST_REQUEST_FIELDS, WIRE_FORGET_WORKSPACE_FIELDS, collectPendingApprovals, unfixedRules, isWireSettingWriteLayer, parseModelRef, WIRE_GOAL_ACTIONS, OPERATION_TYPES, isOperationType, WIRE_PLUGIN_UPLOAD_FORM_FIELDS } from '@epoch-agent/protocol';
|
|
5
5
|
import { resolve, join, relative, isAbsolute, sep, dirname, basename, extname } from 'path';
|
|
6
6
|
import { existsSync, realpathSync, statSync, mkdirSync, createReadStream, readdirSync, createWriteStream, accessSync, constants } from 'fs';
|
|
7
7
|
import { stat, readFile, rm } from 'fs/promises';
|
|
8
8
|
import { pipeline } from 'stream/promises';
|
|
9
|
-
import { homedir } from 'os';
|
|
10
9
|
import { execFile, spawn } from 'child_process';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
11
|
import { fileURLToPath } from 'url';
|
|
12
12
|
|
|
13
13
|
// src/index.ts
|
|
@@ -137,6 +137,70 @@ function firstScreenUrl(host, port, token, ui = {}) {
|
|
|
137
137
|
if (ui.lang) params.push(`${UI_LANG_PARAM}=${encodeURIComponent(ui.lang)}`);
|
|
138
138
|
return `http://${authority}:${port}/?${params.join("&")}`;
|
|
139
139
|
}
|
|
140
|
+
var HOST_COMPOSE_TTL_MS = 10 * 6e4;
|
|
141
|
+
var HostComposeStore = class {
|
|
142
|
+
items = /* @__PURE__ */ new Map();
|
|
143
|
+
now;
|
|
144
|
+
ttlMs;
|
|
145
|
+
newId;
|
|
146
|
+
constructor(opts = {}) {
|
|
147
|
+
this.now = opts.now ?? Date.now;
|
|
148
|
+
this.ttlMs = opts.ttlMs ?? HOST_COMPOSE_TTL_MS;
|
|
149
|
+
this.newId = opts.newId ?? (() => `hc_${randomUUID()}`);
|
|
150
|
+
}
|
|
151
|
+
record(input) {
|
|
152
|
+
const now = this.now();
|
|
153
|
+
this.sweep(now);
|
|
154
|
+
for (const [id, item2] of this.items) {
|
|
155
|
+
if (item2.sessionId === input.sessionId && item2.takenAt === void 0) this.items.delete(id);
|
|
156
|
+
}
|
|
157
|
+
const item = {
|
|
158
|
+
id: this.newId(),
|
|
159
|
+
sessionId: input.sessionId,
|
|
160
|
+
text: input.text,
|
|
161
|
+
autoSend: input.autoSend,
|
|
162
|
+
createdAt: now
|
|
163
|
+
};
|
|
164
|
+
this.items.set(item.id, item);
|
|
165
|
+
return item;
|
|
166
|
+
}
|
|
167
|
+
pending() {
|
|
168
|
+
this.sweep(this.now());
|
|
169
|
+
let newest = null;
|
|
170
|
+
for (const item of this.items.values()) {
|
|
171
|
+
if (item.takenAt !== void 0) continue;
|
|
172
|
+
if (newest === null || item.createdAt >= newest.createdAt) newest = item;
|
|
173
|
+
}
|
|
174
|
+
return newest;
|
|
175
|
+
}
|
|
176
|
+
take(id) {
|
|
177
|
+
const now = this.now();
|
|
178
|
+
this.sweep(now, id);
|
|
179
|
+
const item = this.items.get(id);
|
|
180
|
+
if (item === void 0) return "unknown";
|
|
181
|
+
if (now - item.createdAt >= this.ttlMs) {
|
|
182
|
+
this.items.delete(id);
|
|
183
|
+
return "expired";
|
|
184
|
+
}
|
|
185
|
+
if (item.takenAt !== void 0) return "taken";
|
|
186
|
+
item.takenAt = now;
|
|
187
|
+
return "ok";
|
|
188
|
+
}
|
|
189
|
+
sweep(now, keep) {
|
|
190
|
+
for (const [id, item] of this.items) {
|
|
191
|
+
if (id === keep) continue;
|
|
192
|
+
if (now - item.createdAt >= this.ttlMs) this.items.delete(id);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
function toWireHostCompose(item) {
|
|
197
|
+
return {
|
|
198
|
+
id: item.id,
|
|
199
|
+
sessionId: item.sessionId,
|
|
200
|
+
text: item.text,
|
|
201
|
+
autoSend: item.autoSend
|
|
202
|
+
};
|
|
203
|
+
}
|
|
140
204
|
// src/ring.ts
|
|
141
205
|
var DEFAULT_RING_CAPACITY = 512;
|
|
142
206
|
var EnvelopeRing = class {
|
|
@@ -601,6 +665,10 @@ var SessionHub = class {
|
|
|
601
665
|
if (this.closed) return;
|
|
602
666
|
this.publish(void 0, { type: "plugins-changed" });
|
|
603
667
|
}
|
|
668
|
+
notifyHostCompose(sessionId, payload) {
|
|
669
|
+
if (this.closed) return;
|
|
670
|
+
this.publish(sessionId, { type: "host-compose", ...payload });
|
|
671
|
+
}
|
|
604
672
|
shutdown() {
|
|
605
673
|
if (this.closed) return;
|
|
606
674
|
this.closed = true;
|
|
@@ -1663,12 +1731,19 @@ function lastSessionField(ctx) {
|
|
|
1663
1731
|
if (!ctx.hub.has(recorded) && !ctx.runtime.sessions?.get(recorded)) return {};
|
|
1664
1732
|
return { lastSessionId: recorded };
|
|
1665
1733
|
}
|
|
1734
|
+
function hostComposeField(ctx) {
|
|
1735
|
+
const item = ctx.hostCompose.pending();
|
|
1736
|
+
if (!item) return {};
|
|
1737
|
+
if (!ctx.hub.has(item.sessionId) && !ctx.runtime.sessions?.get(item.sessionId)) return {};
|
|
1738
|
+
return { hostCompose: toWireHostCompose(item) };
|
|
1739
|
+
}
|
|
1666
1740
|
function config(ctx, res, lang) {
|
|
1667
1741
|
const { runtime } = ctx;
|
|
1668
1742
|
const bound = runtime.workspaces.of(runtime.sessionId);
|
|
1669
1743
|
sendJson(res, 200, {
|
|
1670
1744
|
sessionId: runtime.sessionId,
|
|
1671
1745
|
...lastSessionField(ctx),
|
|
1746
|
+
...hostComposeField(ctx),
|
|
1672
1747
|
model: runtime.config.model,
|
|
1673
1748
|
permission: runtime.config.permission,
|
|
1674
1749
|
workspace: bound ? toWireSessionWorkspace(bound) : null,
|
|
@@ -2378,970 +2453,1411 @@ function listBlockedGoals(ctx, res, _lang) {
|
|
|
2378
2453
|
);
|
|
2379
2454
|
sendJson(res, 200, { items });
|
|
2380
2455
|
}
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
2398
|
-
return sendError(res, 400, "bad-request", t("web.last_session_bad_request", {}, lang));
|
|
2399
|
-
}
|
|
2400
|
-
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
2401
|
-
return sendUnknownSession(res, sessionId, lang);
|
|
2402
|
-
}
|
|
2403
|
-
const failure = control.set(sessionId);
|
|
2404
|
-
if (failure) return sendError(res, 500, "write-failed", failure(lang));
|
|
2405
|
-
sendJson(res, 200, { ok: true });
|
|
2406
|
-
}
|
|
2407
|
-
function readConfig(ctx, res, lang) {
|
|
2408
|
-
if (ctx.lanExposed) return sendLanExposed(res, lang);
|
|
2409
|
-
const outcome = ctx.runtime.mcp.readConfig();
|
|
2410
|
-
if (!outcome.ok) {
|
|
2411
|
-
return sendError(
|
|
2412
|
-
res,
|
|
2413
|
-
409,
|
|
2414
|
-
"mcp-config-unreadable",
|
|
2415
|
-
t("web.mcp_config_unreadable", { detail: outcome.detail }, lang)
|
|
2456
|
+
var GIT_TIMEOUT_MS = 1e4;
|
|
2457
|
+
var GIT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
2458
|
+
function runGit(cwd, args) {
|
|
2459
|
+
if (!canProbeGit()) return Promise.resolve({ ok: false, error: "git unavailable" });
|
|
2460
|
+
return new Promise((resolve4) => {
|
|
2461
|
+
guardSpawn(
|
|
2462
|
+
() => execFile(
|
|
2463
|
+
"git",
|
|
2464
|
+
[...args],
|
|
2465
|
+
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, encoding: "buffer" },
|
|
2466
|
+
(err, stdout) => {
|
|
2467
|
+
if (err) return resolve4({ ok: false, error: err.message });
|
|
2468
|
+
resolve4({ ok: true, stdout });
|
|
2469
|
+
}
|
|
2470
|
+
),
|
|
2471
|
+
(err) => resolve4({ ok: false, error: `${err.message} (cwd=${cwd})` })
|
|
2416
2472
|
);
|
|
2417
|
-
}
|
|
2418
|
-
const { snapshot } = outcome;
|
|
2419
|
-
sendJson(res, 200, {
|
|
2420
|
-
configPath: snapshot.configPath,
|
|
2421
|
-
text: snapshot.text,
|
|
2422
|
-
exists: snapshot.exists,
|
|
2423
|
-
revision: snapshot.revision,
|
|
2424
|
-
issues: toWireIssues(snapshot.issues)
|
|
2425
2473
|
});
|
|
2426
2474
|
}
|
|
2427
|
-
async function
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2475
|
+
async function repoRoot(cwd) {
|
|
2476
|
+
const result = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
2477
|
+
if (!result.ok) return null;
|
|
2478
|
+
const root = result.stdout.toString("utf-8").trim();
|
|
2479
|
+
return root.length > 0 ? root : null;
|
|
2480
|
+
}
|
|
2481
|
+
async function hasHead(root) {
|
|
2482
|
+
return (await runGit(root, ["rev-parse", "--verify", "--quiet", "HEAD"])).ok;
|
|
2483
|
+
}
|
|
2484
|
+
var NULL_SHA = /^0+$/;
|
|
2485
|
+
async function rawChanges(root) {
|
|
2486
|
+
const result = await runGit(root, ["diff", "--raw", "-z", "HEAD", "--"]);
|
|
2487
|
+
if (!result.ok) return [];
|
|
2488
|
+
return parseRawZ(result.stdout.toString("utf-8"));
|
|
2489
|
+
}
|
|
2490
|
+
function parseRawZ(text) {
|
|
2491
|
+
const tokens = text.split("\0");
|
|
2492
|
+
const changes = [];
|
|
2493
|
+
let i = 0;
|
|
2494
|
+
while (i < tokens.length) {
|
|
2495
|
+
const meta = tokens[i];
|
|
2496
|
+
if (meta === void 0 || !meta.startsWith(":")) {
|
|
2497
|
+
i += 1;
|
|
2498
|
+
continue;
|
|
2499
|
+
}
|
|
2500
|
+
const parts = meta.slice(1).split(" ");
|
|
2501
|
+
const oldSha = parts[2] ?? "";
|
|
2502
|
+
const status = (parts[4] ?? "").trim();
|
|
2503
|
+
const renamed = status.startsWith("R") || status.startsWith("C");
|
|
2504
|
+
const first = tokens[i + 1];
|
|
2505
|
+
const second = renamed ? tokens[i + 2] : void 0;
|
|
2506
|
+
i += renamed ? 3 : 2;
|
|
2507
|
+
if (first === void 0 || renamed && second === void 0) break;
|
|
2508
|
+
const path = renamed ? second : first;
|
|
2509
|
+
changes.push({
|
|
2510
|
+
status: status.charAt(0),
|
|
2511
|
+
path,
|
|
2512
|
+
...renamed ? { oldPath: first } : {},
|
|
2513
|
+
oldSha: NULL_SHA.test(oldSha) ? null : oldSha
|
|
2514
|
+
});
|
|
2441
2515
|
}
|
|
2442
|
-
|
|
2443
|
-
if (!outcome.ok) return sendError(res, ...saveFailure(outcome.reason, outcome.detail, lang));
|
|
2444
|
-
sendJson(res, 200, {
|
|
2445
|
-
configPath: outcome.configPath,
|
|
2446
|
-
revision: outcome.revision,
|
|
2447
|
-
issues: toWireIssues(outcome.issues)
|
|
2448
|
-
});
|
|
2516
|
+
return changes;
|
|
2449
2517
|
}
|
|
2450
|
-
async function
|
|
2451
|
-
|
|
2452
|
-
const
|
|
2453
|
-
if (!
|
|
2454
|
-
const
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
);
|
|
2518
|
+
async function treeBlobSizes(root) {
|
|
2519
|
+
const sizes = /* @__PURE__ */ new Map();
|
|
2520
|
+
const result = await runGit(root, ["ls-tree", "-r", "-l", "-z", "HEAD"]);
|
|
2521
|
+
if (!result.ok) return sizes;
|
|
2522
|
+
for (const record of splitZ(result.stdout.toString("utf-8"))) {
|
|
2523
|
+
const tab = record.indexOf(" ");
|
|
2524
|
+
if (tab < 0) continue;
|
|
2525
|
+
const columns = record.slice(0, tab).split(/\s+/).filter(Boolean);
|
|
2526
|
+
const sha = columns[2];
|
|
2527
|
+
const size = Number(columns[3]);
|
|
2528
|
+
if (columns[1] === "blob" && sha && Number.isFinite(size)) sizes.set(sha, size);
|
|
2462
2529
|
}
|
|
2463
|
-
|
|
2464
|
-
if (!outcome.ok) return sendError(res, ...applyFailure(outcome.reason, outcome.detail, lang));
|
|
2465
|
-
sendJson(res, 200, {
|
|
2466
|
-
configPath: outcome.configPath,
|
|
2467
|
-
revision: outcome.revision,
|
|
2468
|
-
issues: toWireIssues(outcome.issues),
|
|
2469
|
-
changes: outcome.changes.map((change) => ({
|
|
2470
|
-
name: change.name,
|
|
2471
|
-
action: change.action,
|
|
2472
|
-
...change.status === void 0 ? {} : { server: toWireMcp(change.status) },
|
|
2473
|
-
...change.reason === void 0 ? {} : { reason: change.reason }
|
|
2474
|
-
}))
|
|
2475
|
-
});
|
|
2530
|
+
return sizes;
|
|
2476
2531
|
}
|
|
2477
|
-
function
|
|
2478
|
-
|
|
2532
|
+
async function untrackedFiles(root) {
|
|
2533
|
+
const result = await runGit(root, ["ls-files", "-z", "--others", "--exclude-standard"]);
|
|
2534
|
+
if (!result.ok) return [];
|
|
2535
|
+
return splitZ(result.stdout.toString("utf-8"));
|
|
2479
2536
|
}
|
|
2480
|
-
function
|
|
2481
|
-
|
|
2482
|
-
|
|
2537
|
+
async function trackedFiles(root) {
|
|
2538
|
+
const result = await runGit(root, ["ls-files", "-z"]);
|
|
2539
|
+
if (!result.ok) return [];
|
|
2540
|
+
return splitZ(result.stdout.toString("utf-8"));
|
|
2541
|
+
}
|
|
2542
|
+
async function catFileBlob(root, sha) {
|
|
2543
|
+
const result = await runGit(root, ["cat-file", "blob", sha]);
|
|
2544
|
+
return result.ok ? result.stdout : null;
|
|
2545
|
+
}
|
|
2546
|
+
function splitZ(text) {
|
|
2547
|
+
return text.split("\0").filter((s) => s.length > 0);
|
|
2548
|
+
}
|
|
2549
|
+
// src/workspace/diff.ts
|
|
2550
|
+
var MAX_DIFF_BYTES = 512 * 1024;
|
|
2551
|
+
var MAX_DIFF_FILES = 200;
|
|
2552
|
+
var BINARY_SNIFF_BYTES = 8e3;
|
|
2553
|
+
var ABSENT = { kind: "absent" };
|
|
2554
|
+
async function collectWorkspaceDiff(opts) {
|
|
2555
|
+
const root = await repoRoot(opts.workDir);
|
|
2556
|
+
return root === null ? await checkpointDiff(opts) : await gitDiff(root);
|
|
2557
|
+
}
|
|
2558
|
+
async function gitDiff(root) {
|
|
2559
|
+
const wanted = await collectGitPaths(root);
|
|
2560
|
+
const notes = [];
|
|
2561
|
+
const truncated = wanted.length > MAX_DIFF_FILES;
|
|
2562
|
+
const picked = truncated ? wanted.slice(0, MAX_DIFF_FILES) : wanted;
|
|
2563
|
+
if (truncated) notes.push("truncated");
|
|
2564
|
+
if (wanted.length === 0) notes.push("no-changes");
|
|
2565
|
+
const sizes = picked.some((p) => p.oldSha !== null) ? await treeBlobSizes(root) : /* @__PURE__ */ new Map();
|
|
2566
|
+
const files = [];
|
|
2567
|
+
for (const item of picked) {
|
|
2568
|
+
const old = await gitSide(root, item.oldSha, sizes);
|
|
2569
|
+
const fresh = await worktreeSide(join(root, ...item.path.split("/")));
|
|
2570
|
+
files.push(describe3(item.path, old, fresh));
|
|
2483
2571
|
}
|
|
2484
|
-
|
|
2485
|
-
return [409, "mcp-config-unwritable", t("web.mcp_config_unwritable", { detail }, lang)];
|
|
2572
|
+
return { source: "git", root, files, truncated, notes };
|
|
2486
2573
|
}
|
|
2487
|
-
function
|
|
2488
|
-
|
|
2489
|
-
|
|
2574
|
+
async function collectGitPaths(root) {
|
|
2575
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
2576
|
+
if (await hasHead(root)) {
|
|
2577
|
+
for (const change of await rawChanges(root)) {
|
|
2578
|
+
if (change.oldPath !== void 0) {
|
|
2579
|
+
byPath.set(change.oldPath, { path: change.oldPath, oldSha: change.oldSha });
|
|
2580
|
+
}
|
|
2581
|
+
byPath.set(change.path, {
|
|
2582
|
+
path: change.path,
|
|
2583
|
+
oldSha: change.oldPath === void 0 ? change.oldSha : null
|
|
2584
|
+
});
|
|
2585
|
+
}
|
|
2586
|
+
} else {
|
|
2587
|
+
for (const path of await trackedFiles(root)) byPath.set(path, { path, oldSha: null });
|
|
2490
2588
|
}
|
|
2491
|
-
|
|
2492
|
-
|
|
2589
|
+
for (const path of await untrackedFiles(root)) {
|
|
2590
|
+
if (!byPath.has(path)) byPath.set(path, { path, oldSha: null });
|
|
2493
2591
|
}
|
|
2494
|
-
|
|
2495
|
-
return [409, "mcp-config-no-servers", t("web.mcp_config_no_servers", { detail }, lang)];
|
|
2592
|
+
return [...byPath.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
2496
2593
|
}
|
|
2497
|
-
function
|
|
2498
|
-
|
|
2594
|
+
async function gitSide(root, sha, sizes) {
|
|
2595
|
+
if (sha === null) return ABSENT;
|
|
2596
|
+
const known = sizes.get(sha);
|
|
2597
|
+
if (known !== void 0 && known > MAX_DIFF_BYTES) return { kind: "oversize", bytes: known };
|
|
2598
|
+
const data = await catFileBlob(root, sha);
|
|
2599
|
+
if (data === null) return { kind: "oversize", bytes: known ?? -1 };
|
|
2600
|
+
return { kind: "content", data };
|
|
2499
2601
|
}
|
|
2500
|
-
async function
|
|
2501
|
-
const
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
}
|
|
2506
|
-
const status = {
|
|
2507
|
-
...outcome.status,
|
|
2508
|
-
...outcome.detail === void 0 ? {} : { lastError: outcome.detail }
|
|
2509
|
-
};
|
|
2510
|
-
return sendJson(res, 200, {
|
|
2511
|
-
ok: false,
|
|
2512
|
-
server: toWireMcp(status)
|
|
2513
|
-
});
|
|
2602
|
+
async function checkpointDiff(opts) {
|
|
2603
|
+
const notes = ["not-a-git-repo"];
|
|
2604
|
+
const paths = await checkpointPaths(opts.checkpoints);
|
|
2605
|
+
if (paths.length === 0) {
|
|
2606
|
+
notes.push(opts.checkpoints === null ? "no-workspace-history" : "no-changes");
|
|
2607
|
+
return { source: "none", root: opts.workDir, files: [], truncated: false, notes };
|
|
2514
2608
|
}
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
t("web.mcp_add_lan_exposed", void 0, lang)
|
|
2609
|
+
const truncated = paths.length > MAX_DIFF_FILES;
|
|
2610
|
+
const picked = truncated ? paths.slice(0, MAX_DIFF_FILES) : paths;
|
|
2611
|
+
if (truncated) notes.push("truncated");
|
|
2612
|
+
const files = [];
|
|
2613
|
+
for (const absolute of picked) {
|
|
2614
|
+
const fresh = await worktreeSide(absolute);
|
|
2615
|
+
files.push(
|
|
2616
|
+
describe3(displayPath(opts.workDir, absolute), ABSENT, fresh, {
|
|
2617
|
+
status: fresh.kind === "absent" ? "deleted" : "modified",
|
|
2618
|
+
omitted: "no-snapshot"
|
|
2619
|
+
})
|
|
2527
2620
|
);
|
|
2528
2621
|
}
|
|
2529
|
-
|
|
2530
|
-
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2531
|
-
const payload = asRecord(body.value);
|
|
2532
|
-
const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
|
|
2533
|
-
const transport = payload["transport"];
|
|
2534
|
-
if (transport !== "stdio" && transport !== "sse" && transport !== "http") {
|
|
2535
|
-
return sendError(res, 400, "bad-mcp-config", t("web.mcp_add_bad_transport", void 0, lang));
|
|
2536
|
-
}
|
|
2537
|
-
const outcome = await ctx.runtime.mcp.add({
|
|
2538
|
-
name,
|
|
2539
|
-
transport,
|
|
2540
|
-
...typeof payload["command"] === "string" ? { command: payload["command"].trim() } : {},
|
|
2541
|
-
...Array.isArray(payload["args"]) ? { args: stringList(payload["args"]) } : {},
|
|
2542
|
-
...typeof payload["url"] === "string" ? { url: payload["url"].trim() } : {},
|
|
2543
|
-
...payload["env"] === void 0 ? {} : { env: stringMap(payload["env"]) }
|
|
2544
|
-
});
|
|
2545
|
-
if (!outcome.ok) {
|
|
2546
|
-
return sendError(res, ...addFailure(outcome.reason, outcome.detail, name, lang));
|
|
2547
|
-
}
|
|
2548
|
-
sendJson(res, 200, {
|
|
2549
|
-
server: toWireMcp(outcome.status),
|
|
2550
|
-
configPath: outcome.configPath
|
|
2551
|
-
});
|
|
2622
|
+
return { source: "checkpoints", root: opts.workDir, files, truncated, notes };
|
|
2552
2623
|
}
|
|
2553
|
-
function
|
|
2554
|
-
if (
|
|
2555
|
-
|
|
2624
|
+
async function checkpointPaths(checkpoints) {
|
|
2625
|
+
if (!checkpoints) return [];
|
|
2626
|
+
const paths = /* @__PURE__ */ new Set();
|
|
2627
|
+
try {
|
|
2628
|
+
for (const summary of await checkpoints.list()) {
|
|
2629
|
+
const preview = await checkpoints.preview(summary.turnIndex);
|
|
2630
|
+
if (!preview) continue;
|
|
2631
|
+
for (const plan of [...preview.files, ...preview.conflicts]) {
|
|
2632
|
+
if (plan.action !== "skip") paths.add(plan.path);
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
} catch {
|
|
2556
2636
|
}
|
|
2557
|
-
|
|
2558
|
-
if (reason === "duplicate")
|
|
2559
|
-
return [409, "mcp-name-taken", t("web.mcp_add_taken", { name }, lang)];
|
|
2560
|
-
return [409, "mcp-config-unwritable", t("web.mcp_add_unwritable", { detail }, lang)];
|
|
2637
|
+
return [...paths].sort();
|
|
2561
2638
|
}
|
|
2562
|
-
function
|
|
2563
|
-
|
|
2639
|
+
function displayPath(workDir, absolute) {
|
|
2640
|
+
const rel = relative(workDir, absolute);
|
|
2641
|
+
if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) return absolute;
|
|
2642
|
+
return rel.split(sep).join("/");
|
|
2564
2643
|
}
|
|
2565
|
-
function
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
if (
|
|
2644
|
+
async function worktreeSide(absolute) {
|
|
2645
|
+
try {
|
|
2646
|
+
const info = await stat(absolute);
|
|
2647
|
+
if (!info.isFile()) return ABSENT;
|
|
2648
|
+
if (info.size > MAX_DIFF_BYTES) return { kind: "oversize", bytes: info.size };
|
|
2649
|
+
return { kind: "content", data: await readFile(absolute) };
|
|
2650
|
+
} catch {
|
|
2651
|
+
return ABSENT;
|
|
2569
2652
|
}
|
|
2570
|
-
return out;
|
|
2571
2653
|
}
|
|
2572
|
-
function
|
|
2573
|
-
|
|
2654
|
+
function describe3(path, old, fresh, override = {}) {
|
|
2655
|
+
const status = override.status ?? (old.kind === "absent" ? "added" : fresh.kind === "absent" ? "deleted" : "modified");
|
|
2656
|
+
const oldBytes = bytesOf(old);
|
|
2657
|
+
const newBytes = bytesOf(fresh);
|
|
2658
|
+
const hard = omitReason(old, fresh);
|
|
2659
|
+
if (hard !== void 0) {
|
|
2660
|
+
return { path, status, oldContent: null, newContent: null, oldBytes, newBytes, omitted: hard };
|
|
2661
|
+
}
|
|
2662
|
+
return {
|
|
2663
|
+
path,
|
|
2664
|
+
status,
|
|
2665
|
+
oldContent: textOf(old),
|
|
2666
|
+
newContent: textOf(fresh),
|
|
2667
|
+
oldBytes,
|
|
2668
|
+
newBytes,
|
|
2669
|
+
...override.omitted === void 0 ? {} : { omitted: override.omitted }
|
|
2670
|
+
};
|
|
2574
2671
|
}
|
|
2575
|
-
function
|
|
2576
|
-
|
|
2672
|
+
function bytesOf(side) {
|
|
2673
|
+
if (side.kind === "absent") return null;
|
|
2674
|
+
if (side.kind === "oversize") return side.bytes < 0 ? null : side.bytes;
|
|
2675
|
+
return side.data.byteLength;
|
|
2577
2676
|
}
|
|
2578
|
-
function
|
|
2579
|
-
|
|
2580
|
-
return history.some((m) => m.parts?.some((p) => p.type === "image"));
|
|
2677
|
+
function textOf(side) {
|
|
2678
|
+
return side.kind === "content" ? side.data.toString("utf-8") : null;
|
|
2581
2679
|
}
|
|
2582
|
-
function
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
sendJson(res, 200, {
|
|
2587
|
-
selection: selection ? toWireSelection(selection) : null,
|
|
2588
|
-
configured: (live ? live.model.configured() : runtime.config.model) || null
|
|
2589
|
-
});
|
|
2680
|
+
function omitReason(old, fresh) {
|
|
2681
|
+
if (isBinary(old) || isBinary(fresh)) return "binary";
|
|
2682
|
+
if (old.kind === "oversize" || fresh.kind === "oversize") return "too-large";
|
|
2683
|
+
return void 0;
|
|
2590
2684
|
}
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2685
|
+
function isBinary(side) {
|
|
2686
|
+
return side.kind === "content" && side.data.subarray(0, BINARY_SNIFF_BYTES).includes(0);
|
|
2687
|
+
}
|
|
2688
|
+
// src/workspace/handlers.ts
|
|
2689
|
+
async function createSession(ctx, req, res, lang) {
|
|
2595
2690
|
const body = await readJsonBody(req);
|
|
2596
2691
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2597
|
-
const
|
|
2598
|
-
if (!
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
}
|
|
2604
|
-
const selectionCtx = {
|
|
2605
|
-
hasImages: sessionHasImages(live.session),
|
|
2606
|
-
...input.usedTokens === void 0 ? {} : { usedTokens: input.usedTokens }
|
|
2607
|
-
};
|
|
2608
|
-
const result = ref === null ? control.reset(selectionCtx) : control.set(ref, selectionCtx);
|
|
2609
|
-
const payload = result.ok ? {
|
|
2610
|
-
ok: true,
|
|
2611
|
-
selection: toWireSelection(result.selection),
|
|
2612
|
-
contextLength: result.contextLength,
|
|
2613
|
-
caveats: result.caveats
|
|
2614
|
-
} : { ok: false, rejection: result.rejection };
|
|
2615
|
-
sendJson(res, 200, payload);
|
|
2692
|
+
const outcome = await ensureSession(ctx, asRecord(body.value), lang);
|
|
2693
|
+
if (!outcome.ok) return sendError(res, outcome.status, outcome.code, outcome.message);
|
|
2694
|
+
sendJson(res, 200, {
|
|
2695
|
+
sessionId: outcome.sessionId,
|
|
2696
|
+
created: outcome.created,
|
|
2697
|
+
workspace: outcome.workspace
|
|
2698
|
+
});
|
|
2616
2699
|
}
|
|
2617
|
-
function
|
|
2618
|
-
const
|
|
2619
|
-
if (
|
|
2620
|
-
const
|
|
2700
|
+
async function ensureSession(ctx, payload, lang) {
|
|
2701
|
+
const field = readWorkspaceField(payload, lang);
|
|
2702
|
+
if (!field.ok) return { ok: false, status: 400, code: "bad-workspace", message: field.message };
|
|
2703
|
+
const defer = payload["deferWorkspace"] === true;
|
|
2704
|
+
if (defer && field.dir !== null) {
|
|
2705
|
+
return {
|
|
2706
|
+
ok: false,
|
|
2707
|
+
status: 400,
|
|
2708
|
+
code: "bad-request",
|
|
2709
|
+
message: t("web.workspace_defer_with_root", void 0, lang)
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
if (defer && payload["create"] !== true) {
|
|
2713
|
+
return {
|
|
2714
|
+
ok: false,
|
|
2715
|
+
status: 400,
|
|
2716
|
+
code: "bad-request",
|
|
2717
|
+
message: t("web.workspace_defer_needs_create", void 0, lang)
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
if (field.dir === null && payload["create"] !== true) {
|
|
2721
|
+
const sessionId = ctx.runtime.sessionId;
|
|
2722
|
+
const bound = ctx.runtime.workspaces.of(sessionId);
|
|
2723
|
+
return {
|
|
2724
|
+
ok: true,
|
|
2725
|
+
sessionId,
|
|
2726
|
+
created: false,
|
|
2727
|
+
workspace: bound ? toWireSessionWorkspace(bound) : null
|
|
2728
|
+
};
|
|
2729
|
+
}
|
|
2730
|
+
const role = typeof payload["role"] === "string" ? payload["role"].trim() : "";
|
|
2731
|
+
const made = ctx.runtime.sessionFactory.create({
|
|
2732
|
+
workspace: field.dir,
|
|
2733
|
+
...lang === void 0 ? {} : { lang },
|
|
2734
|
+
reuse: payload["create"] !== true,
|
|
2735
|
+
...role === "" ? {} : { role }
|
|
2736
|
+
});
|
|
2737
|
+
if (!made.ok) {
|
|
2738
|
+
if (made.reason === "unavailable") return { ok: false, ...unavailableToHttp(lang) };
|
|
2739
|
+
if (made.reason === "unknown-role") {
|
|
2740
|
+
return { ok: false, ...unknownRoleToHttp(made.detail, lang) };
|
|
2741
|
+
}
|
|
2742
|
+
return { ok: false, ...bindFailureToHttp(made, lang) };
|
|
2743
|
+
}
|
|
2744
|
+
ctx.hub.register(made.live.session);
|
|
2745
|
+
if (defer) ctx.runtime.workspaces.defer(made.live.sessionId);
|
|
2746
|
+
if (made.created) ctx.hub.notifySessionsChanged(made.live.sessionId);
|
|
2621
2747
|
return {
|
|
2622
2748
|
ok: true,
|
|
2623
|
-
|
|
2624
|
-
|
|
2749
|
+
sessionId: made.live.sessionId,
|
|
2750
|
+
created: made.created,
|
|
2751
|
+
workspace: made.live.workspace ? toWireSessionWorkspace(made.live.workspace) : null
|
|
2625
2752
|
};
|
|
2626
2753
|
}
|
|
2627
|
-
function
|
|
2628
|
-
if (!ctx.runtime.
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2754
|
+
function sessionWorkspace(ctx, res, sessionId, lang) {
|
|
2755
|
+
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
2756
|
+
return sendUnknownSession(res, sessionId);
|
|
2757
|
+
}
|
|
2758
|
+
const state = ctx.runtime.workspaces.stateOf(sessionId);
|
|
2759
|
+
if (state !== "none") {
|
|
2760
|
+
return sendJson(res, 200, {
|
|
2761
|
+
binding: toWireBinding(state, ctx.runtime.workspaces.of(sessionId))
|
|
2762
|
+
});
|
|
2763
|
+
}
|
|
2764
|
+
if (ctx.hub.has(sessionId) || ctx.hub.isCooled(sessionId)) {
|
|
2765
|
+
return sendJson(res, 200, {
|
|
2766
|
+
binding: { state: "none" }
|
|
2767
|
+
});
|
|
2768
|
+
}
|
|
2769
|
+
sendError(res, 409, "not-live-session", t("web.workspace_not_live", void 0, lang));
|
|
2632
2770
|
}
|
|
2633
|
-
async function
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
const plan = live.plan;
|
|
2637
|
-
if (!plan) {
|
|
2638
|
-
return sendError(res, 503, "no-plan-mode", t("web.plan_mode_unavailable", void 0, lang));
|
|
2771
|
+
async function bindWorkspace(ctx, req, res, sessionId, lang) {
|
|
2772
|
+
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
2773
|
+
return sendUnknownSession(res, sessionId);
|
|
2639
2774
|
}
|
|
2640
2775
|
const body = await readJsonBody(req);
|
|
2641
2776
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2642
|
-
const
|
|
2643
|
-
if (
|
|
2777
|
+
const parsed = readBindWorkspaceBody(asRecord(body.value), lang);
|
|
2778
|
+
if (!parsed.ok) return sendError(res, 400, "bad-workspace", parsed.message);
|
|
2779
|
+
const state = ctx.runtime.workspaces.stateOf(sessionId);
|
|
2780
|
+
if (state !== "unbound") {
|
|
2644
2781
|
return sendError(
|
|
2645
2782
|
res,
|
|
2646
|
-
|
|
2647
|
-
"
|
|
2648
|
-
t("web.
|
|
2783
|
+
409,
|
|
2784
|
+
"workspace-locked",
|
|
2785
|
+
t("web.workspace_already_decided", void 0, lang)
|
|
2649
2786
|
);
|
|
2650
2787
|
}
|
|
2651
|
-
const
|
|
2788
|
+
const turn = ctx.hub.stateOf(sessionId);
|
|
2789
|
+
if (turn !== "idle") {
|
|
2790
|
+
return sendError(res, 409, "busy", t("web.workspace_turn_running", void 0, lang), {
|
|
2791
|
+
"X-Epoch-Turn-State": turn
|
|
2792
|
+
});
|
|
2793
|
+
}
|
|
2794
|
+
if (parsed.kind === "none") {
|
|
2795
|
+
ctx.runtime.workspaces.decideNone(sessionId);
|
|
2796
|
+
return sendJson(res, 200, {
|
|
2797
|
+
binding: { state: "none" }
|
|
2798
|
+
});
|
|
2799
|
+
}
|
|
2800
|
+
const outcome = ctx.runtime.workspaces.bind(sessionId, parsed.root, lang);
|
|
2801
|
+
if (!outcome.ok) {
|
|
2802
|
+
const mapped = bindFailureToHttp(outcome, lang);
|
|
2803
|
+
return sendError(res, mapped.status, mapped.code, mapped.message);
|
|
2804
|
+
}
|
|
2652
2805
|
sendJson(res, 200, {
|
|
2653
|
-
|
|
2654
|
-
changed
|
|
2806
|
+
binding: { state: "bound", workspace: toWireSessionWorkspace(outcome.bound) }
|
|
2655
2807
|
});
|
|
2656
2808
|
}
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
}
|
|
2661
|
-
|
|
2662
|
-
|
|
2809
|
+
async function sessionDiff(ctx, res, sessionId, _lang) {
|
|
2810
|
+
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
2811
|
+
return sendUnknownSession(res, sessionId);
|
|
2812
|
+
}
|
|
2813
|
+
const bound = ctx.runtime.workspaces.of(sessionId);
|
|
2814
|
+
if (!bound) {
|
|
2815
|
+
return sendJson(res, 200, {
|
|
2816
|
+
source: "none",
|
|
2817
|
+
root: "",
|
|
2818
|
+
files: [],
|
|
2819
|
+
truncated: false,
|
|
2820
|
+
notes: ["no-workspace"]
|
|
2821
|
+
});
|
|
2822
|
+
}
|
|
2823
|
+
sendJson(
|
|
2824
|
+
res,
|
|
2825
|
+
200,
|
|
2826
|
+
await collectWorkspaceDiff({
|
|
2827
|
+
workDir: bound.workspace.root,
|
|
2828
|
+
checkpoints: checkpointsOf(ctx, sessionId)
|
|
2829
|
+
})
|
|
2830
|
+
);
|
|
2663
2831
|
}
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2832
|
+
// src/host-compose.ts
|
|
2833
|
+
async function postHostCompose(ctx, req, res, lang) {
|
|
2834
|
+
const body = await readJsonBody(req, void 0, lang);
|
|
2835
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2836
|
+
const payload = asRecord(body.value);
|
|
2837
|
+
const sessionId = payload["sessionId"];
|
|
2838
|
+
if (sessionId !== void 0 && typeof sessionId !== "string") {
|
|
2839
|
+
return sendError(res, 400, "bad-request", t("web.host_compose_bad_request", void 0, lang));
|
|
2840
|
+
}
|
|
2841
|
+
if (sessionId !== void 0 && ["workspace", "create", "role", "deferWorkspace"].some((key) => payload[key] !== void 0)) {
|
|
2842
|
+
return sendError(res, 400, "bad-request", t("web.host_compose_exclusive", void 0, lang));
|
|
2843
|
+
}
|
|
2844
|
+
const rawText = payload["text"];
|
|
2845
|
+
if (rawText !== void 0 && typeof rawText !== "string") {
|
|
2846
|
+
return sendError(res, 400, "bad-request", t("web.host_compose_bad_request", void 0, lang));
|
|
2847
|
+
}
|
|
2848
|
+
const text = rawText?.trim() ?? "";
|
|
2849
|
+
if (rawText !== void 0 && text === "") {
|
|
2850
|
+
return sendError(res, 400, "bad-request", t("web.host_compose_blank_text", void 0, lang));
|
|
2851
|
+
}
|
|
2852
|
+
const autoSend = payload["autoSend"] === true;
|
|
2853
|
+
if (autoSend && text === "") {
|
|
2854
|
+
return sendError(
|
|
2855
|
+
res,
|
|
2856
|
+
400,
|
|
2857
|
+
"bad-request",
|
|
2858
|
+
t("web.host_compose_autosend_needs_text", void 0, lang)
|
|
2859
|
+
);
|
|
2860
|
+
}
|
|
2861
|
+
let target;
|
|
2862
|
+
let created = false;
|
|
2863
|
+
if (typeof sessionId === "string") {
|
|
2864
|
+
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
2865
|
+
return sendUnknownSession(res, sessionId, lang);
|
|
2866
|
+
}
|
|
2867
|
+
target = sessionId;
|
|
2868
|
+
} else {
|
|
2869
|
+
const outcome = await ensureSession(ctx, payload, lang);
|
|
2870
|
+
if (!outcome.ok) return sendError(res, outcome.status, outcome.code, outcome.message);
|
|
2871
|
+
target = outcome.sessionId;
|
|
2872
|
+
created = outcome.created;
|
|
2873
|
+
}
|
|
2874
|
+
const item = ctx.hostCompose.record({ sessionId: target, text, autoSend });
|
|
2875
|
+
ctx.hub.notifyHostCompose(target, { id: item.id, text, autoSend });
|
|
2876
|
+
sendJson(res, 200, {
|
|
2877
|
+
sessionId: target,
|
|
2878
|
+
created,
|
|
2879
|
+
composeId: item.id,
|
|
2880
|
+
expiresAt: item.createdAt + HOST_COMPOSE_TTL_MS
|
|
2881
|
+
});
|
|
2672
2882
|
}
|
|
2673
|
-
function
|
|
2674
|
-
|
|
2675
|
-
sendJson(res, 200,
|
|
2883
|
+
function getHostCompose(ctx, res) {
|
|
2884
|
+
const item = ctx.hostCompose.pending();
|
|
2885
|
+
sendJson(res, 200, {
|
|
2886
|
+
compose: item === null ? null : toWireHostCompose(item)
|
|
2887
|
+
});
|
|
2676
2888
|
}
|
|
2677
|
-
|
|
2678
|
-
const
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2889
|
+
function takeHostCompose(ctx, res, id) {
|
|
2890
|
+
const result = ctx.hostCompose.take(id);
|
|
2891
|
+
sendJson(
|
|
2892
|
+
res,
|
|
2893
|
+
200,
|
|
2894
|
+
result === "ok" ? { ok: true } : { ok: false, reason: result }
|
|
2895
|
+
);
|
|
2896
|
+
}
|
|
2897
|
+
async function putLastSession(ctx, req, res, lang) {
|
|
2898
|
+
const control = ctx.runtime.lastOpenSession;
|
|
2899
|
+
if (!control) {
|
|
2682
2900
|
return sendError(
|
|
2683
2901
|
res,
|
|
2684
2902
|
503,
|
|
2685
|
-
"no-
|
|
2686
|
-
t("web.
|
|
2903
|
+
"no-last-session-store",
|
|
2904
|
+
t("web.last_session_unavailable", {}, lang)
|
|
2905
|
+
);
|
|
2906
|
+
}
|
|
2907
|
+
const body = await readJsonBody(req, void 0, lang);
|
|
2908
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2909
|
+
if (!conformsToWire(body.value, WIRE_LAST_SESSION_FIELDS)) {
|
|
2910
|
+
return sendError(res, 400, "bad-request", t("web.last_session_bad_request", {}, lang));
|
|
2911
|
+
}
|
|
2912
|
+
const { sessionId } = body.value;
|
|
2913
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
2914
|
+
return sendError(res, 400, "bad-request", t("web.last_session_bad_request", {}, lang));
|
|
2915
|
+
}
|
|
2916
|
+
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
2917
|
+
return sendUnknownSession(res, sessionId, lang);
|
|
2918
|
+
}
|
|
2919
|
+
const failure = control.set(sessionId);
|
|
2920
|
+
if (failure) return sendError(res, 500, "write-failed", failure(lang));
|
|
2921
|
+
sendJson(res, 200, { ok: true });
|
|
2922
|
+
}
|
|
2923
|
+
function readConfig(ctx, res, lang) {
|
|
2924
|
+
if (ctx.lanExposed) return sendLanExposed(res, lang);
|
|
2925
|
+
const outcome = ctx.runtime.mcp.readConfig();
|
|
2926
|
+
if (!outcome.ok) {
|
|
2927
|
+
return sendError(
|
|
2928
|
+
res,
|
|
2929
|
+
409,
|
|
2930
|
+
"mcp-config-unreadable",
|
|
2931
|
+
t("web.mcp_config_unreadable", { detail: outcome.detail }, lang)
|
|
2687
2932
|
);
|
|
2688
2933
|
}
|
|
2934
|
+
const { snapshot } = outcome;
|
|
2935
|
+
sendJson(res, 200, {
|
|
2936
|
+
configPath: snapshot.configPath,
|
|
2937
|
+
text: snapshot.text,
|
|
2938
|
+
exists: snapshot.exists,
|
|
2939
|
+
revision: snapshot.revision,
|
|
2940
|
+
issues: toWireIssues(snapshot.issues)
|
|
2941
|
+
});
|
|
2942
|
+
}
|
|
2943
|
+
async function saveConfig(ctx, req, res, lang) {
|
|
2944
|
+
if (ctx.lanExposed) return sendLanExposed(res, lang);
|
|
2689
2945
|
const body = await readJsonBody(req);
|
|
2690
2946
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2691
|
-
const
|
|
2692
|
-
|
|
2947
|
+
const payload = asRecord(body.value);
|
|
2948
|
+
const text = payload["text"];
|
|
2949
|
+
const revision = payload["revision"];
|
|
2950
|
+
if (typeof text !== "string" || typeof revision !== "string") {
|
|
2693
2951
|
return sendError(
|
|
2694
2952
|
res,
|
|
2695
2953
|
400,
|
|
2696
|
-
"bad-
|
|
2697
|
-
t("web.
|
|
2954
|
+
"bad-mcp-config-body",
|
|
2955
|
+
t("web.mcp_config_bad_body", void 0, lang)
|
|
2698
2956
|
);
|
|
2699
2957
|
}
|
|
2700
|
-
const
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
...result.notRemembered ? { notRemembered: result.notRemembered } : {}
|
|
2708
|
-
} : { ok: false, refused: REFUSAL[result.reason], changed: false, state };
|
|
2709
|
-
sendJson(res, 200, payload);
|
|
2958
|
+
const outcome = ctx.runtime.mcp.writeConfig(text, revision, lang);
|
|
2959
|
+
if (!outcome.ok) return sendError(res, ...saveFailure(outcome.reason, outcome.detail, lang));
|
|
2960
|
+
sendJson(res, 200, {
|
|
2961
|
+
configPath: outcome.configPath,
|
|
2962
|
+
revision: outcome.revision,
|
|
2963
|
+
issues: toWireIssues(outcome.issues)
|
|
2964
|
+
});
|
|
2710
2965
|
}
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2966
|
+
async function applyConfig(ctx, req, res, lang) {
|
|
2967
|
+
if (ctx.lanExposed) return sendLanExposed(res, lang);
|
|
2968
|
+
const body = await readJsonBody(req);
|
|
2969
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2970
|
+
const revision = asRecord(body.value)["revision"];
|
|
2971
|
+
if (typeof revision !== "string") {
|
|
2972
|
+
return sendError(
|
|
2973
|
+
res,
|
|
2974
|
+
400,
|
|
2975
|
+
"bad-mcp-config-body",
|
|
2976
|
+
t("web.mcp_config_bad_body", void 0, lang)
|
|
2977
|
+
);
|
|
2715
2978
|
}
|
|
2716
|
-
const
|
|
2717
|
-
|
|
2979
|
+
const outcome = await ctx.runtime.mcp.applyConfig(revision, lang);
|
|
2980
|
+
if (!outcome.ok) return sendError(res, ...applyFailure(outcome.reason, outcome.detail, lang));
|
|
2718
2981
|
sendJson(res, 200, {
|
|
2719
|
-
|
|
2720
|
-
|
|
2982
|
+
configPath: outcome.configPath,
|
|
2983
|
+
revision: outcome.revision,
|
|
2984
|
+
issues: toWireIssues(outcome.issues),
|
|
2985
|
+
changes: outcome.changes.map((change) => ({
|
|
2986
|
+
name: change.name,
|
|
2987
|
+
action: change.action,
|
|
2988
|
+
...change.status === void 0 ? {} : { server: toWireMcp(change.status) },
|
|
2989
|
+
...change.reason === void 0 ? {} : { reason: change.reason }
|
|
2990
|
+
}))
|
|
2721
2991
|
});
|
|
2722
2992
|
}
|
|
2723
|
-
function
|
|
2724
|
-
|
|
2993
|
+
function sendLanExposed(res, lang) {
|
|
2994
|
+
sendError(res, 403, "mcp-config-lan-exposed", t("web.mcp_config_lan_exposed", void 0, lang));
|
|
2725
2995
|
}
|
|
2726
|
-
function
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
denyRules: counts.denyRules,
|
|
2733
|
-
mcpServers: counts.mcpServers
|
|
2734
|
-
};
|
|
2996
|
+
function saveFailure(reason, detail, lang) {
|
|
2997
|
+
if (reason === "stale") {
|
|
2998
|
+
return [409, "mcp-config-stale", t("web.mcp_config_stale", void 0, lang)];
|
|
2999
|
+
}
|
|
3000
|
+
if (reason === "invalid-json") return [400, "mcp-config-invalid-json", detail];
|
|
3001
|
+
return [409, "mcp-config-unwritable", t("web.mcp_config_unwritable", { detail }, lang)];
|
|
2735
3002
|
}
|
|
2736
|
-
function
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
const shown = hit.entry.displayName;
|
|
2740
|
-
if (shown !== void 0 && shown !== "") out.set(`${hit.marketplace}/${hit.entry.name}`, shown);
|
|
3003
|
+
function applyFailure(reason, detail, lang) {
|
|
3004
|
+
if (reason === "unreadable") {
|
|
3005
|
+
return [409, "mcp-config-unreadable", t("web.mcp_config_unreadable", { detail }, lang)];
|
|
2741
3006
|
}
|
|
2742
|
-
|
|
3007
|
+
if (reason === "stale") {
|
|
3008
|
+
return [409, "mcp-config-stale", t("web.mcp_config_stale", void 0, lang)];
|
|
3009
|
+
}
|
|
3010
|
+
if (reason === "invalid-json") return [400, "mcp-config-invalid-json", detail];
|
|
3011
|
+
return [409, "mcp-config-no-servers", t("web.mcp_config_no_servers", { detail }, lang)];
|
|
2743
3012
|
}
|
|
2744
|
-
function
|
|
2745
|
-
return {
|
|
2746
|
-
name: entry.name,
|
|
2747
|
-
displayName: displayNames.get(`${entry.marketplace}/${entry.name}`) ?? null,
|
|
2748
|
-
version: entry.version,
|
|
2749
|
-
source: entry.source,
|
|
2750
|
-
sourceType: entry.sourceType,
|
|
2751
|
-
path: entry.path,
|
|
2752
|
-
linked: entry.linked,
|
|
2753
|
-
enabled: entry.enabled,
|
|
2754
|
-
installedAt: entry.installedAt,
|
|
2755
|
-
marketplace: entry.marketplace ?? null,
|
|
2756
|
-
active: entry.active,
|
|
2757
|
-
counts: entry.counts ? toWireCounts(entry.counts) : null,
|
|
2758
|
-
updateAllowed: entry.updateAllowed,
|
|
2759
|
-
uninstallAllowed: entry.uninstallAllowed
|
|
2760
|
-
};
|
|
3013
|
+
function toWireIssues(issues) {
|
|
3014
|
+
return issues.map((issue) => ({ path: issue.path, message: issue.message }));
|
|
2761
3015
|
}
|
|
2762
|
-
function
|
|
2763
|
-
const
|
|
2764
|
-
return {
|
|
2765
|
-
marketplace: hit.marketplace,
|
|
2766
|
-
ref: hit.ref,
|
|
2767
|
-
name: hit.entry.name,
|
|
2768
|
-
description: hit.entry.description ?? "",
|
|
2769
|
-
category: hit.entry.category ?? null,
|
|
2770
|
-
keywords: [...hit.entry.keywords ?? []],
|
|
2771
|
-
sourceType: hit.sourceType ?? null,
|
|
2772
|
-
installable: hit.installable,
|
|
2773
|
-
installed: mine !== void 0,
|
|
2774
|
-
author: hit.entry.author?.name ?? null,
|
|
2775
|
-
downloads: hit.entry.downloads ?? null,
|
|
2776
|
-
likes: hit.entry.likes ?? null,
|
|
2777
|
-
displayName: hit.entry.displayName ?? null,
|
|
2778
|
-
icon: hit.entry.icon ?? null,
|
|
2779
|
-
version: hit.entry.version ?? null,
|
|
2780
|
-
changelog: hit.entry.changelog ?? null,
|
|
2781
|
-
size: hit.entry.size ?? null,
|
|
2782
|
-
homepage: hit.entry.homepage ?? null,
|
|
2783
|
-
visibility: hit.entry.visibility ?? null,
|
|
2784
|
-
status: hit.entry.status ?? null,
|
|
2785
|
-
updateAvailable: mine !== void 0 && !mine.linked && hit.entry.version !== void 0 && hit.entry.version !== mine.version,
|
|
2786
|
-
installAllowed: hit.installAllowed,
|
|
2787
|
-
updateAllowed: mine?.updateAllowed ?? false,
|
|
2788
|
-
menu: hit.menu.map((item) => ({
|
|
2789
|
-
id: item.id,
|
|
2790
|
-
label: item.label,
|
|
2791
|
-
danger: item.danger === true
|
|
2792
|
-
}))
|
|
2793
|
-
};
|
|
2794
|
-
}
|
|
2795
|
-
function toWireMarketplace(market) {
|
|
2796
|
-
return {
|
|
2797
|
-
name: market.name,
|
|
2798
|
-
source: market.source,
|
|
2799
|
-
entries: market.entries,
|
|
2800
|
-
addedAt: market.addedAt,
|
|
2801
|
-
local: market.local,
|
|
2802
|
-
categories: (market.categories ?? []).map(toWireCategory)
|
|
2803
|
-
};
|
|
2804
|
-
}
|
|
2805
|
-
function toWireCategory(node) {
|
|
2806
|
-
return {
|
|
2807
|
-
id: node.id,
|
|
2808
|
-
label: node.label ?? node.id,
|
|
2809
|
-
children: (node.children ?? []).map(toWireCategory)
|
|
2810
|
-
};
|
|
2811
|
-
}
|
|
2812
|
-
function toWirePreview2(preview) {
|
|
2813
|
-
const { manifest, source, inventory } = preview;
|
|
2814
|
-
return {
|
|
2815
|
-
name: manifest.name,
|
|
2816
|
-
version: manifest.version,
|
|
2817
|
-
description: manifest.description ?? "",
|
|
2818
|
-
source: source.raw,
|
|
2819
|
-
sourceType: source.type,
|
|
2820
|
-
checksum: source.sha256 ?? null,
|
|
2821
|
-
author: manifest.author?.name ?? null,
|
|
2822
|
-
homepage: manifest.homepage ?? null,
|
|
2823
|
-
commands: [...inventory.commands],
|
|
2824
|
-
roles: [...inventory.roles],
|
|
2825
|
-
skills: [...inventory.skills],
|
|
2826
|
-
hooks: inventory.hooks.map((h) => ({ type: h.type, count: h.count })),
|
|
2827
|
-
denyRules: inventory.denyRules,
|
|
2828
|
-
mcpServers: [...inventory.mcpServers],
|
|
2829
|
-
ignoredBuckets: [...inventory.ignoredBuckets],
|
|
2830
|
-
hasJsTools: inventory.jsTools,
|
|
2831
|
-
conflict: preview.conflict ? { name: preview.conflict.name, version: preview.conflict.version } : null
|
|
2832
|
-
};
|
|
2833
|
-
}
|
|
2834
|
-
function controlOf(ctx, res, lang) {
|
|
2835
|
-
const control = ctx.runtime.plugins;
|
|
2836
|
-
if (!control) {
|
|
2837
|
-
sendError(res, 503, "no-plugin-control", t("web.plugin_not_available", void 0, lang));
|
|
2838
|
-
return null;
|
|
2839
|
-
}
|
|
2840
|
-
return control;
|
|
2841
|
-
}
|
|
2842
|
-
function refusedByLan(ctx, res, lang) {
|
|
2843
|
-
if (!ctx.lanExposed) return false;
|
|
2844
|
-
sendError(
|
|
2845
|
-
res,
|
|
2846
|
-
403,
|
|
2847
|
-
"plugin-write-lan-exposed",
|
|
2848
|
-
t("web.plugin_write_lan_exposed", void 0, lang)
|
|
2849
|
-
);
|
|
2850
|
-
return true;
|
|
2851
|
-
}
|
|
2852
|
-
function fieldOf(payload, key) {
|
|
2853
|
-
const value = payload[key];
|
|
2854
|
-
return typeof value === "string" ? value.trim() : "";
|
|
2855
|
-
}
|
|
2856
|
-
function uploadFormOf(payload) {
|
|
2857
|
-
const form = {};
|
|
2858
|
-
for (const name of WIRE_PLUGIN_UPLOAD_FORM_FIELDS) {
|
|
2859
|
-
const raw = fieldOf(payload, name);
|
|
2860
|
-
if (raw === "") continue;
|
|
2861
|
-
if (name === "visibility") {
|
|
2862
|
-
if (raw === "private" || raw === "public") form[name] = raw;
|
|
2863
|
-
continue;
|
|
2864
|
-
}
|
|
2865
|
-
form[name] = raw;
|
|
2866
|
-
}
|
|
2867
|
-
return form;
|
|
2868
|
-
}
|
|
2869
|
-
function listPlugins(ctx, res, lang) {
|
|
2870
|
-
const control = controlOf(ctx, res, lang);
|
|
2871
|
-
if (!control) return;
|
|
2872
|
-
sendJson(res, 200, pluginsPayload(control));
|
|
2873
|
-
}
|
|
2874
|
-
function pluginsPayload(control) {
|
|
2875
|
-
const installed = control.list();
|
|
2876
|
-
const installedMap = new Map(
|
|
2877
|
-
installed.map((p) => [
|
|
2878
|
-
p.name,
|
|
2879
|
-
{ version: p.version, linked: p.linked, updateAllowed: p.updateAllowed }
|
|
2880
|
-
])
|
|
2881
|
-
);
|
|
2882
|
-
const shown = displayNamesOf(control);
|
|
2883
|
-
return {
|
|
2884
|
-
installed: installed.map((p) => toWirePluginEntry(p, shown)),
|
|
2885
|
-
hits: control.search().map((hit) => toWirePluginHit(hit, installedMap)),
|
|
2886
|
-
marketplaces: control.marketplaces().map(toWireMarketplace),
|
|
2887
|
-
pendingRestart: control.pendingRestart,
|
|
2888
|
-
uploadEnabled: control.uploadEnabled,
|
|
2889
|
-
uploadOptions: control.uploadOptions,
|
|
2890
|
-
refreshEnabled: control.refreshEnabled,
|
|
2891
|
-
localInstallEnabled: control.localInstallEnabled
|
|
2892
|
-
};
|
|
2893
|
-
}
|
|
2894
|
-
function sendPreview(res, outcome) {
|
|
3016
|
+
async function reconnectMcp(ctx, res, name, lang) {
|
|
3017
|
+
const outcome = await ctx.runtime.mcp.reconnect(name);
|
|
2895
3018
|
if (!outcome.ok) {
|
|
3019
|
+
if (outcome.reason === "unknown") {
|
|
3020
|
+
return sendError(res, 404, "unknown-mcp-server", t("web.mcp_unknown_server", { name }, lang));
|
|
3021
|
+
}
|
|
3022
|
+
const status = {
|
|
3023
|
+
...outcome.status,
|
|
3024
|
+
...outcome.detail === void 0 ? {} : { lastError: outcome.detail }
|
|
3025
|
+
};
|
|
2896
3026
|
return sendJson(res, 200, {
|
|
2897
3027
|
ok: false,
|
|
2898
|
-
|
|
2899
|
-
token: null,
|
|
2900
|
-
reason: outcome.reason,
|
|
2901
|
-
detail: outcome.detail
|
|
3028
|
+
server: toWireMcp(status)
|
|
2902
3029
|
});
|
|
2903
3030
|
}
|
|
2904
3031
|
sendJson(res, 200, {
|
|
2905
3032
|
ok: true,
|
|
2906
|
-
|
|
2907
|
-
token: outcome.token,
|
|
2908
|
-
reason: null,
|
|
2909
|
-
detail: null
|
|
3033
|
+
server: toWireMcp(outcome.status)
|
|
2910
3034
|
});
|
|
2911
3035
|
}
|
|
2912
|
-
async function
|
|
2913
|
-
if (
|
|
2914
|
-
|
|
2915
|
-
|
|
3036
|
+
async function addMcp(ctx, req, res, lang) {
|
|
3037
|
+
if (ctx.lanExposed) {
|
|
3038
|
+
return sendError(
|
|
3039
|
+
res,
|
|
3040
|
+
403,
|
|
3041
|
+
"mcp-add-lan-exposed",
|
|
3042
|
+
t("web.mcp_add_lan_exposed", void 0, lang)
|
|
3043
|
+
);
|
|
3044
|
+
}
|
|
2916
3045
|
const body = await readJsonBody(req);
|
|
2917
3046
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2918
|
-
|
|
3047
|
+
const payload = asRecord(body.value);
|
|
3048
|
+
const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
|
|
3049
|
+
const transport = payload["transport"];
|
|
3050
|
+
if (transport !== "stdio" && transport !== "sse" && transport !== "http") {
|
|
3051
|
+
return sendError(res, 400, "bad-mcp-config", t("web.mcp_add_bad_transport", void 0, lang));
|
|
3052
|
+
}
|
|
3053
|
+
const outcome = await ctx.runtime.mcp.add({
|
|
3054
|
+
name,
|
|
3055
|
+
transport,
|
|
3056
|
+
...typeof payload["command"] === "string" ? { command: payload["command"].trim() } : {},
|
|
3057
|
+
...Array.isArray(payload["args"]) ? { args: stringList(payload["args"]) } : {},
|
|
3058
|
+
...typeof payload["url"] === "string" ? { url: payload["url"].trim() } : {},
|
|
3059
|
+
...payload["env"] === void 0 ? {} : { env: stringMap(payload["env"]) }
|
|
3060
|
+
});
|
|
3061
|
+
if (!outcome.ok) {
|
|
3062
|
+
return sendError(res, ...addFailure(outcome.reason, outcome.detail, name, lang));
|
|
3063
|
+
}
|
|
3064
|
+
sendJson(res, 200, {
|
|
3065
|
+
server: toWireMcp(outcome.status),
|
|
3066
|
+
configPath: outcome.configPath
|
|
3067
|
+
});
|
|
2919
3068
|
}
|
|
2920
|
-
|
|
2921
|
-
if (
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
if (
|
|
2926
|
-
|
|
3069
|
+
function addFailure(reason, detail, name, lang) {
|
|
3070
|
+
if (reason === "invalid-name") {
|
|
3071
|
+
return [400, "bad-mcp-name", t("web.mcp_add_bad_name", { name }, lang)];
|
|
3072
|
+
}
|
|
3073
|
+
if (reason === "invalid-config") return [400, "bad-mcp-config", detail];
|
|
3074
|
+
if (reason === "duplicate")
|
|
3075
|
+
return [409, "mcp-name-taken", t("web.mcp_add_taken", { name }, lang)];
|
|
3076
|
+
return [409, "mcp-config-unwritable", t("web.mcp_add_unwritable", { detail }, lang)];
|
|
2927
3077
|
}
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
const control = controlOf(ctx, res, lang);
|
|
2931
|
-
if (!control) return;
|
|
2932
|
-
const body = await readJsonBody(req);
|
|
2933
|
-
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2934
|
-
sendPreview(res, await control.previewUpdate(fieldOf(asRecord(body.value), "name"), lang));
|
|
3078
|
+
function stringList(value) {
|
|
3079
|
+
return value.filter((v) => typeof v === "string");
|
|
2935
3080
|
}
|
|
2936
|
-
function
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
entry: null,
|
|
2941
|
-
pendingRestart: control.pendingRestart,
|
|
2942
|
-
detail: outcome.detail,
|
|
2943
|
-
reason: outcome.reason
|
|
2944
|
-
});
|
|
3081
|
+
function stringMap(value) {
|
|
3082
|
+
const out = {};
|
|
3083
|
+
for (const [k, v] of Object.entries(asRecord(value))) {
|
|
3084
|
+
if (typeof v === "string") out[k] = v;
|
|
2945
3085
|
}
|
|
2946
|
-
|
|
3086
|
+
return out;
|
|
3087
|
+
}
|
|
3088
|
+
function toRef(raw) {
|
|
3089
|
+
return parseModelRef(raw);
|
|
3090
|
+
}
|
|
3091
|
+
function toWireSelection(selection) {
|
|
3092
|
+
return { provider: selection.provider, model: selection.model, origin: selection.origin };
|
|
3093
|
+
}
|
|
3094
|
+
function sessionHasImages(session) {
|
|
3095
|
+
const history = session?.getHistory() ?? [];
|
|
3096
|
+
return history.some((m) => m.parts?.some((p) => p.type === "image"));
|
|
3097
|
+
}
|
|
3098
|
+
function getModel(ctx, res, sessionId, _lang) {
|
|
3099
|
+
const { runtime } = ctx;
|
|
3100
|
+
const live = runtime.sessionFactory.get(sessionId);
|
|
3101
|
+
const selection = live?.model.get();
|
|
2947
3102
|
sendJson(res, 200, {
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
pendingRestart: control.pendingRestart,
|
|
2951
|
-
detail: "detail" in outcome ? outcome.detail : null,
|
|
2952
|
-
reason: null
|
|
3103
|
+
selection: selection ? toWireSelection(selection) : null,
|
|
3104
|
+
configured: (live ? live.model.configured() : runtime.config.model) || null
|
|
2953
3105
|
});
|
|
2954
3106
|
}
|
|
2955
|
-
async function
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
3107
|
+
async function setModel(ctx, req, res, sessionId, lang) {
|
|
3108
|
+
const live = ctx.runtime.sessionFactory.get(sessionId);
|
|
3109
|
+
if (!live) return sendUnknownSession(res, sessionId);
|
|
3110
|
+
const control = live.model;
|
|
2959
3111
|
const body = await readJsonBody(req);
|
|
2960
3112
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2961
|
-
const
|
|
2962
|
-
|
|
2963
|
-
res,
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
}
|
|
2968
|
-
async function installLocalPlugin(ctx, req, res, lang) {
|
|
2969
|
-
if (refusedByLan(ctx, res, lang)) return;
|
|
2970
|
-
const control = controlOf(ctx, res, lang);
|
|
2971
|
-
if (!control) return;
|
|
2972
|
-
const body = await readJsonBody(req);
|
|
2973
|
-
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2974
|
-
const payload = asRecord(body.value);
|
|
2975
|
-
sendAction(
|
|
2976
|
-
res,
|
|
2977
|
-
control,
|
|
2978
|
-
await control.installLocal(fieldOf(payload, "path"), fieldOf(payload, "token"), lang)
|
|
2979
|
-
);
|
|
2980
|
-
}
|
|
2981
|
-
async function stagePlugin(ctx, req, res, lang) {
|
|
2982
|
-
if (refusedByLan(ctx, res, lang)) return;
|
|
2983
|
-
const control = controlOf(ctx, res, lang);
|
|
2984
|
-
if (!control) return;
|
|
2985
|
-
const asJson = (req.headers["content-type"] ?? "").toLowerCase().includes("application/json");
|
|
2986
|
-
if (asJson) {
|
|
2987
|
-
const body = await readJsonBody(req, void 0, lang);
|
|
2988
|
-
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
2989
|
-
return sendStage(
|
|
2990
|
-
res,
|
|
2991
|
-
await control.stage({ kind: "path", path: fieldOf(asRecord(body.value), "path") }, lang)
|
|
2992
|
-
);
|
|
2993
|
-
}
|
|
2994
|
-
const raw = await readRawBody(req, PLUGIN_UPLOAD_BODY_LIMIT, lang);
|
|
2995
|
-
if (!raw.ok) return sendError(res, raw.status, "bad-body", raw.message);
|
|
2996
|
-
return sendStage(
|
|
2997
|
-
res,
|
|
2998
|
-
await control.stage({ kind: "bytes", name: nameParam2(req), data: raw.value }, lang)
|
|
2999
|
-
);
|
|
3000
|
-
}
|
|
3001
|
-
function nameParam2(req) {
|
|
3002
|
-
try {
|
|
3003
|
-
return new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("name") ?? "";
|
|
3004
|
-
} catch {
|
|
3005
|
-
return "";
|
|
3113
|
+
const input = readModelSetBody(asRecord(body.value));
|
|
3114
|
+
if (!input.ok)
|
|
3115
|
+
return sendError(res, 400, "bad-model", t("web.model_bad_request", void 0, lang));
|
|
3116
|
+
const ref = input.model === null ? null : toRef(input.model);
|
|
3117
|
+
if (ref === void 0) {
|
|
3118
|
+
return sendError(res, 400, "bad-model", t("web.model_bad_request", void 0, lang));
|
|
3006
3119
|
}
|
|
3120
|
+
const selectionCtx = {
|
|
3121
|
+
hasImages: sessionHasImages(live.session),
|
|
3122
|
+
...input.usedTokens === void 0 ? {} : { usedTokens: input.usedTokens }
|
|
3123
|
+
};
|
|
3124
|
+
const result = ref === null ? control.reset(selectionCtx) : control.set(ref, selectionCtx);
|
|
3125
|
+
const payload = result.ok ? {
|
|
3126
|
+
ok: true,
|
|
3127
|
+
selection: toWireSelection(result.selection),
|
|
3128
|
+
contextLength: result.contextLength,
|
|
3129
|
+
caveats: result.caveats
|
|
3130
|
+
} : { ok: false, rejection: result.rejection };
|
|
3131
|
+
sendJson(res, 200, payload);
|
|
3007
3132
|
}
|
|
3008
|
-
function
|
|
3009
|
-
const
|
|
3133
|
+
function readModelSetBody(body) {
|
|
3134
|
+
const { model, usedTokens } = body;
|
|
3135
|
+
if (model !== null && (typeof model !== "string" || model.trim() === "")) return { ok: false };
|
|
3136
|
+
const used = typeof usedTokens === "number" && Number.isFinite(usedTokens) && usedTokens >= 0 ? Math.floor(usedTokens) : void 0;
|
|
3137
|
+
return {
|
|
3010
3138
|
ok: true,
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
version: outcome.version,
|
|
3014
|
-
detail: null,
|
|
3015
|
-
reason: null
|
|
3016
|
-
} : {
|
|
3017
|
-
ok: false,
|
|
3018
|
-
path: null,
|
|
3019
|
-
name: null,
|
|
3020
|
-
version: null,
|
|
3021
|
-
detail: outcome.detail,
|
|
3022
|
-
reason: outcome.reason
|
|
3139
|
+
model: model === null ? null : model.trim(),
|
|
3140
|
+
...used === void 0 ? {} : { usedTokens: used }
|
|
3023
3141
|
};
|
|
3024
|
-
sendJson(res, 200, payload);
|
|
3025
3142
|
}
|
|
3026
|
-
|
|
3027
|
-
if (
|
|
3028
|
-
const
|
|
3029
|
-
if (!
|
|
3030
|
-
|
|
3031
|
-
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3032
|
-
const payload = asRecord(body.value);
|
|
3033
|
-
sendAction(
|
|
3034
|
-
res,
|
|
3035
|
-
control,
|
|
3036
|
-
await control.update(fieldOf(payload, "name"), fieldOf(payload, "token"), lang)
|
|
3037
|
-
);
|
|
3143
|
+
function planModeState(ctx, sessionId) {
|
|
3144
|
+
if (!ctx.runtime.plan) return null;
|
|
3145
|
+
const plan = ctx.runtime.sessionFactory.get(sessionId)?.plan;
|
|
3146
|
+
if (!plan) return { active: false, from: null };
|
|
3147
|
+
return { active: plan.active(), from: plan.from() };
|
|
3038
3148
|
}
|
|
3039
|
-
async function
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3149
|
+
async function setPlanMode(ctx, req, res, sessionId, lang) {
|
|
3150
|
+
const live = ctx.runtime.sessionFactory.get(sessionId);
|
|
3151
|
+
if (!live) return sendUnknownSession(res, sessionId);
|
|
3152
|
+
const plan = live.plan;
|
|
3153
|
+
if (!plan) {
|
|
3154
|
+
return sendError(res, 503, "no-plan-mode", t("web.plan_mode_unavailable", void 0, lang));
|
|
3155
|
+
}
|
|
3043
3156
|
const body = await readJsonBody(req);
|
|
3044
3157
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3158
|
+
const action = asRecord(body.value)["action"];
|
|
3159
|
+
if (typeof action !== "string" || !isWirePlanAction(action)) {
|
|
3160
|
+
return sendError(
|
|
3161
|
+
res,
|
|
3162
|
+
400,
|
|
3163
|
+
"bad-action",
|
|
3164
|
+
t("web.plan_mode_bad_action", { actions: WIRE_PLAN_ACTIONS.join("/") }, lang)
|
|
3165
|
+
);
|
|
3166
|
+
}
|
|
3167
|
+
const changed = action === "enter" ? plan.enter().ok : plan.leave() !== null;
|
|
3048
3168
|
sendJson(res, 200, {
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
reason: outcome.ok ? null : outcome.reason,
|
|
3052
|
-
detail: outcome.ok ? null : outcome.detail
|
|
3169
|
+
mode: { active: plan.active(), from: plan.from() },
|
|
3170
|
+
changed
|
|
3053
3171
|
});
|
|
3054
3172
|
}
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
sendMarket(res, await control.addMarketplace(fieldOf(asRecord(body.value), "source"), lang));
|
|
3173
|
+
// src/permission.ts
|
|
3174
|
+
var REFUSAL = {
|
|
3175
|
+
"managed-bypass-disabled": "managed"
|
|
3176
|
+
};
|
|
3177
|
+
function blockedLevels(managed) {
|
|
3178
|
+
return managed.bypassDisabled ? [{ level: "bypass", reason: "managed" }] : [];
|
|
3062
3179
|
}
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3180
|
+
function permissionState(ctx, sessionId) {
|
|
3181
|
+
const perms = ctx.runtime.sessionFactory.get(sessionId)?.permissions ?? null;
|
|
3182
|
+
return {
|
|
3183
|
+
level: perms?.level() ?? ctx.runtime.config.permission,
|
|
3184
|
+
editable: perms !== null,
|
|
3185
|
+
blocked: perms ? blockedLevels(perms.managed()) : [],
|
|
3186
|
+
planMode: planModeState(ctx, sessionId)
|
|
3187
|
+
};
|
|
3070
3188
|
}
|
|
3071
|
-
function
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
});
|
|
3189
|
+
function getPermission(ctx, res, sessionId, _lang) {
|
|
3190
|
+
if (!ctx.hub.has(sessionId)) return sendUnknownSession(res, sessionId);
|
|
3191
|
+
sendJson(res, 200, permissionState(ctx, sessionId));
|
|
3075
3192
|
}
|
|
3076
|
-
function
|
|
3077
|
-
|
|
3078
|
-
|
|
3193
|
+
async function setPermission(ctx, req, res, sessionId, lang) {
|
|
3194
|
+
const live = ctx.runtime.sessionFactory.get(sessionId);
|
|
3195
|
+
if (!live) return sendUnknownSession(res, sessionId);
|
|
3196
|
+
const perms = live.permissions;
|
|
3197
|
+
if (!perms) {
|
|
3198
|
+
return sendError(
|
|
3079
3199
|
res,
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
uploadFormOf(payload),
|
|
3084
|
-
lang
|
|
3085
|
-
)
|
|
3200
|
+
503,
|
|
3201
|
+
"no-permission-layer",
|
|
3202
|
+
t("web.permission_unavailable", void 0, lang)
|
|
3086
3203
|
);
|
|
3087
|
-
}
|
|
3088
|
-
}
|
|
3089
|
-
async function withUploadBody(ctx, req, res, lang, run) {
|
|
3090
|
-
if (refusedByLan(ctx, res, lang)) return;
|
|
3091
|
-
const control = controlOf(ctx, res, lang);
|
|
3092
|
-
if (!control) return;
|
|
3204
|
+
}
|
|
3093
3205
|
const body = await readJsonBody(req);
|
|
3094
3206
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
prefill: null,
|
|
3104
|
-
fromManifest: null,
|
|
3105
|
-
reason: outcome.reason,
|
|
3106
|
-
detail: outcome.detail
|
|
3107
|
-
});
|
|
3207
|
+
const level = asRecord(body.value)["level"];
|
|
3208
|
+
if (typeof level !== "string" || !isPermissionLevel(level)) {
|
|
3209
|
+
return sendError(
|
|
3210
|
+
res,
|
|
3211
|
+
400,
|
|
3212
|
+
"bad-level",
|
|
3213
|
+
t("web.permission_bad_level", { levels: PERMISSION_LEVELS.join("/") }, lang)
|
|
3214
|
+
);
|
|
3108
3215
|
}
|
|
3109
|
-
const
|
|
3110
|
-
|
|
3216
|
+
const before = perms.level();
|
|
3217
|
+
const result = perms.setLevel(level, { remember: true });
|
|
3218
|
+
const state = permissionState(ctx, sessionId);
|
|
3219
|
+
const payload = result.ok ? {
|
|
3111
3220
|
ok: true,
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
files: files.map((f) => ({ path: f.path, bytes: f.bytes })),
|
|
3118
|
-
totalBytes,
|
|
3119
|
-
suspicious: [...suspicious],
|
|
3120
|
-
skippedLinks: [...skippedLinks]
|
|
3121
|
-
},
|
|
3122
|
-
token: outcome.token,
|
|
3123
|
-
prefill: outcome.prefill,
|
|
3124
|
-
fromManifest: [...outcome.fromManifest],
|
|
3125
|
-
reason: null,
|
|
3126
|
-
detail: null
|
|
3127
|
-
});
|
|
3221
|
+
changed: state.level !== before,
|
|
3222
|
+
state,
|
|
3223
|
+
...result.notRemembered ? { notRemembered: result.notRemembered } : {}
|
|
3224
|
+
} : { ok: false, refused: REFUSAL[result.reason], changed: false, state };
|
|
3225
|
+
sendJson(res, 200, payload);
|
|
3128
3226
|
}
|
|
3129
|
-
|
|
3227
|
+
// src/plan.ts
|
|
3228
|
+
function getPlan(ctx, res, sessionId, _lang) {
|
|
3229
|
+
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
3230
|
+
return sendUnknownSession(res, sessionId);
|
|
3231
|
+
}
|
|
3232
|
+
const live = ctx.runtime.sessionFactory.get(sessionId);
|
|
3233
|
+
const markdown = live ? live.plan?.approvedPlan() ?? null : ctx.runtime.sessionStore?.loadPlan(sessionId) ?? null;
|
|
3130
3234
|
sendJson(res, 200, {
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
uploaded: outcome.ok ? { name: outcome.name, version: outcome.version, bytes: outcome.bytes } : null,
|
|
3134
|
-
reason: outcome.ok ? null : outcome.reason
|
|
3235
|
+
plan: normalize(markdown),
|
|
3236
|
+
mode: planModeState(ctx, sessionId)
|
|
3135
3237
|
});
|
|
3136
3238
|
}
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
const control = controlOf(ctx, res, lang);
|
|
3140
|
-
if (!control) return;
|
|
3141
|
-
const outcome = await control.refresh(lang);
|
|
3142
|
-
if (!outcome.ok) {
|
|
3143
|
-
return sendJson(res, 200, {
|
|
3144
|
-
ok: false,
|
|
3145
|
-
plugins: null,
|
|
3146
|
-
reason: outcome.reason,
|
|
3147
|
-
detail: outcome.detail
|
|
3148
|
-
});
|
|
3149
|
-
}
|
|
3150
|
-
sendJson(res, 200, {
|
|
3151
|
-
ok: true,
|
|
3152
|
-
plugins: pluginsPayload(control),
|
|
3153
|
-
reason: null,
|
|
3154
|
-
detail: outcome.detail === "" ? null : outcome.detail
|
|
3155
|
-
});
|
|
3239
|
+
function normalize(markdown) {
|
|
3240
|
+
return markdown?.trim() ? markdown : null;
|
|
3156
3241
|
}
|
|
3157
|
-
function
|
|
3242
|
+
function toWireCounts(counts) {
|
|
3158
3243
|
return {
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3244
|
+
commands: counts.commands,
|
|
3245
|
+
roles: counts.roles,
|
|
3246
|
+
skills: counts.skills,
|
|
3247
|
+
hooks: counts.hooks,
|
|
3248
|
+
denyRules: counts.denyRules,
|
|
3249
|
+
mcpServers: counts.mcpServers
|
|
3165
3250
|
};
|
|
3166
3251
|
}
|
|
3167
|
-
function
|
|
3252
|
+
function displayNamesOf(control) {
|
|
3253
|
+
const out = /* @__PURE__ */ new Map();
|
|
3254
|
+
for (const hit of control.search()) {
|
|
3255
|
+
const shown = hit.entry.displayName;
|
|
3256
|
+
if (shown !== void 0 && shown !== "") out.set(`${hit.marketplace}/${hit.entry.name}`, shown);
|
|
3257
|
+
}
|
|
3258
|
+
return out;
|
|
3259
|
+
}
|
|
3260
|
+
function toWirePluginEntry(entry, displayNames) {
|
|
3168
3261
|
return {
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3262
|
+
name: entry.name,
|
|
3263
|
+
displayName: displayNames.get(`${entry.marketplace}/${entry.name}`) ?? null,
|
|
3264
|
+
version: entry.version,
|
|
3265
|
+
source: entry.source,
|
|
3266
|
+
sourceType: entry.sourceType,
|
|
3267
|
+
path: entry.path,
|
|
3268
|
+
linked: entry.linked,
|
|
3269
|
+
enabled: entry.enabled,
|
|
3270
|
+
installedAt: entry.installedAt,
|
|
3271
|
+
marketplace: entry.marketplace ?? null,
|
|
3272
|
+
active: entry.active,
|
|
3273
|
+
counts: entry.counts ? toWireCounts(entry.counts) : null,
|
|
3274
|
+
updateAllowed: entry.updateAllowed,
|
|
3275
|
+
uninstallAllowed: entry.uninstallAllowed
|
|
3173
3276
|
};
|
|
3174
3277
|
}
|
|
3175
|
-
function
|
|
3278
|
+
function toWirePluginHit(hit, installed) {
|
|
3279
|
+
const mine = installed.get(hit.entry.name);
|
|
3280
|
+
return {
|
|
3281
|
+
marketplace: hit.marketplace,
|
|
3282
|
+
ref: hit.ref,
|
|
3283
|
+
name: hit.entry.name,
|
|
3284
|
+
description: hit.entry.description ?? "",
|
|
3285
|
+
categories: hit.entry.category ?? null,
|
|
3286
|
+
keywords: [...hit.entry.keywords ?? []],
|
|
3287
|
+
sourceType: hit.sourceType ?? null,
|
|
3288
|
+
installable: hit.installable,
|
|
3289
|
+
installed: mine !== void 0,
|
|
3290
|
+
author: hit.entry.author?.name ?? null,
|
|
3291
|
+
downloads: hit.entry.downloads ?? null,
|
|
3292
|
+
likes: hit.entry.likes ?? null,
|
|
3293
|
+
displayName: hit.entry.displayName ?? null,
|
|
3294
|
+
icon: hit.entry.icon ?? null,
|
|
3295
|
+
version: hit.entry.version ?? null,
|
|
3296
|
+
changelog: hit.entry.changelog ?? null,
|
|
3297
|
+
size: hit.entry.size ?? null,
|
|
3298
|
+
homepage: hit.entry.homepage ?? null,
|
|
3299
|
+
visibility: hit.entry.visibility ?? null,
|
|
3300
|
+
status: hit.entry.status ?? null,
|
|
3301
|
+
updateAvailable: mine !== void 0 && !mine.linked && hit.entry.version !== void 0 && hit.entry.version !== mine.version,
|
|
3302
|
+
installAllowed: hit.installAllowed,
|
|
3303
|
+
updateAllowed: mine?.updateAllowed ?? false,
|
|
3304
|
+
menu: hit.menu.map((item) => ({
|
|
3305
|
+
id: item.id,
|
|
3306
|
+
label: item.label,
|
|
3307
|
+
danger: item.danger === true
|
|
3308
|
+
}))
|
|
3309
|
+
};
|
|
3310
|
+
}
|
|
3311
|
+
function toWireMarketplace(market) {
|
|
3312
|
+
return {
|
|
3313
|
+
name: market.name,
|
|
3314
|
+
source: market.source,
|
|
3315
|
+
entries: market.entries,
|
|
3316
|
+
addedAt: market.addedAt,
|
|
3317
|
+
local: market.local,
|
|
3318
|
+
categories: (market.categories ?? []).map(toWireCategory)
|
|
3319
|
+
};
|
|
3320
|
+
}
|
|
3321
|
+
function toWireCategory(node) {
|
|
3322
|
+
return {
|
|
3323
|
+
id: node.id,
|
|
3324
|
+
label: node.label ?? node.id,
|
|
3325
|
+
children: (node.children ?? []).map(toWireCategory)
|
|
3326
|
+
};
|
|
3327
|
+
}
|
|
3328
|
+
function toWirePreview2(preview) {
|
|
3329
|
+
const { manifest, source, inventory } = preview;
|
|
3330
|
+
return {
|
|
3331
|
+
name: manifest.name,
|
|
3332
|
+
version: manifest.version,
|
|
3333
|
+
description: manifest.description ?? "",
|
|
3334
|
+
source: source.raw,
|
|
3335
|
+
sourceType: source.type,
|
|
3336
|
+
checksum: source.sha256 ?? null,
|
|
3337
|
+
author: manifest.author?.name ?? null,
|
|
3338
|
+
homepage: manifest.homepage ?? null,
|
|
3339
|
+
commands: [...inventory.commands],
|
|
3340
|
+
roles: [...inventory.roles],
|
|
3341
|
+
skills: [...inventory.skills],
|
|
3342
|
+
hooks: inventory.hooks.map((h) => ({ type: h.type, count: h.count })),
|
|
3343
|
+
denyRules: inventory.denyRules,
|
|
3344
|
+
mcpServers: [...inventory.mcpServers],
|
|
3345
|
+
ignoredBuckets: [...inventory.ignoredBuckets],
|
|
3346
|
+
hasJsTools: inventory.jsTools,
|
|
3347
|
+
conflict: preview.conflict ? { name: preview.conflict.name, version: preview.conflict.version } : null
|
|
3348
|
+
};
|
|
3349
|
+
}
|
|
3350
|
+
function controlOf(ctx, res, lang) {
|
|
3351
|
+
const control = ctx.runtime.plugins;
|
|
3352
|
+
if (!control) {
|
|
3353
|
+
sendError(res, 503, "no-plugin-control", t("web.plugin_not_available", void 0, lang));
|
|
3354
|
+
return null;
|
|
3355
|
+
}
|
|
3356
|
+
return control;
|
|
3357
|
+
}
|
|
3358
|
+
function refusedByLan(ctx, res, lang) {
|
|
3359
|
+
if (!ctx.lanExposed) return false;
|
|
3360
|
+
sendError(
|
|
3361
|
+
res,
|
|
3362
|
+
403,
|
|
3363
|
+
"plugin-write-lan-exposed",
|
|
3364
|
+
t("web.plugin_write_lan_exposed", void 0, lang)
|
|
3365
|
+
);
|
|
3366
|
+
return true;
|
|
3367
|
+
}
|
|
3368
|
+
function fieldOf(payload, key) {
|
|
3369
|
+
const value = payload[key];
|
|
3370
|
+
return typeof value === "string" ? value.trim() : "";
|
|
3371
|
+
}
|
|
3372
|
+
function uploadFormOf(payload) {
|
|
3373
|
+
const form = {};
|
|
3374
|
+
for (const name of WIRE_PLUGIN_UPLOAD_FORM_FIELDS) {
|
|
3375
|
+
const raw = fieldOf(payload, name);
|
|
3376
|
+
if (raw === "") continue;
|
|
3377
|
+
if (name === "visibility") {
|
|
3378
|
+
if (raw === "private" || raw === "public") form[name] = raw;
|
|
3379
|
+
continue;
|
|
3380
|
+
}
|
|
3381
|
+
form[name] = raw;
|
|
3382
|
+
}
|
|
3383
|
+
return form;
|
|
3384
|
+
}
|
|
3385
|
+
function listPlugins(ctx, res, lang) {
|
|
3386
|
+
const control = controlOf(ctx, res, lang);
|
|
3387
|
+
if (!control) return;
|
|
3388
|
+
sendJson(res, 200, pluginsPayload(control));
|
|
3389
|
+
}
|
|
3390
|
+
function pluginsPayload(control) {
|
|
3391
|
+
const installed = control.list();
|
|
3392
|
+
const installedMap = new Map(
|
|
3393
|
+
installed.map((p) => [
|
|
3394
|
+
p.name,
|
|
3395
|
+
{ version: p.version, linked: p.linked, updateAllowed: p.updateAllowed }
|
|
3396
|
+
])
|
|
3397
|
+
);
|
|
3398
|
+
const shown = displayNamesOf(control);
|
|
3399
|
+
return {
|
|
3400
|
+
installed: installed.map((p) => toWirePluginEntry(p, shown)),
|
|
3401
|
+
hits: control.search().map((hit) => toWirePluginHit(hit, installedMap)),
|
|
3402
|
+
marketplaces: control.marketplaces().map(toWireMarketplace),
|
|
3403
|
+
pendingRestart: control.pendingRestart,
|
|
3404
|
+
uploadEnabled: control.uploadEnabled,
|
|
3405
|
+
uploadOptions: control.uploadOptions,
|
|
3406
|
+
refreshEnabled: control.refreshEnabled,
|
|
3407
|
+
localInstallEnabled: control.localInstallEnabled
|
|
3408
|
+
};
|
|
3409
|
+
}
|
|
3410
|
+
function sendPreview(res, outcome) {
|
|
3411
|
+
if (!outcome.ok) {
|
|
3412
|
+
return sendJson(res, 200, {
|
|
3413
|
+
ok: false,
|
|
3414
|
+
preview: null,
|
|
3415
|
+
token: null,
|
|
3416
|
+
reason: outcome.reason,
|
|
3417
|
+
detail: outcome.detail
|
|
3418
|
+
});
|
|
3419
|
+
}
|
|
3176
3420
|
sendJson(res, 200, {
|
|
3177
|
-
|
|
3421
|
+
ok: true,
|
|
3422
|
+
preview: toWirePreview2(outcome.preview),
|
|
3423
|
+
token: outcome.token,
|
|
3424
|
+
reason: null,
|
|
3425
|
+
detail: null
|
|
3178
3426
|
});
|
|
3179
3427
|
}
|
|
3180
|
-
async function
|
|
3181
|
-
if (
|
|
3182
|
-
|
|
3183
|
-
|
|
3428
|
+
async function previewPluginInstall(ctx, req, res, lang) {
|
|
3429
|
+
if (refusedByLan(ctx, res, lang)) return;
|
|
3430
|
+
const control = controlOf(ctx, res, lang);
|
|
3431
|
+
if (!control) return;
|
|
3184
3432
|
const body = await readJsonBody(req);
|
|
3185
3433
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3186
|
-
|
|
3187
|
-
const suggestions = await ctx.runtime.modelCatalog.discover(type, { refresh });
|
|
3188
|
-
sendJson(res, 200, {
|
|
3189
|
-
suggestions: toWireSuggestions(suggestions)
|
|
3190
|
-
});
|
|
3434
|
+
sendPreview(res, await control.preview(fieldOf(asRecord(body.value), "ref"), lang));
|
|
3191
3435
|
}
|
|
3192
|
-
async function
|
|
3193
|
-
if (
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
const envVar = apiKeyEnvVar(type);
|
|
3197
|
-
if (envVar === null) {
|
|
3198
|
-
return sendError(
|
|
3199
|
-
res,
|
|
3200
|
-
400,
|
|
3201
|
-
"key-not-applicable",
|
|
3202
|
-
t("web.provider_key_not_applicable", { name: type }, lang)
|
|
3203
|
-
);
|
|
3204
|
-
}
|
|
3436
|
+
async function previewLocalPluginInstall(ctx, req, res, lang) {
|
|
3437
|
+
if (refusedByLan(ctx, res, lang)) return;
|
|
3438
|
+
const control = controlOf(ctx, res, lang);
|
|
3439
|
+
if (!control) return;
|
|
3205
3440
|
const body = await readJsonBody(req);
|
|
3206
3441
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3442
|
+
sendPreview(res, await control.previewLocal(fieldOf(asRecord(body.value), "path"), lang));
|
|
3443
|
+
}
|
|
3444
|
+
async function previewPluginUpdate(ctx, req, res, lang) {
|
|
3445
|
+
if (refusedByLan(ctx, res, lang)) return;
|
|
3446
|
+
const control = controlOf(ctx, res, lang);
|
|
3447
|
+
if (!control) return;
|
|
3448
|
+
const body = await readJsonBody(req);
|
|
3449
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3450
|
+
sendPreview(res, await control.previewUpdate(fieldOf(asRecord(body.value), "name"), lang));
|
|
3451
|
+
}
|
|
3452
|
+
function sendAction(res, control, outcome) {
|
|
3453
|
+
if (!outcome.ok) {
|
|
3454
|
+
return sendJson(res, 200, {
|
|
3455
|
+
ok: false,
|
|
3456
|
+
entry: null,
|
|
3457
|
+
pendingRestart: control.pendingRestart,
|
|
3458
|
+
detail: outcome.detail,
|
|
3459
|
+
reason: outcome.reason
|
|
3460
|
+
});
|
|
3211
3461
|
}
|
|
3212
|
-
const
|
|
3462
|
+
const fresh = "record" in outcome ? control.list().find((p) => p.name === outcome.record.name) : void 0;
|
|
3213
3463
|
sendJson(res, 200, {
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
envPath: written.envPath
|
|
3464
|
+
ok: true,
|
|
3465
|
+
entry: fresh ? toWirePluginEntry(fresh, displayNamesOf(control)) : null,
|
|
3466
|
+
pendingRestart: control.pendingRestart,
|
|
3467
|
+
detail: "detail" in outcome ? outcome.detail : null,
|
|
3468
|
+
reason: null
|
|
3220
3469
|
});
|
|
3221
3470
|
}
|
|
3222
|
-
async function
|
|
3223
|
-
if (ctx
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
"provider-available",
|
|
3228
|
-
t("web.model_default_has_provider", void 0, lang)
|
|
3229
|
-
);
|
|
3230
|
-
}
|
|
3231
|
-
const body = await readJsonBody(req, void 0, lang);
|
|
3471
|
+
async function installPluginFromMarket(ctx, req, res, lang) {
|
|
3472
|
+
if (refusedByLan(ctx, res, lang)) return;
|
|
3473
|
+
const control = controlOf(ctx, res, lang);
|
|
3474
|
+
if (!control) return;
|
|
3475
|
+
const body = await readJsonBody(req);
|
|
3232
3476
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3233
|
-
const
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3477
|
+
const payload = asRecord(body.value);
|
|
3478
|
+
sendAction(
|
|
3479
|
+
res,
|
|
3480
|
+
control,
|
|
3481
|
+
await control.install(fieldOf(payload, "ref"), fieldOf(payload, "token"), lang)
|
|
3482
|
+
);
|
|
3483
|
+
}
|
|
3484
|
+
async function installLocalPlugin(ctx, req, res, lang) {
|
|
3485
|
+
if (refusedByLan(ctx, res, lang)) return;
|
|
3486
|
+
const control = controlOf(ctx, res, lang);
|
|
3487
|
+
if (!control) return;
|
|
3488
|
+
const body = await readJsonBody(req);
|
|
3489
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3490
|
+
const payload = asRecord(body.value);
|
|
3491
|
+
sendAction(
|
|
3492
|
+
res,
|
|
3493
|
+
control,
|
|
3494
|
+
await control.installLocal(fieldOf(payload, "path"), fieldOf(payload, "token"), lang)
|
|
3495
|
+
);
|
|
3496
|
+
}
|
|
3497
|
+
async function stagePlugin(ctx, req, res, lang) {
|
|
3498
|
+
if (refusedByLan(ctx, res, lang)) return;
|
|
3499
|
+
const control = controlOf(ctx, res, lang);
|
|
3500
|
+
if (!control) return;
|
|
3501
|
+
const asJson = (req.headers["content-type"] ?? "").toLowerCase().includes("application/json");
|
|
3502
|
+
if (asJson) {
|
|
3503
|
+
const body = await readJsonBody(req, void 0, lang);
|
|
3504
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3505
|
+
return sendStage(
|
|
3242
3506
|
res,
|
|
3243
|
-
|
|
3244
|
-
failure.code,
|
|
3245
|
-
t(failure.key, { path: failure.path }, lang)
|
|
3507
|
+
await control.stage({ kind: "path", path: fieldOf(asRecord(body.value), "path") }, lang)
|
|
3246
3508
|
);
|
|
3247
3509
|
}
|
|
3248
|
-
|
|
3510
|
+
const raw = await readRawBody(req, PLUGIN_UPLOAD_BODY_LIMIT, lang);
|
|
3511
|
+
if (!raw.ok) return sendError(res, raw.status, "bad-body", raw.message);
|
|
3512
|
+
return sendStage(
|
|
3513
|
+
res,
|
|
3514
|
+
await control.stage({ kind: "bytes", name: nameParam2(req), data: raw.value }, lang)
|
|
3515
|
+
);
|
|
3249
3516
|
}
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
return
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
"role-add-lan-exposed",
|
|
3256
|
-
t("web.role_add_lan_exposed", void 0, lang)
|
|
3257
|
-
);
|
|
3517
|
+
function nameParam2(req) {
|
|
3518
|
+
try {
|
|
3519
|
+
return new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("name") ?? "";
|
|
3520
|
+
} catch {
|
|
3521
|
+
return "";
|
|
3258
3522
|
}
|
|
3523
|
+
}
|
|
3524
|
+
function sendStage(res, outcome) {
|
|
3525
|
+
const payload = outcome.ok ? {
|
|
3526
|
+
ok: true,
|
|
3527
|
+
path: outcome.path,
|
|
3528
|
+
name: outcome.name,
|
|
3529
|
+
version: outcome.version,
|
|
3530
|
+
detail: null,
|
|
3531
|
+
reason: null
|
|
3532
|
+
} : {
|
|
3533
|
+
ok: false,
|
|
3534
|
+
path: null,
|
|
3535
|
+
name: null,
|
|
3536
|
+
version: null,
|
|
3537
|
+
detail: outcome.detail,
|
|
3538
|
+
reason: outcome.reason
|
|
3539
|
+
};
|
|
3540
|
+
sendJson(res, 200, payload);
|
|
3541
|
+
}
|
|
3542
|
+
async function updateInstalledPlugin(ctx, req, res, lang) {
|
|
3543
|
+
if (refusedByLan(ctx, res, lang)) return;
|
|
3544
|
+
const control = controlOf(ctx, res, lang);
|
|
3545
|
+
if (!control) return;
|
|
3259
3546
|
const body = await readJsonBody(req);
|
|
3260
3547
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3261
3548
|
const payload = asRecord(body.value);
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
...typeof payload["prompt"] === "string" ? { prompt: payload["prompt"] } : {},
|
|
3267
|
-
...Array.isArray(payload["tools"]) ? { tools: stringList2(payload["tools"]) } : {},
|
|
3268
|
-
...Array.isArray(payload["skills"]) ? { skills: stringList2(payload["skills"]) } : {},
|
|
3269
|
-
...typeof payload["maxTurns"] === "number" ? { maxTurns: payload["maxTurns"] } : {}
|
|
3270
|
-
},
|
|
3271
|
-
lang
|
|
3549
|
+
sendAction(
|
|
3550
|
+
res,
|
|
3551
|
+
control,
|
|
3552
|
+
await control.update(fieldOf(payload, "name"), fieldOf(payload, "token"), lang)
|
|
3272
3553
|
);
|
|
3273
|
-
|
|
3554
|
+
}
|
|
3555
|
+
async function uninstallPluginByName(ctx, req, res, lang) {
|
|
3556
|
+
if (refusedByLan(ctx, res, lang)) return;
|
|
3557
|
+
const control = controlOf(ctx, res, lang);
|
|
3558
|
+
if (!control) return;
|
|
3559
|
+
const body = await readJsonBody(req);
|
|
3560
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3561
|
+
sendAction(res, control, await control.uninstall(fieldOf(asRecord(body.value), "name"), lang));
|
|
3562
|
+
}
|
|
3563
|
+
function sendMarket(res, outcome) {
|
|
3274
3564
|
sendJson(res, 200, {
|
|
3275
|
-
|
|
3276
|
-
|
|
3565
|
+
ok: outcome.ok,
|
|
3566
|
+
marketplace: outcome.ok && outcome.marketplace ? toWireMarketplace(outcome.marketplace) : null,
|
|
3567
|
+
reason: outcome.ok ? null : outcome.reason,
|
|
3568
|
+
detail: outcome.ok ? null : outcome.detail
|
|
3277
3569
|
});
|
|
3278
3570
|
}
|
|
3279
|
-
function
|
|
3280
|
-
if (
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
if (
|
|
3285
|
-
|
|
3571
|
+
async function addPluginMarketplace(ctx, req, res, lang) {
|
|
3572
|
+
if (refusedByLan(ctx, res, lang)) return;
|
|
3573
|
+
const control = controlOf(ctx, res, lang);
|
|
3574
|
+
if (!control) return;
|
|
3575
|
+
const body = await readJsonBody(req);
|
|
3576
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3577
|
+
sendMarket(res, await control.addMarketplace(fieldOf(asRecord(body.value), "source"), lang));
|
|
3286
3578
|
}
|
|
3287
|
-
function
|
|
3288
|
-
|
|
3579
|
+
async function removePluginMarketplace(ctx, req, res, lang) {
|
|
3580
|
+
if (refusedByLan(ctx, res, lang)) return;
|
|
3581
|
+
const control = controlOf(ctx, res, lang);
|
|
3582
|
+
if (!control) return;
|
|
3583
|
+
const body = await readJsonBody(req);
|
|
3584
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3585
|
+
sendMarket(res, await control.removeMarketplace(fieldOf(asRecord(body.value), "name"), lang));
|
|
3289
3586
|
}
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3587
|
+
function previewPluginUpload(ctx, req, res, lang) {
|
|
3588
|
+
return withUploadBody(ctx, req, res, lang, (control, payload) => {
|
|
3589
|
+
sendUploadPreview(res, control.previewUpload(fieldOf(payload, "path"), lang));
|
|
3590
|
+
});
|
|
3591
|
+
}
|
|
3592
|
+
function uploadPlugin(ctx, req, res, lang) {
|
|
3593
|
+
return withUploadBody(ctx, req, res, lang, async (control, payload) => {
|
|
3594
|
+
sendUpload(
|
|
3293
3595
|
res,
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3596
|
+
await control.upload(
|
|
3597
|
+
fieldOf(payload, "path"),
|
|
3598
|
+
fieldOf(payload, "token"),
|
|
3599
|
+
uploadFormOf(payload),
|
|
3600
|
+
lang
|
|
3601
|
+
)
|
|
3297
3602
|
);
|
|
3298
|
-
}
|
|
3603
|
+
});
|
|
3604
|
+
}
|
|
3605
|
+
async function withUploadBody(ctx, req, res, lang, run) {
|
|
3606
|
+
if (refusedByLan(ctx, res, lang)) return;
|
|
3607
|
+
const control = controlOf(ctx, res, lang);
|
|
3608
|
+
if (!control) return;
|
|
3299
3609
|
const body = await readJsonBody(req);
|
|
3300
3610
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3611
|
+
await run(control, asRecord(body.value));
|
|
3612
|
+
}
|
|
3613
|
+
function sendUploadPreview(res, outcome) {
|
|
3304
3614
|
if (!outcome.ok) {
|
|
3305
3615
|
return sendJson(res, 200, {
|
|
3306
3616
|
ok: false,
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
reason: outcome.reason
|
|
3617
|
+
plan: null,
|
|
3618
|
+
token: null,
|
|
3619
|
+
prefill: null,
|
|
3620
|
+
fromManifest: null,
|
|
3621
|
+
reason: outcome.reason,
|
|
3622
|
+
detail: outcome.detail
|
|
3312
3623
|
});
|
|
3313
3624
|
}
|
|
3625
|
+
const { manifest, files, totalBytes, suspicious, skippedLinks } = outcome.plan;
|
|
3314
3626
|
sendJson(res, 200, {
|
|
3315
3627
|
ok: true,
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3628
|
+
plan: {
|
|
3629
|
+
name: manifest.name,
|
|
3630
|
+
version: manifest.version,
|
|
3631
|
+
description: manifest.description ?? "",
|
|
3632
|
+
author: manifest.author?.name ?? null,
|
|
3633
|
+
files: files.map((f) => ({ path: f.path, bytes: f.bytes })),
|
|
3634
|
+
totalBytes,
|
|
3635
|
+
suspicious: [...suspicious],
|
|
3636
|
+
skippedLinks: [...skippedLinks]
|
|
3637
|
+
},
|
|
3638
|
+
token: outcome.token,
|
|
3639
|
+
prefill: outcome.prefill,
|
|
3640
|
+
fromManifest: [...outcome.fromManifest],
|
|
3641
|
+
reason: null,
|
|
3642
|
+
detail: null
|
|
3321
3643
|
});
|
|
3322
3644
|
}
|
|
3323
|
-
function
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
}
|
|
3330
|
-
return out;
|
|
3331
|
-
}
|
|
3332
|
-
function operationList(value) {
|
|
3333
|
-
if (!Array.isArray(value)) return null;
|
|
3334
|
-
const out = [];
|
|
3335
|
-
for (const item of value) {
|
|
3336
|
-
if (typeof item !== "string" || !isOperationType(item)) return null;
|
|
3337
|
-
out.push(item);
|
|
3338
|
-
}
|
|
3339
|
-
return out;
|
|
3340
|
-
}
|
|
3341
|
-
function finite(value) {
|
|
3342
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
3645
|
+
function sendUpload(res, outcome) {
|
|
3646
|
+
sendJson(res, 200, {
|
|
3647
|
+
ok: outcome.ok,
|
|
3648
|
+
detail: outcome.detail,
|
|
3649
|
+
uploaded: outcome.ok ? { name: outcome.name, version: outcome.version, bytes: outcome.bytes } : null,
|
|
3650
|
+
reason: outcome.ok ? null : outcome.reason
|
|
3651
|
+
});
|
|
3343
3652
|
}
|
|
3344
|
-
function
|
|
3653
|
+
async function refreshPlugins(ctx, res, lang) {
|
|
3654
|
+
if (refusedByLan(ctx, res, lang)) return;
|
|
3655
|
+
const control = controlOf(ctx, res, lang);
|
|
3656
|
+
if (!control) return;
|
|
3657
|
+
const outcome = await control.refresh(lang);
|
|
3658
|
+
if (!outcome.ok) {
|
|
3659
|
+
return sendJson(res, 200, {
|
|
3660
|
+
ok: false,
|
|
3661
|
+
plugins: null,
|
|
3662
|
+
reason: outcome.reason,
|
|
3663
|
+
detail: outcome.detail
|
|
3664
|
+
});
|
|
3665
|
+
}
|
|
3666
|
+
sendJson(res, 200, {
|
|
3667
|
+
ok: true,
|
|
3668
|
+
plugins: pluginsPayload(control),
|
|
3669
|
+
reason: null,
|
|
3670
|
+
detail: outcome.detail === "" ? null : outcome.detail
|
|
3671
|
+
});
|
|
3672
|
+
}
|
|
3673
|
+
function toWireProvider(option, lang) {
|
|
3674
|
+
return {
|
|
3675
|
+
type: option.type,
|
|
3676
|
+
label: providerLabel(option, lang),
|
|
3677
|
+
envVar: option.envVar,
|
|
3678
|
+
hasKey: option.hasKey,
|
|
3679
|
+
keyHint: option.keyHint,
|
|
3680
|
+
current: option.current
|
|
3681
|
+
};
|
|
3682
|
+
}
|
|
3683
|
+
function toWireSuggestions(view) {
|
|
3684
|
+
return {
|
|
3685
|
+
provider: view.provider,
|
|
3686
|
+
models: view.models,
|
|
3687
|
+
source: view.source,
|
|
3688
|
+
status: view.status
|
|
3689
|
+
};
|
|
3690
|
+
}
|
|
3691
|
+
function listProviders(ctx, res, lang) {
|
|
3692
|
+
sendJson(res, 200, {
|
|
3693
|
+
providers: ctx.runtime.modelCatalog.providers().map((o) => toWireProvider(o, lang))
|
|
3694
|
+
});
|
|
3695
|
+
}
|
|
3696
|
+
async function discoverProviderModels(ctx, req, res, type, lang) {
|
|
3697
|
+
if (!isProviderType(type)) {
|
|
3698
|
+
return sendError(res, 404, "unknown-provider", t("web.provider_unknown", { name: type }, lang));
|
|
3699
|
+
}
|
|
3700
|
+
const body = await readJsonBody(req);
|
|
3701
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3702
|
+
const refresh = asRecord(body.value).refresh === true;
|
|
3703
|
+
const suggestions = await ctx.runtime.modelCatalog.discover(type, { refresh });
|
|
3704
|
+
sendJson(res, 200, {
|
|
3705
|
+
suggestions: toWireSuggestions(suggestions)
|
|
3706
|
+
});
|
|
3707
|
+
}
|
|
3708
|
+
async function setProviderKey(ctx, req, res, type, lang) {
|
|
3709
|
+
if (!isProviderType(type)) {
|
|
3710
|
+
return sendError(res, 404, "unknown-provider", t("web.provider_unknown", { name: type }, lang));
|
|
3711
|
+
}
|
|
3712
|
+
const envVar = apiKeyEnvVar(type);
|
|
3713
|
+
if (envVar === null) {
|
|
3714
|
+
return sendError(
|
|
3715
|
+
res,
|
|
3716
|
+
400,
|
|
3717
|
+
"key-not-applicable",
|
|
3718
|
+
t("web.provider_key_not_applicable", { name: type }, lang)
|
|
3719
|
+
);
|
|
3720
|
+
}
|
|
3721
|
+
const body = await readJsonBody(req);
|
|
3722
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3723
|
+
const raw = asRecord(body.value)["apiKey"];
|
|
3724
|
+
const apiKey = typeof raw === "string" ? raw.trim() : "";
|
|
3725
|
+
if (apiKey === "") {
|
|
3726
|
+
return sendError(res, 400, "empty-key", t("web.provider_key_empty", void 0, lang));
|
|
3727
|
+
}
|
|
3728
|
+
const written = await ctx.runtime.modelCatalog.setKey(type, apiKey);
|
|
3729
|
+
sendJson(res, 200, {
|
|
3730
|
+
provider: type,
|
|
3731
|
+
hasKey: true,
|
|
3732
|
+
keyHint: written.keyHint,
|
|
3733
|
+
backend: written.backend,
|
|
3734
|
+
encrypted: written.encrypted,
|
|
3735
|
+
envPath: written.envPath
|
|
3736
|
+
});
|
|
3737
|
+
}
|
|
3738
|
+
async function setDefaultModel(ctx, req, res, lang) {
|
|
3739
|
+
if (ctx.runtime.providerInfo !== null) {
|
|
3740
|
+
return sendError(
|
|
3741
|
+
res,
|
|
3742
|
+
409,
|
|
3743
|
+
"provider-available",
|
|
3744
|
+
t("web.model_default_has_provider", void 0, lang)
|
|
3745
|
+
);
|
|
3746
|
+
}
|
|
3747
|
+
const body = await readJsonBody(req, void 0, lang);
|
|
3748
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3749
|
+
const raw = asRecord(body.value)["model"];
|
|
3750
|
+
const model = typeof raw === "string" ? raw.trim() : "";
|
|
3751
|
+
if (model === "") {
|
|
3752
|
+
return sendError(res, 400, "bad-request", t("web.model_default_empty", void 0, lang));
|
|
3753
|
+
}
|
|
3754
|
+
const report = ctx.runtime.settings.write({ key: "model", layer: "user", value: model });
|
|
3755
|
+
if (!report.ok) {
|
|
3756
|
+
const failure = settingWriteFailure(report);
|
|
3757
|
+
return sendError(
|
|
3758
|
+
res,
|
|
3759
|
+
failure.status,
|
|
3760
|
+
failure.code,
|
|
3761
|
+
t(failure.key, { path: failure.path }, lang)
|
|
3762
|
+
);
|
|
3763
|
+
}
|
|
3764
|
+
sendJson(res, 200, toWireSettingsWrite(report));
|
|
3765
|
+
}
|
|
3766
|
+
async function addRole(ctx, req, res, lang) {
|
|
3767
|
+
if (ctx.lanExposed) {
|
|
3768
|
+
return sendError(
|
|
3769
|
+
res,
|
|
3770
|
+
403,
|
|
3771
|
+
"role-add-lan-exposed",
|
|
3772
|
+
t("web.role_add_lan_exposed", void 0, lang)
|
|
3773
|
+
);
|
|
3774
|
+
}
|
|
3775
|
+
const body = await readJsonBody(req);
|
|
3776
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3777
|
+
const payload = asRecord(body.value);
|
|
3778
|
+
const outcome = ctx.runtime.roleWrite.add(
|
|
3779
|
+
{
|
|
3780
|
+
name: typeof payload["name"] === "string" ? payload["name"].trim() : "",
|
|
3781
|
+
description: typeof payload["description"] === "string" ? payload["description"].trim() : "",
|
|
3782
|
+
...typeof payload["prompt"] === "string" ? { prompt: payload["prompt"] } : {},
|
|
3783
|
+
...Array.isArray(payload["tools"]) ? { tools: stringList2(payload["tools"]) } : {},
|
|
3784
|
+
...Array.isArray(payload["skills"]) ? { skills: stringList2(payload["skills"]) } : {},
|
|
3785
|
+
...typeof payload["maxTurns"] === "number" ? { maxTurns: payload["maxTurns"] } : {}
|
|
3786
|
+
},
|
|
3787
|
+
lang
|
|
3788
|
+
);
|
|
3789
|
+
if (!outcome.ok) return sendError(res, ...addFailure2(outcome.reason, outcome.detail, lang));
|
|
3790
|
+
sendJson(res, 200, {
|
|
3791
|
+
role: toWireRole(outcome.role, ctx.runtime.agentRoleNotices),
|
|
3792
|
+
path: outcome.path
|
|
3793
|
+
});
|
|
3794
|
+
}
|
|
3795
|
+
function addFailure2(reason, detail, lang) {
|
|
3796
|
+
if (reason === "invalid-name") return [400, "bad-role-name", detail];
|
|
3797
|
+
if (reason === "invalid-field")
|
|
3798
|
+
return [400, "bad-role-field", t("web.role_add_bad_field", { detail }, lang)];
|
|
3799
|
+
if (reason === "unknown-tool") return [400, "bad-role-tools", detail];
|
|
3800
|
+
if (reason === "duplicate") return [409, "role-name-taken", detail];
|
|
3801
|
+
return [409, "role-unwritable", detail];
|
|
3802
|
+
}
|
|
3803
|
+
function stringList2(value) {
|
|
3804
|
+
return value.filter((v) => typeof v === "string");
|
|
3805
|
+
}
|
|
3806
|
+
async function removeRole(ctx, req, res, lang) {
|
|
3807
|
+
if (ctx.lanExposed) {
|
|
3808
|
+
return sendError(
|
|
3809
|
+
res,
|
|
3810
|
+
403,
|
|
3811
|
+
"role-remove-lan-exposed",
|
|
3812
|
+
t("web.role_remove_lan_exposed", void 0, lang)
|
|
3813
|
+
);
|
|
3814
|
+
}
|
|
3815
|
+
const body = await readJsonBody(req);
|
|
3816
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
3817
|
+
const payload = asRecord(body.value);
|
|
3818
|
+
const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
|
|
3819
|
+
const outcome = ctx.runtime.roleWrite.remove(name, lang);
|
|
3820
|
+
if (!outcome.ok) {
|
|
3821
|
+
return sendJson(res, 200, {
|
|
3822
|
+
ok: false,
|
|
3823
|
+
name: "",
|
|
3824
|
+
path: null,
|
|
3825
|
+
takenOverBy: null,
|
|
3826
|
+
detail: outcome.detail,
|
|
3827
|
+
reason: outcome.reason
|
|
3828
|
+
});
|
|
3829
|
+
}
|
|
3830
|
+
sendJson(res, 200, {
|
|
3831
|
+
ok: true,
|
|
3832
|
+
name: outcome.name,
|
|
3833
|
+
path: outcome.path,
|
|
3834
|
+
takenOverBy: outcome.takenOverBy,
|
|
3835
|
+
detail: null,
|
|
3836
|
+
reason: null
|
|
3837
|
+
});
|
|
3838
|
+
}
|
|
3839
|
+
function stringList3(value) {
|
|
3840
|
+
if (!Array.isArray(value)) return null;
|
|
3841
|
+
const out = [];
|
|
3842
|
+
for (const item of value) {
|
|
3843
|
+
if (typeof item !== "string" || item.trim().length === 0) return null;
|
|
3844
|
+
out.push(item);
|
|
3845
|
+
}
|
|
3846
|
+
return out;
|
|
3847
|
+
}
|
|
3848
|
+
function operationList(value) {
|
|
3849
|
+
if (!Array.isArray(value)) return null;
|
|
3850
|
+
const out = [];
|
|
3851
|
+
for (const item of value) {
|
|
3852
|
+
if (typeof item !== "string" || !isOperationType(item)) return null;
|
|
3853
|
+
out.push(item);
|
|
3854
|
+
}
|
|
3855
|
+
return out;
|
|
3856
|
+
}
|
|
3857
|
+
function finite(value) {
|
|
3858
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
3859
|
+
}
|
|
3860
|
+
function parseTrigger(value) {
|
|
3345
3861
|
const raw = asRecord(value);
|
|
3346
3862
|
const kind = raw["kind"];
|
|
3347
3863
|
const at = raw["at"];
|
|
@@ -4177,681 +4693,315 @@ function browseWorkspaceDirs(ctx, res, rawUrl, lang) {
|
|
|
4177
4693
|
return sendError(
|
|
4178
4694
|
res,
|
|
4179
4695
|
403,
|
|
4180
|
-
"workspace-denied",
|
|
4181
|
-
t("web.dirs_denied", { path: outcome.path }, lang)
|
|
4182
|
-
);
|
|
4183
|
-
}
|
|
4184
|
-
if (outcome.reason === "missing") {
|
|
4185
|
-
return sendError(res, 404, "not-found", t("web.dirs_missing", { path: outcome.path }, lang));
|
|
4186
|
-
}
|
|
4187
|
-
sendError(
|
|
4188
|
-
res,
|
|
4189
|
-
400,
|
|
4190
|
-
"bad-workspace",
|
|
4191
|
-
outcome.reason === "not-absolute" ? t("web.dirs_not_absolute", { path: outcome.path }, lang) : t("web.dirs_not_a_directory", { path: outcome.path }, lang)
|
|
4192
|
-
);
|
|
4193
|
-
}
|
|
4194
|
-
function dirsQuery(query) {
|
|
4195
|
-
const value = query.get("path")?.trim();
|
|
4196
|
-
return value ? value : void 0;
|
|
4197
|
-
}
|
|
4198
|
-
function atQuery(query) {
|
|
4199
|
-
return query.get("at")?.trim().toLowerCase() === "home" ? workspaceHome() : void 0;
|
|
4200
|
-
}
|
|
4201
|
-
function hiddenQuery(query) {
|
|
4202
|
-
const value = query.get("hidden")?.trim().toLowerCase();
|
|
4203
|
-
return value === "1" || value === "true";
|
|
4204
|
-
}
|
|
4205
|
-
async function createWorkspaceDir(req, res, lang) {
|
|
4206
|
-
const body = await readJsonBody(req);
|
|
4207
|
-
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
4208
|
-
const payload = asRecord(body.value);
|
|
4209
|
-
const parent = typeof payload["parent"] === "string" ? payload["parent"].trim() : "";
|
|
4210
|
-
const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
|
|
4211
|
-
if (parent.length === 0 || !isAbsolute(parent)) {
|
|
4212
|
-
return sendError(
|
|
4213
|
-
res,
|
|
4214
|
-
400,
|
|
4215
|
-
"bad-workspace",
|
|
4216
|
-
t("web.mkdir_parent_not_absolute", { parent }, lang)
|
|
4217
|
-
);
|
|
4218
|
-
}
|
|
4219
|
-
if (!isSingleSegment(name)) {
|
|
4220
|
-
return sendError(res, 400, "bad-workspace", t("web.mkdir_bad_name", { name }, lang));
|
|
4221
|
-
}
|
|
4222
|
-
const path = resolve(parent, name);
|
|
4223
|
-
try {
|
|
4224
|
-
mkdirSync(path);
|
|
4225
|
-
} catch (cause) {
|
|
4226
|
-
return sendError(res, ...mkdirFailure(cause, path, lang));
|
|
4227
|
-
}
|
|
4228
|
-
sendJson(res, 200, {
|
|
4229
|
-
entry: {
|
|
4230
|
-
name,
|
|
4231
|
-
path,
|
|
4232
|
-
readable: true,
|
|
4233
|
-
known: false,
|
|
4234
|
-
group: "child"
|
|
4235
|
-
}
|
|
4236
|
-
});
|
|
4237
|
-
}
|
|
4238
|
-
async function pickWorkspaceDir(ctx, req, res, lang) {
|
|
4239
|
-
if (!ctx.nativeDirPicker) {
|
|
4240
|
-
return sendError(res, 403, "native-picker-unavailable", t("web.pick_unavailable", {}, lang));
|
|
4241
|
-
}
|
|
4242
|
-
const abort = new AbortController();
|
|
4243
|
-
req.on("close", () => abort.abort());
|
|
4244
|
-
const outcome = await pickNativeDirectory({
|
|
4245
|
-
prompt: t("web.pick_prompt", {}, lang),
|
|
4246
|
-
signal: abort.signal
|
|
4247
|
-
});
|
|
4248
|
-
if (res.writableEnded || abort.signal.aborted) return;
|
|
4249
|
-
if (outcome.ok) {
|
|
4250
|
-
return sendJson(
|
|
4251
|
-
res,
|
|
4252
|
-
200,
|
|
4253
|
-
outcome.path === void 0 ? { canceled: true } : { path: outcome.path }
|
|
4254
|
-
);
|
|
4255
|
-
}
|
|
4256
|
-
if (outcome.reason === "busy") {
|
|
4257
|
-
return sendError(res, 409, "native-picker-busy", t("web.pick_busy", {}, lang));
|
|
4258
|
-
}
|
|
4259
|
-
sendError(
|
|
4260
|
-
res,
|
|
4261
|
-
500,
|
|
4262
|
-
"native-picker-failed",
|
|
4263
|
-
t("web.pick_failed", { detail: outcome.message }, lang)
|
|
4264
|
-
);
|
|
4265
|
-
}
|
|
4266
|
-
function isSingleSegment(name) {
|
|
4267
|
-
if (name.length === 0) return false;
|
|
4268
|
-
if (name === "." || name === "..") return false;
|
|
4269
|
-
return !name.includes("/") && !name.includes(sep);
|
|
4270
|
-
}
|
|
4271
|
-
function mkdirFailure(cause, path, lang) {
|
|
4272
|
-
const code = cause?.code;
|
|
4273
|
-
if (code === "EEXIST") return [409, "workspace-exists", t("web.mkdir_exists", { path }, lang)];
|
|
4274
|
-
if (code === "EACCES" || code === "EPERM") {
|
|
4275
|
-
return [403, "workspace-denied", t("web.mkdir_denied", { path }, lang)];
|
|
4276
|
-
}
|
|
4277
|
-
if (code === "ENOENT") return [404, "not-found", t("web.mkdir_parent_missing", { path }, lang)];
|
|
4278
|
-
return [500, "internal", t("web.mkdir_failed", { path }, lang)];
|
|
4279
|
-
}
|
|
4280
|
-
var MAX_TEXT_BYTES = 2 * 1024 * 1024;
|
|
4281
|
-
var VIEWABLE = /* @__PURE__ */ new Map([
|
|
4282
|
-
[".png", { kind: "image", mime: "image/png" }],
|
|
4283
|
-
[".jpg", { kind: "image", mime: "image/jpeg" }],
|
|
4284
|
-
[".jpeg", { kind: "image", mime: "image/jpeg" }],
|
|
4285
|
-
[".gif", { kind: "image", mime: "image/gif" }],
|
|
4286
|
-
[".webp", { kind: "image", mime: "image/webp" }],
|
|
4287
|
-
[".bmp", { kind: "image", mime: "image/bmp" }],
|
|
4288
|
-
[".ico", { kind: "image", mime: "image/x-icon" }],
|
|
4289
|
-
[".avif", { kind: "image", mime: "image/avif" }],
|
|
4290
|
-
[".pdf", { kind: "pdf", mime: "application/pdf" }],
|
|
4291
|
-
[".mp3", { kind: "audio", mime: "audio/mpeg" }],
|
|
4292
|
-
[".wav", { kind: "audio", mime: "audio/wav" }],
|
|
4293
|
-
[".ogg", { kind: "audio", mime: "audio/ogg" }],
|
|
4294
|
-
[".m4a", { kind: "audio", mime: "audio/mp4" }],
|
|
4295
|
-
[".flac", { kind: "audio", mime: "audio/flac" }],
|
|
4296
|
-
[".mp4", { kind: "video", mime: "video/mp4" }],
|
|
4297
|
-
[".webm", { kind: "video", mime: "video/webm" }],
|
|
4298
|
-
[".mov", { kind: "video", mime: "video/quicktime" }]
|
|
4299
|
-
]);
|
|
4300
|
-
function kindOf(stat3) {
|
|
4301
|
-
if (stat3.kind === "text") return "text";
|
|
4302
|
-
return VIEWABLE.get(extname(stat3.rel).toLowerCase())?.kind ?? "opaque";
|
|
4303
|
-
}
|
|
4304
|
-
function toWireRefusal(reason) {
|
|
4305
|
-
return reason;
|
|
4306
|
-
}
|
|
4307
|
-
function statusFor(refusal) {
|
|
4308
|
-
if (refusal === "denied") return 403;
|
|
4309
|
-
if (refusal === "missing") return 404;
|
|
4310
|
-
if (refusal === "too-large") return 413;
|
|
4311
|
-
return refusal === "not-a-file" ? 400 : 503;
|
|
4312
|
-
}
|
|
4313
|
-
function describeRefusal(refusal, lang) {
|
|
4314
|
-
if (refusal === "denied") return t("web.file_view_denied", void 0, lang);
|
|
4315
|
-
if (refusal === "missing") return t("web.file_view_missing", void 0, lang);
|
|
4316
|
-
if (refusal === "not-a-file") return t("web.file_view_not_a_file", void 0, lang);
|
|
4317
|
-
if (refusal === "too-large") {
|
|
4318
|
-
return t("web.file_view_too_large", { max: String(MAX_TEXT_BYTES) }, lang);
|
|
4319
|
-
}
|
|
4320
|
-
return t("web.file_view_unreadable", void 0, lang);
|
|
4321
|
-
}
|
|
4322
|
-
function resolveViewRoot(ctx, res, sessionId, lang) {
|
|
4323
|
-
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
4324
|
-
sendUnknownSession(res, sessionId);
|
|
4325
|
-
return null;
|
|
4326
|
-
}
|
|
4327
|
-
const root = workspaceRootOf(ctx, sessionId);
|
|
4328
|
-
if (!root) {
|
|
4329
|
-
sendError(res, 409, "no-workspace", t("web.file_view_no_workspace", void 0, lang));
|
|
4330
|
-
return null;
|
|
4331
|
-
}
|
|
4332
|
-
return root;
|
|
4333
|
-
}
|
|
4334
|
-
function singlePath(res, rawUrl, lang) {
|
|
4335
|
-
const params = new URLSearchParams((rawUrl ?? "").split("?")[1] ?? "");
|
|
4336
|
-
const path = params.get("path")?.trim();
|
|
4337
|
-
if (!path) {
|
|
4338
|
-
sendError(res, 400, "missing-path", t("web.file_view_missing_path", void 0, lang));
|
|
4339
|
-
return null;
|
|
4340
|
-
}
|
|
4341
|
-
return path;
|
|
4342
|
-
}
|
|
4343
|
-
function statFiles(ctx, res, sessionId, rawUrl, lang) {
|
|
4344
|
-
const root = resolveViewRoot(ctx, res, sessionId, lang);
|
|
4345
|
-
if (root === null) return;
|
|
4346
|
-
const params = new URLSearchParams((rawUrl ?? "").split("?")[1] ?? "");
|
|
4347
|
-
const asked = params.getAll("path").filter((p) => p.trim() !== "");
|
|
4348
|
-
const taken = asked.slice(0, WIRE_FILE_STAT_MAX);
|
|
4349
|
-
const files = taken.map((path) => {
|
|
4350
|
-
const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
|
|
4351
|
-
if (!stat3.ok) return { path, ok: false, refusal: toWireRefusal(stat3.reason) };
|
|
4352
|
-
return {
|
|
4353
|
-
path,
|
|
4354
|
-
ok: true,
|
|
4355
|
-
kind: kindOf(stat3),
|
|
4356
|
-
bytes: stat3.bytes,
|
|
4357
|
-
mtimeMs: stat3.mtimeMs,
|
|
4358
|
-
openable: ctx.sameMachine && isSystemOpenable(stat3.rel)
|
|
4359
|
-
};
|
|
4360
|
-
});
|
|
4361
|
-
sendJson(res, 200, {
|
|
4362
|
-
files,
|
|
4363
|
-
overLimit: asked.length - taken.length,
|
|
4364
|
-
root
|
|
4365
|
-
});
|
|
4366
|
-
}
|
|
4367
|
-
function readFileText(ctx, res, sessionId, rawUrl, lang) {
|
|
4368
|
-
const root = resolveViewRoot(ctx, res, sessionId, lang);
|
|
4369
|
-
if (root === null) return;
|
|
4370
|
-
const path = singlePath(res, rawUrl, lang);
|
|
4371
|
-
if (path === null) return;
|
|
4372
|
-
const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
|
|
4373
|
-
if (!stat3.ok) {
|
|
4374
|
-
const refusal = toWireRefusal(stat3.reason);
|
|
4375
|
-
return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
|
|
4376
|
-
}
|
|
4377
|
-
if (stat3.kind !== "text") {
|
|
4378
|
-
return sendError(res, 415, "not-text", t("web.file_view_not_text", void 0, lang));
|
|
4379
|
-
}
|
|
4380
|
-
if (stat3.bytes > MAX_TEXT_BYTES) {
|
|
4381
|
-
return sendError(res, 413, "too-large", describeRefusal("too-large", lang));
|
|
4382
|
-
}
|
|
4383
|
-
const out = ctx.runtime.workspaceFiles.readFile(stat3.rel, root);
|
|
4384
|
-
if (!out.ok) {
|
|
4385
|
-
const refusal = out.reason === "binary" ? "unreadable" : out.reason;
|
|
4386
|
-
return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
|
|
4387
|
-
}
|
|
4388
|
-
sendJson(res, 200, {
|
|
4389
|
-
path,
|
|
4390
|
-
text: out.text,
|
|
4391
|
-
bytes: out.bytes
|
|
4392
|
-
});
|
|
4393
|
-
}
|
|
4394
|
-
function readFileBytes(ctx, res, sessionId, rawUrl, headOnly, lang) {
|
|
4395
|
-
const root = resolveViewRoot(ctx, res, sessionId, lang);
|
|
4396
|
-
if (root === null) return;
|
|
4397
|
-
const path = singlePath(res, rawUrl, lang);
|
|
4398
|
-
if (path === null) return;
|
|
4399
|
-
const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
|
|
4400
|
-
if (!stat3.ok) {
|
|
4401
|
-
const refusal = toWireRefusal(stat3.reason);
|
|
4402
|
-
return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
|
|
4403
|
-
}
|
|
4404
|
-
const known = VIEWABLE.get(extname(stat3.rel).toLowerCase());
|
|
4405
|
-
const inline = known !== void 0 && stat3.kind === "binary";
|
|
4406
|
-
const headers = {
|
|
4407
|
-
"Content-Type": inline ? known.mime : "application/octet-stream",
|
|
4408
|
-
"X-Content-Type-Options": "nosniff",
|
|
4409
|
-
"Content-Disposition": inline ? "inline" : `attachment; filename*=UTF-8''${encodeURIComponent(basenameOf(stat3.rel))}`
|
|
4410
|
-
};
|
|
4411
|
-
if (inline && known.kind === "pdf") headers["Content-Security-Policy"] = "sandbox";
|
|
4412
|
-
if (headOnly) {
|
|
4413
|
-
return sendEmpty(res, 200, {
|
|
4414
|
-
...headers,
|
|
4415
|
-
"Content-Length": String(stat3.bytes)
|
|
4416
|
-
});
|
|
4417
|
-
}
|
|
4418
|
-
sendFile(res, stat3.absPath, headers);
|
|
4419
|
-
}
|
|
4420
|
-
function basenameOf(rel) {
|
|
4421
|
-
const cut = rel.lastIndexOf("/");
|
|
4422
|
-
return cut < 0 ? rel : rel.slice(cut + 1);
|
|
4423
|
-
}
|
|
4424
|
-
// src/workspace/file-open.ts
|
|
4425
|
-
function describeOpenRefusal(refusal, lang) {
|
|
4426
|
-
if (refusal === "extension-not-allowed") {
|
|
4427
|
-
return t("web.file_open_extension_not_allowed", void 0, lang);
|
|
4428
|
-
}
|
|
4429
|
-
if (refusal === "not-same-machine") return t("web.file_open_not_same_machine", void 0, lang);
|
|
4430
|
-
if (refusal === "launch-failed") return t("web.file_open_launch_failed", void 0, lang);
|
|
4431
|
-
return describeRefusal(refusal, lang);
|
|
4432
|
-
}
|
|
4433
|
-
function refuse(res, refusal, lang) {
|
|
4434
|
-
sendJson(res, 200, {
|
|
4435
|
-
ok: false,
|
|
4436
|
-
refusal,
|
|
4437
|
-
message: describeOpenRefusal(refusal, lang)
|
|
4438
|
-
});
|
|
4439
|
-
}
|
|
4440
|
-
async function openFileWithSystemApp(ctx, req, res, sessionId, lang) {
|
|
4441
|
-
if (!ctx.sameMachine) {
|
|
4442
|
-
return sendError(res, 403, "not-same-machine", describeOpenRefusal("not-same-machine", lang));
|
|
4443
|
-
}
|
|
4444
|
-
const root = resolveViewRoot(ctx, res, sessionId, lang);
|
|
4445
|
-
if (root === null) return;
|
|
4446
|
-
const body = await readJsonBody(req);
|
|
4447
|
-
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
4448
|
-
const payload = asRecord(body.value);
|
|
4449
|
-
const path = typeof payload["path"] === "string" ? payload["path"].trim() : "";
|
|
4450
|
-
if (path === "") {
|
|
4451
|
-
return sendError(res, 400, "missing-path", t("web.file_view_missing_path", void 0, lang));
|
|
4452
|
-
}
|
|
4453
|
-
const plan = ctx.runtime.workspaceFiles.planSystemOpen(path, root);
|
|
4454
|
-
if (!plan.ok) return refuse(res, plan.reason, lang);
|
|
4455
|
-
try {
|
|
4456
|
-
const child = spawn(plan.command, [...plan.args], { stdio: "ignore", detached: true });
|
|
4457
|
-
await new Promise((done, fail) => {
|
|
4458
|
-
child.on("error", fail);
|
|
4459
|
-
child.on("spawn", () => {
|
|
4460
|
-
child.unref();
|
|
4461
|
-
done();
|
|
4462
|
-
});
|
|
4463
|
-
});
|
|
4464
|
-
} catch {
|
|
4465
|
-
return refuse(res, "launch-failed", lang);
|
|
4466
|
-
}
|
|
4467
|
-
sendJson(res, 200, { ok: true });
|
|
4468
|
-
}
|
|
4469
|
-
async function forgetWorkspace(ctx, sessionId, req, res, lang) {
|
|
4470
|
-
const body = await readJsonBody(req, void 0, lang);
|
|
4471
|
-
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
4472
|
-
if (!conformsToWire(body.value, WIRE_FORGET_WORKSPACE_FIELDS)) {
|
|
4473
|
-
return sendError(res, 400, "bad-request", t("web.forget_bad_request", {}, lang));
|
|
4474
|
-
}
|
|
4475
|
-
const { root } = body.value;
|
|
4476
|
-
const workspaces = ctx.runtime.workspaces;
|
|
4477
|
-
if (!workspaces.known().some((w) => w.root === root)) {
|
|
4478
|
-
return sendError(res, 404, "unknown-workspace", t("web.forget_unknown", {}, lang));
|
|
4479
|
-
}
|
|
4480
|
-
const current = workspaces.of(sessionId)?.workspace.root;
|
|
4481
|
-
if (current !== void 0 && current === root) {
|
|
4482
|
-
return sendError(res, 409, "workspace-in-use", t("web.forget_in_use", {}, lang));
|
|
4483
|
-
}
|
|
4484
|
-
workspaces.forget(root);
|
|
4485
|
-
sendJson(res, 200, {
|
|
4486
|
-
known: toWireTrustList(workspaces.known(), workspaces, current ?? null)
|
|
4487
|
-
});
|
|
4488
|
-
}
|
|
4489
|
-
var GIT_TIMEOUT_MS = 1e4;
|
|
4490
|
-
var GIT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
4491
|
-
function runGit(cwd, args) {
|
|
4492
|
-
if (!canProbeGit()) return Promise.resolve({ ok: false, error: "git unavailable" });
|
|
4493
|
-
return new Promise((resolve4) => {
|
|
4494
|
-
guardSpawn(
|
|
4495
|
-
() => execFile(
|
|
4496
|
-
"git",
|
|
4497
|
-
[...args],
|
|
4498
|
-
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, encoding: "buffer" },
|
|
4499
|
-
(err, stdout) => {
|
|
4500
|
-
if (err) return resolve4({ ok: false, error: err.message });
|
|
4501
|
-
resolve4({ ok: true, stdout });
|
|
4502
|
-
}
|
|
4503
|
-
),
|
|
4504
|
-
(err) => resolve4({ ok: false, error: `${err.message} (cwd=${cwd})` })
|
|
4505
|
-
);
|
|
4506
|
-
});
|
|
4507
|
-
}
|
|
4508
|
-
async function repoRoot(cwd) {
|
|
4509
|
-
const result = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
4510
|
-
if (!result.ok) return null;
|
|
4511
|
-
const root = result.stdout.toString("utf-8").trim();
|
|
4512
|
-
return root.length > 0 ? root : null;
|
|
4513
|
-
}
|
|
4514
|
-
async function hasHead(root) {
|
|
4515
|
-
return (await runGit(root, ["rev-parse", "--verify", "--quiet", "HEAD"])).ok;
|
|
4516
|
-
}
|
|
4517
|
-
var NULL_SHA = /^0+$/;
|
|
4518
|
-
async function rawChanges(root) {
|
|
4519
|
-
const result = await runGit(root, ["diff", "--raw", "-z", "HEAD", "--"]);
|
|
4520
|
-
if (!result.ok) return [];
|
|
4521
|
-
return parseRawZ(result.stdout.toString("utf-8"));
|
|
4522
|
-
}
|
|
4523
|
-
function parseRawZ(text) {
|
|
4524
|
-
const tokens = text.split("\0");
|
|
4525
|
-
const changes = [];
|
|
4526
|
-
let i = 0;
|
|
4527
|
-
while (i < tokens.length) {
|
|
4528
|
-
const meta = tokens[i];
|
|
4529
|
-
if (meta === void 0 || !meta.startsWith(":")) {
|
|
4530
|
-
i += 1;
|
|
4531
|
-
continue;
|
|
4532
|
-
}
|
|
4533
|
-
const parts = meta.slice(1).split(" ");
|
|
4534
|
-
const oldSha = parts[2] ?? "";
|
|
4535
|
-
const status = (parts[4] ?? "").trim();
|
|
4536
|
-
const renamed = status.startsWith("R") || status.startsWith("C");
|
|
4537
|
-
const first = tokens[i + 1];
|
|
4538
|
-
const second = renamed ? tokens[i + 2] : void 0;
|
|
4539
|
-
i += renamed ? 3 : 2;
|
|
4540
|
-
if (first === void 0 || renamed && second === void 0) break;
|
|
4541
|
-
const path = renamed ? second : first;
|
|
4542
|
-
changes.push({
|
|
4543
|
-
status: status.charAt(0),
|
|
4544
|
-
path,
|
|
4545
|
-
...renamed ? { oldPath: first } : {},
|
|
4546
|
-
oldSha: NULL_SHA.test(oldSha) ? null : oldSha
|
|
4547
|
-
});
|
|
4696
|
+
"workspace-denied",
|
|
4697
|
+
t("web.dirs_denied", { path: outcome.path }, lang)
|
|
4698
|
+
);
|
|
4548
4699
|
}
|
|
4549
|
-
|
|
4550
|
-
}
|
|
4551
|
-
async function treeBlobSizes(root) {
|
|
4552
|
-
const sizes = /* @__PURE__ */ new Map();
|
|
4553
|
-
const result = await runGit(root, ["ls-tree", "-r", "-l", "-z", "HEAD"]);
|
|
4554
|
-
if (!result.ok) return sizes;
|
|
4555
|
-
for (const record of splitZ(result.stdout.toString("utf-8"))) {
|
|
4556
|
-
const tab = record.indexOf(" ");
|
|
4557
|
-
if (tab < 0) continue;
|
|
4558
|
-
const columns = record.slice(0, tab).split(/\s+/).filter(Boolean);
|
|
4559
|
-
const sha = columns[2];
|
|
4560
|
-
const size = Number(columns[3]);
|
|
4561
|
-
if (columns[1] === "blob" && sha && Number.isFinite(size)) sizes.set(sha, size);
|
|
4700
|
+
if (outcome.reason === "missing") {
|
|
4701
|
+
return sendError(res, 404, "not-found", t("web.dirs_missing", { path: outcome.path }, lang));
|
|
4562
4702
|
}
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
}
|
|
4570
|
-
async function trackedFiles(root) {
|
|
4571
|
-
const result = await runGit(root, ["ls-files", "-z"]);
|
|
4572
|
-
if (!result.ok) return [];
|
|
4573
|
-
return splitZ(result.stdout.toString("utf-8"));
|
|
4703
|
+
sendError(
|
|
4704
|
+
res,
|
|
4705
|
+
400,
|
|
4706
|
+
"bad-workspace",
|
|
4707
|
+
outcome.reason === "not-absolute" ? t("web.dirs_not_absolute", { path: outcome.path }, lang) : t("web.dirs_not_a_directory", { path: outcome.path }, lang)
|
|
4708
|
+
);
|
|
4574
4709
|
}
|
|
4575
|
-
|
|
4576
|
-
const
|
|
4577
|
-
return
|
|
4710
|
+
function dirsQuery(query) {
|
|
4711
|
+
const value = query.get("path")?.trim();
|
|
4712
|
+
return value ? value : void 0;
|
|
4578
4713
|
}
|
|
4579
|
-
function
|
|
4580
|
-
return
|
|
4714
|
+
function atQuery(query) {
|
|
4715
|
+
return query.get("at")?.trim().toLowerCase() === "home" ? workspaceHome() : void 0;
|
|
4581
4716
|
}
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
var BINARY_SNIFF_BYTES = 8e3;
|
|
4586
|
-
var ABSENT = { kind: "absent" };
|
|
4587
|
-
async function collectWorkspaceDiff(opts) {
|
|
4588
|
-
const root = await repoRoot(opts.workDir);
|
|
4589
|
-
return root === null ? await checkpointDiff(opts) : await gitDiff(root);
|
|
4717
|
+
function hiddenQuery(query) {
|
|
4718
|
+
const value = query.get("hidden")?.trim().toLowerCase();
|
|
4719
|
+
return value === "1" || value === "true";
|
|
4590
4720
|
}
|
|
4591
|
-
async function
|
|
4592
|
-
const
|
|
4593
|
-
|
|
4594
|
-
const
|
|
4595
|
-
const
|
|
4596
|
-
|
|
4597
|
-
if (
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4721
|
+
async function createWorkspaceDir(req, res, lang) {
|
|
4722
|
+
const body = await readJsonBody(req);
|
|
4723
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
4724
|
+
const payload = asRecord(body.value);
|
|
4725
|
+
const parent = typeof payload["parent"] === "string" ? payload["parent"].trim() : "";
|
|
4726
|
+
const name = typeof payload["name"] === "string" ? payload["name"].trim() : "";
|
|
4727
|
+
if (parent.length === 0 || !isAbsolute(parent)) {
|
|
4728
|
+
return sendError(
|
|
4729
|
+
res,
|
|
4730
|
+
400,
|
|
4731
|
+
"bad-workspace",
|
|
4732
|
+
t("web.mkdir_parent_not_absolute", { parent }, lang)
|
|
4733
|
+
);
|
|
4604
4734
|
}
|
|
4605
|
-
|
|
4606
|
-
}
|
|
4607
|
-
async function collectGitPaths(root) {
|
|
4608
|
-
const byPath = /* @__PURE__ */ new Map();
|
|
4609
|
-
if (await hasHead(root)) {
|
|
4610
|
-
for (const change of await rawChanges(root)) {
|
|
4611
|
-
if (change.oldPath !== void 0) {
|
|
4612
|
-
byPath.set(change.oldPath, { path: change.oldPath, oldSha: change.oldSha });
|
|
4613
|
-
}
|
|
4614
|
-
byPath.set(change.path, {
|
|
4615
|
-
path: change.path,
|
|
4616
|
-
oldSha: change.oldPath === void 0 ? change.oldSha : null
|
|
4617
|
-
});
|
|
4618
|
-
}
|
|
4619
|
-
} else {
|
|
4620
|
-
for (const path of await trackedFiles(root)) byPath.set(path, { path, oldSha: null });
|
|
4735
|
+
if (!isSingleSegment(name)) {
|
|
4736
|
+
return sendError(res, 400, "bad-workspace", t("web.mkdir_bad_name", { name }, lang));
|
|
4621
4737
|
}
|
|
4622
|
-
|
|
4623
|
-
|
|
4738
|
+
const path = resolve(parent, name);
|
|
4739
|
+
try {
|
|
4740
|
+
mkdirSync(path);
|
|
4741
|
+
} catch (cause) {
|
|
4742
|
+
return sendError(res, ...mkdirFailure(cause, path, lang));
|
|
4624
4743
|
}
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4744
|
+
sendJson(res, 200, {
|
|
4745
|
+
entry: {
|
|
4746
|
+
name,
|
|
4747
|
+
path,
|
|
4748
|
+
readable: true,
|
|
4749
|
+
known: false,
|
|
4750
|
+
group: "child"
|
|
4751
|
+
}
|
|
4752
|
+
});
|
|
4634
4753
|
}
|
|
4635
|
-
async function
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
if (paths.length === 0) {
|
|
4639
|
-
notes.push(opts.checkpoints === null ? "no-workspace-history" : "no-changes");
|
|
4640
|
-
return { source: "none", root: opts.workDir, files: [], truncated: false, notes };
|
|
4754
|
+
async function pickWorkspaceDir(ctx, req, res, lang) {
|
|
4755
|
+
if (!ctx.nativeDirPicker) {
|
|
4756
|
+
return sendError(res, 403, "native-picker-unavailable", t("web.pick_unavailable", {}, lang));
|
|
4641
4757
|
}
|
|
4642
|
-
const
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4758
|
+
const abort = new AbortController();
|
|
4759
|
+
req.on("close", () => abort.abort());
|
|
4760
|
+
const outcome = await pickNativeDirectory({
|
|
4761
|
+
prompt: t("web.pick_prompt", {}, lang),
|
|
4762
|
+
signal: abort.signal
|
|
4763
|
+
});
|
|
4764
|
+
if (res.writableEnded || abort.signal.aborted) return;
|
|
4765
|
+
if (outcome.ok) {
|
|
4766
|
+
return sendJson(
|
|
4767
|
+
res,
|
|
4768
|
+
200,
|
|
4769
|
+
outcome.path === void 0 ? { canceled: true } : { path: outcome.path }
|
|
4653
4770
|
);
|
|
4654
4771
|
}
|
|
4655
|
-
|
|
4656
|
-
}
|
|
4657
|
-
async function checkpointPaths(checkpoints) {
|
|
4658
|
-
if (!checkpoints) return [];
|
|
4659
|
-
const paths = /* @__PURE__ */ new Set();
|
|
4660
|
-
try {
|
|
4661
|
-
for (const summary of await checkpoints.list()) {
|
|
4662
|
-
const preview = await checkpoints.preview(summary.turnIndex);
|
|
4663
|
-
if (!preview) continue;
|
|
4664
|
-
for (const plan of [...preview.files, ...preview.conflicts]) {
|
|
4665
|
-
if (plan.action !== "skip") paths.add(plan.path);
|
|
4666
|
-
}
|
|
4667
|
-
}
|
|
4668
|
-
} catch {
|
|
4772
|
+
if (outcome.reason === "busy") {
|
|
4773
|
+
return sendError(res, 409, "native-picker-busy", t("web.pick_busy", {}, lang));
|
|
4669
4774
|
}
|
|
4670
|
-
|
|
4775
|
+
sendError(
|
|
4776
|
+
res,
|
|
4777
|
+
500,
|
|
4778
|
+
"native-picker-failed",
|
|
4779
|
+
t("web.pick_failed", { detail: outcome.message }, lang)
|
|
4780
|
+
);
|
|
4671
4781
|
}
|
|
4672
|
-
function
|
|
4673
|
-
|
|
4674
|
-
if (
|
|
4675
|
-
return
|
|
4782
|
+
function isSingleSegment(name) {
|
|
4783
|
+
if (name.length === 0) return false;
|
|
4784
|
+
if (name === "." || name === "..") return false;
|
|
4785
|
+
return !name.includes("/") && !name.includes(sep);
|
|
4676
4786
|
}
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
return { kind: "content", data: await readFile(absolute) };
|
|
4683
|
-
} catch {
|
|
4684
|
-
return ABSENT;
|
|
4787
|
+
function mkdirFailure(cause, path, lang) {
|
|
4788
|
+
const code = cause?.code;
|
|
4789
|
+
if (code === "EEXIST") return [409, "workspace-exists", t("web.mkdir_exists", { path }, lang)];
|
|
4790
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
4791
|
+
return [403, "workspace-denied", t("web.mkdir_denied", { path }, lang)];
|
|
4685
4792
|
}
|
|
4793
|
+
if (code === "ENOENT") return [404, "not-found", t("web.mkdir_parent_missing", { path }, lang)];
|
|
4794
|
+
return [500, "internal", t("web.mkdir_failed", { path }, lang)];
|
|
4686
4795
|
}
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
}
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
}
|
|
4796
|
+
var MAX_TEXT_BYTES = 2 * 1024 * 1024;
|
|
4797
|
+
var VIEWABLE = /* @__PURE__ */ new Map([
|
|
4798
|
+
[".png", { kind: "image", mime: "image/png" }],
|
|
4799
|
+
[".jpg", { kind: "image", mime: "image/jpeg" }],
|
|
4800
|
+
[".jpeg", { kind: "image", mime: "image/jpeg" }],
|
|
4801
|
+
[".gif", { kind: "image", mime: "image/gif" }],
|
|
4802
|
+
[".webp", { kind: "image", mime: "image/webp" }],
|
|
4803
|
+
[".bmp", { kind: "image", mime: "image/bmp" }],
|
|
4804
|
+
[".ico", { kind: "image", mime: "image/x-icon" }],
|
|
4805
|
+
[".avif", { kind: "image", mime: "image/avif" }],
|
|
4806
|
+
[".pdf", { kind: "pdf", mime: "application/pdf" }],
|
|
4807
|
+
[".mp3", { kind: "audio", mime: "audio/mpeg" }],
|
|
4808
|
+
[".wav", { kind: "audio", mime: "audio/wav" }],
|
|
4809
|
+
[".ogg", { kind: "audio", mime: "audio/ogg" }],
|
|
4810
|
+
[".m4a", { kind: "audio", mime: "audio/mp4" }],
|
|
4811
|
+
[".flac", { kind: "audio", mime: "audio/flac" }],
|
|
4812
|
+
[".mp4", { kind: "video", mime: "video/mp4" }],
|
|
4813
|
+
[".webm", { kind: "video", mime: "video/webm" }],
|
|
4814
|
+
[".mov", { kind: "video", mime: "video/quicktime" }]
|
|
4815
|
+
]);
|
|
4816
|
+
function kindOf(stat3) {
|
|
4817
|
+
if (stat3.kind === "text") return "text";
|
|
4818
|
+
return VIEWABLE.get(extname(stat3.rel).toLowerCase())?.kind ?? "opaque";
|
|
4704
4819
|
}
|
|
4705
|
-
function
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4820
|
+
function toWireRefusal(reason) {
|
|
4821
|
+
return reason;
|
|
4822
|
+
}
|
|
4823
|
+
function statusFor(refusal) {
|
|
4824
|
+
if (refusal === "denied") return 403;
|
|
4825
|
+
if (refusal === "missing") return 404;
|
|
4826
|
+
if (refusal === "too-large") return 413;
|
|
4827
|
+
return refusal === "not-a-file" ? 400 : 503;
|
|
4828
|
+
}
|
|
4829
|
+
function describeRefusal(refusal, lang) {
|
|
4830
|
+
if (refusal === "denied") return t("web.file_view_denied", void 0, lang);
|
|
4831
|
+
if (refusal === "missing") return t("web.file_view_missing", void 0, lang);
|
|
4832
|
+
if (refusal === "not-a-file") return t("web.file_view_not_a_file", void 0, lang);
|
|
4833
|
+
if (refusal === "too-large") {
|
|
4834
|
+
return t("web.file_view_too_large", { max: String(MAX_TEXT_BYTES) }, lang);
|
|
4835
|
+
}
|
|
4836
|
+
return t("web.file_view_unreadable", void 0, lang);
|
|
4709
4837
|
}
|
|
4710
|
-
function
|
|
4711
|
-
|
|
4838
|
+
function resolveViewRoot(ctx, res, sessionId, lang) {
|
|
4839
|
+
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
4840
|
+
sendUnknownSession(res, sessionId);
|
|
4841
|
+
return null;
|
|
4842
|
+
}
|
|
4843
|
+
const root = workspaceRootOf(ctx, sessionId);
|
|
4844
|
+
if (!root) {
|
|
4845
|
+
sendError(res, 409, "no-workspace", t("web.file_view_no_workspace", void 0, lang));
|
|
4846
|
+
return null;
|
|
4847
|
+
}
|
|
4848
|
+
return root;
|
|
4712
4849
|
}
|
|
4713
|
-
function
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4850
|
+
function singlePath(res, rawUrl, lang) {
|
|
4851
|
+
const params = new URLSearchParams((rawUrl ?? "").split("?")[1] ?? "");
|
|
4852
|
+
const path = params.get("path")?.trim();
|
|
4853
|
+
if (!path) {
|
|
4854
|
+
sendError(res, 400, "missing-path", t("web.file_view_missing_path", void 0, lang));
|
|
4855
|
+
return null;
|
|
4856
|
+
}
|
|
4857
|
+
return path;
|
|
4717
4858
|
}
|
|
4718
|
-
function
|
|
4719
|
-
|
|
4859
|
+
function statFiles(ctx, res, sessionId, rawUrl, lang) {
|
|
4860
|
+
const root = resolveViewRoot(ctx, res, sessionId, lang);
|
|
4861
|
+
if (root === null) return;
|
|
4862
|
+
const params = new URLSearchParams((rawUrl ?? "").split("?")[1] ?? "");
|
|
4863
|
+
const asked = params.getAll("path").filter((p) => p.trim() !== "");
|
|
4864
|
+
const taken = asked.slice(0, WIRE_FILE_STAT_MAX);
|
|
4865
|
+
const files = taken.map((path) => {
|
|
4866
|
+
const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
|
|
4867
|
+
if (!stat3.ok) return { path, ok: false, refusal: toWireRefusal(stat3.reason) };
|
|
4868
|
+
return {
|
|
4869
|
+
path,
|
|
4870
|
+
ok: true,
|
|
4871
|
+
kind: kindOf(stat3),
|
|
4872
|
+
bytes: stat3.bytes,
|
|
4873
|
+
mtimeMs: stat3.mtimeMs,
|
|
4874
|
+
openable: ctx.sameMachine && isSystemOpenable(stat3.rel)
|
|
4875
|
+
};
|
|
4876
|
+
});
|
|
4877
|
+
sendJson(res, 200, {
|
|
4878
|
+
files,
|
|
4879
|
+
overLimit: asked.length - taken.length,
|
|
4880
|
+
root
|
|
4881
|
+
});
|
|
4720
4882
|
}
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
const
|
|
4727
|
-
if (!
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
return sendError(res, 400, "bad-request", t("web.workspace_defer_with_root", void 0, lang));
|
|
4883
|
+
function readFileText(ctx, res, sessionId, rawUrl, lang) {
|
|
4884
|
+
const root = resolveViewRoot(ctx, res, sessionId, lang);
|
|
4885
|
+
if (root === null) return;
|
|
4886
|
+
const path = singlePath(res, rawUrl, lang);
|
|
4887
|
+
if (path === null) return;
|
|
4888
|
+
const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
|
|
4889
|
+
if (!stat3.ok) {
|
|
4890
|
+
const refusal = toWireRefusal(stat3.reason);
|
|
4891
|
+
return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
|
|
4731
4892
|
}
|
|
4732
|
-
if (
|
|
4733
|
-
return sendError(
|
|
4734
|
-
res,
|
|
4735
|
-
400,
|
|
4736
|
-
"bad-request",
|
|
4737
|
-
t("web.workspace_defer_needs_create", void 0, lang)
|
|
4738
|
-
);
|
|
4893
|
+
if (stat3.kind !== "text") {
|
|
4894
|
+
return sendError(res, 415, "not-text", t("web.file_view_not_text", void 0, lang));
|
|
4739
4895
|
}
|
|
4740
|
-
if (
|
|
4741
|
-
|
|
4742
|
-
const bound = ctx.runtime.workspaces.of(sessionId);
|
|
4743
|
-
return sendJson(res, 200, {
|
|
4744
|
-
sessionId,
|
|
4745
|
-
created: false,
|
|
4746
|
-
workspace: bound ? toWireSessionWorkspace(bound) : null
|
|
4747
|
-
});
|
|
4896
|
+
if (stat3.bytes > MAX_TEXT_BYTES) {
|
|
4897
|
+
return sendError(res, 413, "too-large", describeRefusal("too-large", lang));
|
|
4748
4898
|
}
|
|
4749
|
-
const
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
reuse: payload["create"] !== true,
|
|
4754
|
-
...role === "" ? {} : { role }
|
|
4755
|
-
});
|
|
4756
|
-
if (!outcome.ok) {
|
|
4757
|
-
if (outcome.reason === "unavailable") {
|
|
4758
|
-
const mapped2 = unavailableToHttp(lang);
|
|
4759
|
-
return sendError(res, mapped2.status, mapped2.code, mapped2.message);
|
|
4760
|
-
}
|
|
4761
|
-
if (outcome.reason === "unknown-role") {
|
|
4762
|
-
const mapped2 = unknownRoleToHttp(outcome.detail, lang);
|
|
4763
|
-
return sendError(res, mapped2.status, mapped2.code, mapped2.message);
|
|
4764
|
-
}
|
|
4765
|
-
const mapped = bindFailureToHttp(outcome, lang);
|
|
4766
|
-
return sendError(res, mapped.status, mapped.code, mapped.message);
|
|
4899
|
+
const out = ctx.runtime.workspaceFiles.readFile(stat3.rel, root);
|
|
4900
|
+
if (!out.ok) {
|
|
4901
|
+
const refusal = out.reason === "binary" ? "unreadable" : out.reason;
|
|
4902
|
+
return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
|
|
4767
4903
|
}
|
|
4768
|
-
ctx.hub.register(outcome.live.session);
|
|
4769
|
-
if (defer) ctx.runtime.workspaces.defer(outcome.live.sessionId);
|
|
4770
|
-
if (outcome.created) ctx.hub.notifySessionsChanged(outcome.live.sessionId);
|
|
4771
4904
|
sendJson(res, 200, {
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4905
|
+
path,
|
|
4906
|
+
text: out.text,
|
|
4907
|
+
bytes: out.bytes
|
|
4775
4908
|
});
|
|
4776
4909
|
}
|
|
4777
|
-
function
|
|
4778
|
-
|
|
4779
|
-
|
|
4910
|
+
function readFileBytes(ctx, res, sessionId, rawUrl, headOnly, lang) {
|
|
4911
|
+
const root = resolveViewRoot(ctx, res, sessionId, lang);
|
|
4912
|
+
if (root === null) return;
|
|
4913
|
+
const path = singlePath(res, rawUrl, lang);
|
|
4914
|
+
if (path === null) return;
|
|
4915
|
+
const stat3 = ctx.runtime.workspaceFiles.statFile(path, root);
|
|
4916
|
+
if (!stat3.ok) {
|
|
4917
|
+
const refusal = toWireRefusal(stat3.reason);
|
|
4918
|
+
return sendError(res, statusFor(refusal), refusal, describeRefusal(refusal, lang));
|
|
4780
4919
|
}
|
|
4781
|
-
const
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4920
|
+
const known = VIEWABLE.get(extname(stat3.rel).toLowerCase());
|
|
4921
|
+
const inline = known !== void 0 && stat3.kind === "binary";
|
|
4922
|
+
const headers = {
|
|
4923
|
+
"Content-Type": inline ? known.mime : "application/octet-stream",
|
|
4924
|
+
"X-Content-Type-Options": "nosniff",
|
|
4925
|
+
"Content-Disposition": inline ? "inline" : `attachment; filename*=UTF-8''${encodeURIComponent(basenameOf(stat3.rel))}`
|
|
4926
|
+
};
|
|
4927
|
+
if (inline && known.kind === "pdf") headers["Content-Security-Policy"] = "sandbox";
|
|
4928
|
+
if (headOnly) {
|
|
4929
|
+
return sendEmpty(res, 200, {
|
|
4930
|
+
...headers,
|
|
4931
|
+
"Content-Length": String(stat3.bytes)
|
|
4785
4932
|
});
|
|
4786
4933
|
}
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4934
|
+
sendFile(res, stat3.absPath, headers);
|
|
4935
|
+
}
|
|
4936
|
+
function basenameOf(rel) {
|
|
4937
|
+
const cut = rel.lastIndexOf("/");
|
|
4938
|
+
return cut < 0 ? rel : rel.slice(cut + 1);
|
|
4939
|
+
}
|
|
4940
|
+
// src/workspace/file-open.ts
|
|
4941
|
+
function describeOpenRefusal(refusal, lang) {
|
|
4942
|
+
if (refusal === "extension-not-allowed") {
|
|
4943
|
+
return t("web.file_open_extension_not_allowed", void 0, lang);
|
|
4791
4944
|
}
|
|
4792
|
-
|
|
4945
|
+
if (refusal === "not-same-machine") return t("web.file_open_not_same_machine", void 0, lang);
|
|
4946
|
+
if (refusal === "launch-failed") return t("web.file_open_launch_failed", void 0, lang);
|
|
4947
|
+
return describeRefusal(refusal, lang);
|
|
4793
4948
|
}
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4949
|
+
function refuse(res, refusal, lang) {
|
|
4950
|
+
sendJson(res, 200, {
|
|
4951
|
+
ok: false,
|
|
4952
|
+
refusal,
|
|
4953
|
+
message: describeOpenRefusal(refusal, lang)
|
|
4954
|
+
});
|
|
4955
|
+
}
|
|
4956
|
+
async function openFileWithSystemApp(ctx, req, res, sessionId, lang) {
|
|
4957
|
+
if (!ctx.sameMachine) {
|
|
4958
|
+
return sendError(res, 403, "not-same-machine", describeOpenRefusal("not-same-machine", lang));
|
|
4797
4959
|
}
|
|
4960
|
+
const root = resolveViewRoot(ctx, res, sessionId, lang);
|
|
4961
|
+
if (root === null) return;
|
|
4798
4962
|
const body = await readJsonBody(req);
|
|
4799
4963
|
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
4800
|
-
const
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
return sendError(
|
|
4805
|
-
res,
|
|
4806
|
-
409,
|
|
4807
|
-
"workspace-locked",
|
|
4808
|
-
t("web.workspace_already_decided", void 0, lang)
|
|
4809
|
-
);
|
|
4964
|
+
const payload = asRecord(body.value);
|
|
4965
|
+
const path = typeof payload["path"] === "string" ? payload["path"].trim() : "";
|
|
4966
|
+
if (path === "") {
|
|
4967
|
+
return sendError(res, 400, "missing-path", t("web.file_view_missing_path", void 0, lang));
|
|
4810
4968
|
}
|
|
4811
|
-
const
|
|
4812
|
-
if (
|
|
4813
|
-
|
|
4814
|
-
|
|
4969
|
+
const plan = ctx.runtime.workspaceFiles.planSystemOpen(path, root);
|
|
4970
|
+
if (!plan.ok) return refuse(res, plan.reason, lang);
|
|
4971
|
+
try {
|
|
4972
|
+
const child = spawn(plan.command, [...plan.args], { stdio: "ignore", detached: true });
|
|
4973
|
+
await new Promise((done, fail) => {
|
|
4974
|
+
child.on("error", fail);
|
|
4975
|
+
child.on("spawn", () => {
|
|
4976
|
+
child.unref();
|
|
4977
|
+
done();
|
|
4978
|
+
});
|
|
4815
4979
|
});
|
|
4980
|
+
} catch {
|
|
4981
|
+
return refuse(res, "launch-failed", lang);
|
|
4816
4982
|
}
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4983
|
+
sendJson(res, 200, { ok: true });
|
|
4984
|
+
}
|
|
4985
|
+
async function forgetWorkspace(ctx, sessionId, req, res, lang) {
|
|
4986
|
+
const body = await readJsonBody(req, void 0, lang);
|
|
4987
|
+
if (!body.ok) return sendError(res, body.status, "bad-body", body.message);
|
|
4988
|
+
if (!conformsToWire(body.value, WIRE_FORGET_WORKSPACE_FIELDS)) {
|
|
4989
|
+
return sendError(res, 400, "bad-request", t("web.forget_bad_request", {}, lang));
|
|
4822
4990
|
}
|
|
4823
|
-
const
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
return sendError(res,
|
|
4991
|
+
const { root } = body.value;
|
|
4992
|
+
const workspaces = ctx.runtime.workspaces;
|
|
4993
|
+
if (!workspaces.known().some((w) => w.root === root)) {
|
|
4994
|
+
return sendError(res, 404, "unknown-workspace", t("web.forget_unknown", {}, lang));
|
|
4995
|
+
}
|
|
4996
|
+
const current = workspaces.of(sessionId)?.workspace.root;
|
|
4997
|
+
if (current !== void 0 && current === root) {
|
|
4998
|
+
return sendError(res, 409, "workspace-in-use", t("web.forget_in_use", {}, lang));
|
|
4827
4999
|
}
|
|
5000
|
+
workspaces.forget(root);
|
|
4828
5001
|
sendJson(res, 200, {
|
|
4829
|
-
|
|
5002
|
+
known: toWireTrustList(workspaces.known(), workspaces, current ?? null)
|
|
4830
5003
|
});
|
|
4831
5004
|
}
|
|
4832
|
-
async function sessionDiff(ctx, res, sessionId, _lang) {
|
|
4833
|
-
if (!ctx.hub.has(sessionId) && !ctx.runtime.sessions?.get(sessionId)) {
|
|
4834
|
-
return sendUnknownSession(res, sessionId);
|
|
4835
|
-
}
|
|
4836
|
-
const bound = ctx.runtime.workspaces.of(sessionId);
|
|
4837
|
-
if (!bound) {
|
|
4838
|
-
return sendJson(res, 200, {
|
|
4839
|
-
source: "none",
|
|
4840
|
-
root: "",
|
|
4841
|
-
files: [],
|
|
4842
|
-
truncated: false,
|
|
4843
|
-
notes: ["no-workspace"]
|
|
4844
|
-
});
|
|
4845
|
-
}
|
|
4846
|
-
sendJson(
|
|
4847
|
-
res,
|
|
4848
|
-
200,
|
|
4849
|
-
await collectWorkspaceDiff({
|
|
4850
|
-
workDir: bound.workspace.root,
|
|
4851
|
-
checkpoints: checkpointsOf(ctx, sessionId)
|
|
4852
|
-
})
|
|
4853
|
-
);
|
|
4854
|
-
}
|
|
4855
5005
|
function isKnownRoot(ctx, sessionId, root) {
|
|
4856
5006
|
if (ctx.runtime.workspaces.of(sessionId)?.workspace.root === root) return true;
|
|
4857
5007
|
return ctx.runtime.workspaces.known().some((w) => w.root === root);
|
|
@@ -4951,6 +5101,15 @@ async function handleApi(opts, req, res, method, segments, lang) {
|
|
|
4951
5101
|
if (resource === "last-session" && first === void 0 && method === "PUT") {
|
|
4952
5102
|
return putLastSession(ctx, req, res, lang);
|
|
4953
5103
|
}
|
|
5104
|
+
if (resource === "host" && first === "compose" && second === void 0 && method === "POST") {
|
|
5105
|
+
return postHostCompose(ctx, req, res, lang);
|
|
5106
|
+
}
|
|
5107
|
+
if (resource === "host" && first === "compose" && second === void 0 && isRead(method)) {
|
|
5108
|
+
return getHostCompose(ctx, res);
|
|
5109
|
+
}
|
|
5110
|
+
if (resource === "host" && first === "compose" && second !== void 0 && detail === "take" && method === "POST") {
|
|
5111
|
+
return takeHostCompose(ctx, res, second);
|
|
5112
|
+
}
|
|
4954
5113
|
if (resource === "sessions") {
|
|
4955
5114
|
return handleSessions(opts, req, res, method, first, second, detail, lang);
|
|
4956
5115
|
}
|
|
@@ -5330,6 +5489,7 @@ function buildHandler(opts, hub, binding, port, webRoot) {
|
|
|
5330
5489
|
return createRequestHandler({
|
|
5331
5490
|
ctx: {
|
|
5332
5491
|
hub,
|
|
5492
|
+
hostCompose: new HostComposeStore(),
|
|
5333
5493
|
runtime: opts.runtime,
|
|
5334
5494
|
version: opts.version,
|
|
5335
5495
|
artifactsRoot: opts.artifactsRoot ?? opts.runtime.artifactsRoot,
|