dsh-remote-bridge 0.0.0-stage → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.en.md +312 -0
- package/README.md +308 -2
- package/assets/app-qr-gitee.png +0 -0
- package/assets/app-qr-gitee.svg +1 -0
- package/assets/app-qr-github.png +0 -0
- package/assets/app-qr-github.svg +1 -0
- package/cordis.patch.yml +16 -0
- package/docs/api/asyncapi.yaml +86 -0
- package/docs/api/openapi.yaml +1065 -0
- package/kotlin-sdk/README.md +28 -0
- package/kotlin-sdk/build.gradle.kts +19 -0
- package/kotlin-sdk/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/kotlin-sdk/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/kotlin-sdk/gradlew +252 -0
- package/kotlin-sdk/gradlew.bat +94 -0
- package/kotlin-sdk/settings.gradle.kts +1 -0
- package/kotlin-sdk/src/main/kotlin/ai/deepseek/dsh/workspace/DshWorkspaceClient.kt +691 -0
- package/lib/client.cjs +30855 -0
- package/lib/index.d.ts +1261 -0
- package/lib/index.js +4008 -0
- package/package.json +108 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,4008 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
|
+
import { chmod, copyFile, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { createReadStream, mkdirSync } from "node:fs";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { isIP } from "node:net";
|
|
8
|
+
import os, { hostname, networkInterfaces } from "node:os";
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
11
|
+
//#region src/shared/contracts.ts
|
|
12
|
+
const DEVICE_SCOPES = [
|
|
13
|
+
"chat.read",
|
|
14
|
+
"chat.write",
|
|
15
|
+
"files.read",
|
|
16
|
+
"files.write",
|
|
17
|
+
"files.delete",
|
|
18
|
+
"settings.read",
|
|
19
|
+
"settings.write"
|
|
20
|
+
];
|
|
21
|
+
const ADMIN_PRINCIPAL = {
|
|
22
|
+
kind: "admin",
|
|
23
|
+
id: "loopback-admin",
|
|
24
|
+
scopes: new Set(DEVICE_SCOPES),
|
|
25
|
+
rootIds: "all"
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/host/errors.ts
|
|
29
|
+
var ApiError = class extends Error {
|
|
30
|
+
status;
|
|
31
|
+
code;
|
|
32
|
+
details;
|
|
33
|
+
constructor(status, code, message, details) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.status = status;
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.details = details;
|
|
38
|
+
this.name = "ApiError";
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
function asApiError(error) {
|
|
42
|
+
if (error instanceof ApiError) return error;
|
|
43
|
+
return new ApiError(500, "INTERNAL_ERROR", "An internal server error occurred.");
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/host/rate-limit.ts
|
|
47
|
+
var FixedWindowLimiter = class {
|
|
48
|
+
limit;
|
|
49
|
+
windowMs;
|
|
50
|
+
buckets = /* @__PURE__ */ new Map();
|
|
51
|
+
constructor(limit, windowMs) {
|
|
52
|
+
this.limit = limit;
|
|
53
|
+
this.windowMs = windowMs;
|
|
54
|
+
}
|
|
55
|
+
consume(key) {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const current = this.buckets.get(key);
|
|
58
|
+
if (current === void 0 || current.resetAt <= now) {
|
|
59
|
+
this.buckets.set(key, {
|
|
60
|
+
count: 1,
|
|
61
|
+
resetAt: now + this.windowMs
|
|
62
|
+
});
|
|
63
|
+
this.sweep(now);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
current.count += 1;
|
|
67
|
+
if (current.count > this.limit) throw new ApiError(429, "RATE_LIMITED", "Too many requests. Try again later.", { retryAfterMs: current.resetAt - now });
|
|
68
|
+
}
|
|
69
|
+
sweep(now) {
|
|
70
|
+
if (this.buckets.size < 2048) return;
|
|
71
|
+
for (const [key, bucket] of this.buckets) if (bucket.resetAt <= now) this.buckets.delete(key);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/host/auth.ts
|
|
76
|
+
var AuthService = class {
|
|
77
|
+
database;
|
|
78
|
+
authFailures = new FixedWindowLimiter(20, 3e5);
|
|
79
|
+
pairingAttempts = new FixedWindowLimiter(10, 6e5);
|
|
80
|
+
requests = new FixedWindowLimiter(120, 6e4);
|
|
81
|
+
constructor(database) {
|
|
82
|
+
this.database = database;
|
|
83
|
+
}
|
|
84
|
+
principal(req, options = {}) {
|
|
85
|
+
const remote = requestAddress(req);
|
|
86
|
+
this.requests.consume(remote);
|
|
87
|
+
if (options.allowAdmin === true && req.headers["x-dsh-workspace-admin"] === "1" && isLoopbackAddress(remote) && isLoopbackOrigin(req.headers.origin)) return ADMIN_PRINCIPAL;
|
|
88
|
+
const authorization = req.headers.authorization;
|
|
89
|
+
if (authorization === void 0 || !authorization.startsWith("Bearer ")) {
|
|
90
|
+
this.authFailures.consume(remote);
|
|
91
|
+
throw new ApiError(401, "AUTH_REQUIRED", "A valid Bearer token is required.");
|
|
92
|
+
}
|
|
93
|
+
const token = authorization.slice(7).trim();
|
|
94
|
+
const device = token === "" ? void 0 : this.database.authenticate(token);
|
|
95
|
+
if (device === void 0) {
|
|
96
|
+
this.authFailures.consume(remote);
|
|
97
|
+
throw new ApiError(401, "TOKEN_INVALID", "The device token is invalid or revoked.");
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
kind: "device",
|
|
101
|
+
id: device.id,
|
|
102
|
+
name: device.name,
|
|
103
|
+
scopes: new Set(device.scopes),
|
|
104
|
+
rootIds: new Set(device.rootIds)
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
requireAdmin(req) {
|
|
108
|
+
if (!isLoopbackAddress(requestAddress(req)) || !isLoopbackOrigin(req.headers.origin)) throw new ApiError(403, "ADMIN_LOOPBACK_REQUIRED", "This operation is available only from the local machine.");
|
|
109
|
+
return ADMIN_PRINCIPAL;
|
|
110
|
+
}
|
|
111
|
+
requireScope(principal, scope) {
|
|
112
|
+
if (!principal.scopes.has(scope)) throw new ApiError(403, "SCOPE_REQUIRED", `The ${scope} scope is required.`);
|
|
113
|
+
}
|
|
114
|
+
requireRoot(principal, rootId) {
|
|
115
|
+
if (principal.rootIds !== "all" && !principal.rootIds.has(rootId)) throw new ApiError(403, "ROOT_FORBIDDEN", "This device is not authorized for the requested root.");
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
function requestAddress(req) {
|
|
119
|
+
return req.socket.remoteAddress ?? "unknown";
|
|
120
|
+
}
|
|
121
|
+
function isLoopbackAddress(address) {
|
|
122
|
+
const normalized = address.toLowerCase().split("%")[0];
|
|
123
|
+
return normalized === "127.0.0.1" || normalized === "::1" || normalized === "::ffff:127.0.0.1";
|
|
124
|
+
}
|
|
125
|
+
function isLoopbackOrigin(origin) {
|
|
126
|
+
if (origin === void 0) return true;
|
|
127
|
+
try {
|
|
128
|
+
const hostname = new URL(origin).hostname.toLowerCase();
|
|
129
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/host/path-policy.ts
|
|
136
|
+
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
137
|
+
function validateRelativePath(value) {
|
|
138
|
+
if (typeof value !== "string") throw new ApiError(400, "PATH_INVALID", "Relative path must be a string.");
|
|
139
|
+
if (value.includes("\0")) throw new ApiError(400, "PATH_INVALID", "Relative path contains a null byte.");
|
|
140
|
+
if (value.includes("\\")) throw new ApiError(400, "PATH_INVALID", "Wire paths must use forward slashes.");
|
|
141
|
+
if (value.startsWith("/") || value.startsWith("//") || /^[A-Za-z]:/.test(value)) throw new ApiError(400, "PATH_INVALID", "Absolute, drive-letter, and UNC paths are forbidden.");
|
|
142
|
+
if (value === "") return [];
|
|
143
|
+
const segments = value.split("/");
|
|
144
|
+
for (const segment of segments) {
|
|
145
|
+
if (segment === "" || segment === "." || segment === "..") throw new ApiError(400, "PATH_INVALID", "Relative path contains an empty or forbidden segment.");
|
|
146
|
+
if (process.platform === "win32") {
|
|
147
|
+
if (segment.includes(":") || /[. ]$/.test(segment) || WINDOWS_RESERVED.test(segment)) throw new ApiError(400, "PATH_INVALID", "Relative path contains a name unsupported on Windows.");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return segments;
|
|
151
|
+
}
|
|
152
|
+
async function canonicalRoot(input) {
|
|
153
|
+
let canonical;
|
|
154
|
+
try {
|
|
155
|
+
canonical = await realpath(path.resolve(input));
|
|
156
|
+
} catch {
|
|
157
|
+
throw new ApiError(400, "ROOT_INVALID", "The authorized root does not exist or cannot be resolved.");
|
|
158
|
+
}
|
|
159
|
+
if (!(await stat(canonical)).isDirectory()) throw new ApiError(400, "ROOT_INVALID", "The authorized root must be a directory.");
|
|
160
|
+
return canonical;
|
|
161
|
+
}
|
|
162
|
+
async function resolveAuthorizedPath(root, relativePath, options = {}) {
|
|
163
|
+
const segments = validateRelativePath(relativePath);
|
|
164
|
+
let current = root;
|
|
165
|
+
if (segments.length === 0) return {
|
|
166
|
+
absolutePath: root,
|
|
167
|
+
exists: true,
|
|
168
|
+
leafIsLink: false
|
|
169
|
+
};
|
|
170
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
171
|
+
current = path.join(current, segments[index]);
|
|
172
|
+
let info;
|
|
173
|
+
try {
|
|
174
|
+
info = await lstat(current);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const code = error.code;
|
|
177
|
+
const isLeaf = index === segments.length - 1;
|
|
178
|
+
if (code === "ENOENT" && isLeaf && options.allowMissingLeaf === true) return {
|
|
179
|
+
absolutePath: current,
|
|
180
|
+
exists: false,
|
|
181
|
+
leafIsLink: false
|
|
182
|
+
};
|
|
183
|
+
if (code === "ENOENT") throw new ApiError(404, "PATH_NOT_FOUND", "The requested path does not exist.");
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
const isLeaf = index === segments.length - 1;
|
|
187
|
+
if (info.isSymbolicLink()) {
|
|
188
|
+
if (!isLeaf || options.allowLeafLink !== true) throw new ApiError(403, "LINK_TRAVERSAL_FORBIDDEN", "Symbolic links and junctions cannot be traversed.");
|
|
189
|
+
return {
|
|
190
|
+
absolutePath: current,
|
|
191
|
+
exists: true,
|
|
192
|
+
leafIsLink: true
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (!isLeaf && !info.isDirectory()) throw new ApiError(400, "PATH_NOT_DIRECTORY", "An intermediate path segment is not a directory.");
|
|
196
|
+
}
|
|
197
|
+
if (!isPathInside(root, current)) throw new ApiError(403, "PATH_OUTSIDE_ROOT", "The path escaped its authorized root.");
|
|
198
|
+
return {
|
|
199
|
+
absolutePath: current,
|
|
200
|
+
exists: true,
|
|
201
|
+
leafIsLink: false
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function isPathInside(root, candidate) {
|
|
205
|
+
const relative = path.relative(root, candidate);
|
|
206
|
+
return relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
|
|
207
|
+
}
|
|
208
|
+
function wireRelative(root, candidate) {
|
|
209
|
+
if (!isPathInside(root, candidate)) return void 0;
|
|
210
|
+
return path.relative(root, candidate).split(path.sep).join("/");
|
|
211
|
+
}
|
|
212
|
+
function parentWirePath(relativePath) {
|
|
213
|
+
const segments = validateRelativePath(relativePath);
|
|
214
|
+
segments.pop();
|
|
215
|
+
return segments.join("/");
|
|
216
|
+
}
|
|
217
|
+
//#endregion
|
|
218
|
+
//#region src/host/chat-adapter.ts
|
|
219
|
+
var DshChatAdapter = class {
|
|
220
|
+
api;
|
|
221
|
+
database;
|
|
222
|
+
commandServices;
|
|
223
|
+
sessionRootIds = /* @__PURE__ */ new Map();
|
|
224
|
+
sessionRootLookups = /* @__PURE__ */ new Map();
|
|
225
|
+
pendingApprovals = /* @__PURE__ */ new Map();
|
|
226
|
+
constructor(api, database, commandServices) {
|
|
227
|
+
this.api = api;
|
|
228
|
+
this.database = database;
|
|
229
|
+
this.commandServices = commandServices;
|
|
230
|
+
}
|
|
231
|
+
async listSessions(principal) {
|
|
232
|
+
const [sessionsResponse, workspaceResponse] = await Promise.all([this.api.sessions.list({
|
|
233
|
+
rpcId: randomUUID(),
|
|
234
|
+
payload: {}
|
|
235
|
+
}), this.api.workspace.list({
|
|
236
|
+
rpcId: randomUUID(),
|
|
237
|
+
payload: {}
|
|
238
|
+
})]);
|
|
239
|
+
const value = unwrapDsh(sessionsResponse);
|
|
240
|
+
const workspaceValue = unwrapDsh(workspaceResponse);
|
|
241
|
+
const workspaces = this.visibleWorkspaces(principal, workspaceValue.items);
|
|
242
|
+
const archivedSessionIds = new Set(workspaceValue.archivedSessionIds);
|
|
243
|
+
const roots = this.authorizedRoots(principal);
|
|
244
|
+
const visible = [];
|
|
245
|
+
for (const session of value.items) {
|
|
246
|
+
if (archivedSessionIds.has(session.sessionId)) continue;
|
|
247
|
+
const match = session.cwd === void 0 ? void 0 : matchRoot(roots, session.cwd);
|
|
248
|
+
if (match === void 0) continue;
|
|
249
|
+
const workspace = workspaces.find((item) => item.rootId === match.root.id && item.path === match.relative);
|
|
250
|
+
const title = sessionTitle(session);
|
|
251
|
+
visible.push({
|
|
252
|
+
id: session.sessionId,
|
|
253
|
+
rootId: match.root.id,
|
|
254
|
+
cwd: match.relative,
|
|
255
|
+
updatedAt: session.updatedAt,
|
|
256
|
+
running: session.running,
|
|
257
|
+
blank: session.blank,
|
|
258
|
+
...title === void 0 ? {} : { title },
|
|
259
|
+
...workspace === void 0 ? {} : {
|
|
260
|
+
workspaceId: workspace.id,
|
|
261
|
+
workspaceTitle: workspace.title
|
|
262
|
+
},
|
|
263
|
+
...session.agentPreset === void 0 ? {} : { agentPreset: session.agentPreset },
|
|
264
|
+
...session.parentSessionId === void 0 ? {} : { parentSessionId: session.parentSessionId },
|
|
265
|
+
...session.origin === void 0 ? {} : { origin: session.origin },
|
|
266
|
+
...this.hasPendingApproval(session.sessionId) ? { pendingInteraction: "approval" } : {}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return visible;
|
|
270
|
+
}
|
|
271
|
+
async listWorkspaces(principal) {
|
|
272
|
+
const value = unwrapDsh(await this.api.workspace.list({
|
|
273
|
+
rpcId: randomUUID(),
|
|
274
|
+
payload: {}
|
|
275
|
+
}));
|
|
276
|
+
return this.visibleWorkspaces(principal, value.items);
|
|
277
|
+
}
|
|
278
|
+
async createWorkspace(principal, rootId, relativePath) {
|
|
279
|
+
const root = this.requireAuthorizedRoot(principal, rootId);
|
|
280
|
+
const resolved = await resolveAuthorizedPath(root.realPath, relativePath);
|
|
281
|
+
if (!(await lstat(resolved.absolutePath)).isDirectory()) throw new ApiError(400, "WORKSPACE_PATH_NOT_DIRECTORY", "A DSH workspace must use an existing directory.");
|
|
282
|
+
const value = unwrapDsh(await this.api.workspace.create({
|
|
283
|
+
rpcId: randomUUID(),
|
|
284
|
+
payload: { path: resolved.absolutePath }
|
|
285
|
+
}));
|
|
286
|
+
const path = wireRelative(root.realPath, value.workspace.path);
|
|
287
|
+
if (path === void 0) throw new ApiError(403, "WORKSPACE_OUTSIDE_ROOT", "The created DSH workspace is outside the authorized root.");
|
|
288
|
+
return {
|
|
289
|
+
id: value.workspace.workspaceId,
|
|
290
|
+
title: value.workspace.title,
|
|
291
|
+
rootId: root.id,
|
|
292
|
+
path,
|
|
293
|
+
createdAt: value.workspace.createdAt,
|
|
294
|
+
updatedAt: value.workspace.updatedAt
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
async renameWorkspace(principal, workspaceId, title) {
|
|
298
|
+
const workspace = await this.requireWorkspace(principal, workspaceId);
|
|
299
|
+
const normalized = title.trim();
|
|
300
|
+
if (normalized === "") throw new ApiError(400, "WORKSPACE_TITLE_INVALID", "The workspace title cannot be empty.");
|
|
301
|
+
const value = unwrapDsh(await this.api.workspace.rename({
|
|
302
|
+
rpcId: randomUUID(),
|
|
303
|
+
payload: {
|
|
304
|
+
workspaceId,
|
|
305
|
+
title: normalized
|
|
306
|
+
}
|
|
307
|
+
}));
|
|
308
|
+
const renamed = this.visibleWorkspaces(principal, [value.workspace])[0];
|
|
309
|
+
if (renamed === void 0) throw new ApiError(403, "WORKSPACE_OUTSIDE_ROOT", "The renamed DSH workspace is outside the authorized roots.");
|
|
310
|
+
return {
|
|
311
|
+
...renamed,
|
|
312
|
+
rootId: workspace.rootId,
|
|
313
|
+
path: workspace.path
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
async deleteWorkspace(principal, workspaceId) {
|
|
317
|
+
await this.requireWorkspace(principal, workspaceId);
|
|
318
|
+
unwrapDsh(await this.api.workspace.delete({
|
|
319
|
+
rpcId: randomUUID(),
|
|
320
|
+
payload: { workspaceId }
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
async listAgentPresets() {
|
|
324
|
+
return unwrapDsh(await this.api.agentPresets.list({
|
|
325
|
+
rpcId: randomUUID(),
|
|
326
|
+
payload: {}
|
|
327
|
+
})).presets.map((preset) => ({
|
|
328
|
+
id: preset.id,
|
|
329
|
+
name: preset.name?.trim() || preset.id,
|
|
330
|
+
...preset.description === void 0 ? {} : { description: preset.description },
|
|
331
|
+
trust: preset.trust,
|
|
332
|
+
isDefault: preset.isDefault,
|
|
333
|
+
available: preset.broken === void 0
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
async selectAgentPreset(principal, sessionId, agentPreset) {
|
|
337
|
+
const session = await this.requireSession(principal, sessionId);
|
|
338
|
+
const requested = agentPreset.trim();
|
|
339
|
+
if (requested === "") throw new ApiError(400, "AGENT_PRESET_INVALID", "agentPreset is required.");
|
|
340
|
+
if (!session.blank) throw new ApiError(409, "AGENT_PRESET_LOCKED", "This session has already started. Its Agent preset can no longer be changed.");
|
|
341
|
+
return unwrapDsh(await this.api.agentPresets.select({
|
|
342
|
+
rpcId: randomUUID(),
|
|
343
|
+
payload: {
|
|
344
|
+
sessionId,
|
|
345
|
+
agentPreset: requested
|
|
346
|
+
}
|
|
347
|
+
})).agentPreset;
|
|
348
|
+
}
|
|
349
|
+
async createSessionInWorkspace(principal, workspaceId, options) {
|
|
350
|
+
if ((await this.listWorkspaces(principal)).find((item) => item.id === workspaceId) === void 0) throw new ApiError(404, "WORKSPACE_NOT_FOUND", "The DSH workspace is not visible to this device.");
|
|
351
|
+
const payload = {
|
|
352
|
+
workspaceId,
|
|
353
|
+
...options.sessionId === void 0 ? {} : { sessionId: options.sessionId },
|
|
354
|
+
...options.agentPreset === void 0 ? {} : { agentPreset: options.agentPreset }
|
|
355
|
+
};
|
|
356
|
+
const value = unwrapDsh(await this.api.sessions.create({
|
|
357
|
+
rpcId: randomUUID(),
|
|
358
|
+
payload
|
|
359
|
+
}));
|
|
360
|
+
return {
|
|
361
|
+
id: value.sessionId,
|
|
362
|
+
...value.agentPreset === void 0 ? {} : { agentPreset: value.agentPreset }
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
async createSession(principal, rootId, relativePath, options) {
|
|
366
|
+
const payload = {
|
|
367
|
+
cwd: (await resolveAuthorizedPath(this.requireAuthorizedRoot(principal, rootId).realPath, relativePath)).absolutePath,
|
|
368
|
+
...options.sessionId === void 0 ? {} : { sessionId: options.sessionId },
|
|
369
|
+
...options.agentPreset === void 0 ? {} : { agentPreset: options.agentPreset }
|
|
370
|
+
};
|
|
371
|
+
const value = unwrapDsh(await this.api.sessions.create({
|
|
372
|
+
rpcId: randomUUID(),
|
|
373
|
+
payload
|
|
374
|
+
}));
|
|
375
|
+
return {
|
|
376
|
+
id: value.sessionId,
|
|
377
|
+
...value.agentPreset === void 0 ? {} : { agentPreset: value.agentPreset }
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
async history(principal, sessionId, options) {
|
|
381
|
+
await this.requireSession(principal, sessionId);
|
|
382
|
+
const payload = {
|
|
383
|
+
sessionId,
|
|
384
|
+
...options.beforeSeq === void 0 ? {} : { beforeSeq: options.beforeSeq },
|
|
385
|
+
...options.maxMessages === void 0 ? {} : { maxMessages: options.maxMessages }
|
|
386
|
+
};
|
|
387
|
+
return unwrapDsh(await this.api.sessions.history({
|
|
388
|
+
rpcId: randomUUID(),
|
|
389
|
+
payload
|
|
390
|
+
}));
|
|
391
|
+
}
|
|
392
|
+
async prompt(principal, sessionId, parts, mode, clientTimeZone) {
|
|
393
|
+
await this.requireSession(principal, sessionId);
|
|
394
|
+
const hasText = parts.some((part) => part.type === "text" && part.text.trim() !== "");
|
|
395
|
+
const hasAttachment = parts.some((part) => part.type !== "text");
|
|
396
|
+
if (!hasText && !hasAttachment) throw new ApiError(400, "MESSAGE_EMPTY", "A message needs non-empty text or at least one attachment.");
|
|
397
|
+
return unwrapDsh(await this.api.sessions.prompt({
|
|
398
|
+
rpcId: randomUUID(),
|
|
399
|
+
payload: {
|
|
400
|
+
sessionId,
|
|
401
|
+
mode,
|
|
402
|
+
content: parts,
|
|
403
|
+
...clientTimeZone === void 0 ? {} : { clientTimeZone }
|
|
404
|
+
}
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* 上传一个附件,返回 prompt 可以引用的凭证。
|
|
409
|
+
*
|
|
410
|
+
* 为什么需要这一步而不是让客户端直接把字节塞进消息:内核对**文件**类型要求的是
|
|
411
|
+
* `receiptId`(由上传换取),只有**图片**才允许内联 base64。
|
|
412
|
+
*/
|
|
413
|
+
async uploadAttachment(principal, sessionId, data, name) {
|
|
414
|
+
await this.requireSession(principal, sessionId);
|
|
415
|
+
return unwrapDsh(await this.api.sessions.uploadAttachment({
|
|
416
|
+
rpcId: randomUUID(),
|
|
417
|
+
payload: {
|
|
418
|
+
sessionId,
|
|
419
|
+
data,
|
|
420
|
+
...name === void 0 ? {} : { name }
|
|
421
|
+
}
|
|
422
|
+
}));
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* 取回会话历史里某个图片附件的字节。
|
|
426
|
+
*
|
|
427
|
+
* 这里不做会话归属校验:附件 id 是内容哈希,拿到 id 就等于拿到内容,
|
|
428
|
+
* 而且调用方必须已经通过 `files.read` 鉴权并主动持有该 id(它只出现在其有权读取的
|
|
429
|
+
* 会话事件里)。多一层会话校验并不能提升安全性,却会让「在别的会话里引用同一张图」
|
|
430
|
+
* 这种正常情况莫名失败。
|
|
431
|
+
*/
|
|
432
|
+
async readAttachment(principal, attachmentId) {
|
|
433
|
+
if (attachmentId.trim() === "") throw new ApiError(400, "BODY_INVALID", "attachmentId is required.");
|
|
434
|
+
return unwrapDsh(await this.api.sessions.readAttachment({
|
|
435
|
+
rpcId: randomUUID(),
|
|
436
|
+
payload: { attachmentId }
|
|
437
|
+
}));
|
|
438
|
+
}
|
|
439
|
+
async cancel(principal, sessionId) {
|
|
440
|
+
await this.requireSession(principal, sessionId);
|
|
441
|
+
unwrapDsh(await this.api.sessions.cancel({
|
|
442
|
+
rpcId: randomUUID(),
|
|
443
|
+
payload: { sessionId }
|
|
444
|
+
}));
|
|
445
|
+
}
|
|
446
|
+
async renameSession(principal, sessionId, title) {
|
|
447
|
+
await this.requireSession(principal, sessionId);
|
|
448
|
+
const normalized = title.trim();
|
|
449
|
+
if (normalized === "") throw new ApiError(400, "SESSION_TITLE_INVALID", "The session title cannot be empty.");
|
|
450
|
+
return unwrapDsh(await this.api.sessions.rename({
|
|
451
|
+
rpcId: randomUUID(),
|
|
452
|
+
payload: {
|
|
453
|
+
sessionId,
|
|
454
|
+
title: normalized
|
|
455
|
+
}
|
|
456
|
+
}));
|
|
457
|
+
}
|
|
458
|
+
async forkSession(principal, sessionId, atSeq) {
|
|
459
|
+
await this.requireSession(principal, sessionId);
|
|
460
|
+
const value = unwrapDsh(await this.api.sessions.fork({
|
|
461
|
+
rpcId: randomUUID(),
|
|
462
|
+
payload: {
|
|
463
|
+
sessionId,
|
|
464
|
+
...atSeq === void 0 ? {} : { atSeq }
|
|
465
|
+
}
|
|
466
|
+
}));
|
|
467
|
+
if (!await this.isSessionAuthorized(principal, value.sessionId)) throw new ApiError(403, "SESSION_FORK_OUTSIDE_ROOT", "The forked session is outside the authorized roots.");
|
|
468
|
+
return value.sessionId;
|
|
469
|
+
}
|
|
470
|
+
async archiveSession(principal, sessionId) {
|
|
471
|
+
await this.requireSession(principal, sessionId);
|
|
472
|
+
return unwrapDsh(await this.api.workspace.archiveSession({
|
|
473
|
+
rpcId: randomUUID(),
|
|
474
|
+
payload: { sessionId }
|
|
475
|
+
})).archivedSessionIds;
|
|
476
|
+
}
|
|
477
|
+
async listPendingApprovals(principal, sessionId) {
|
|
478
|
+
await this.requireSession(principal, sessionId);
|
|
479
|
+
const records = [...this.pendingApprovals.values()].filter((item) => item.sessionId === sessionId);
|
|
480
|
+
if (records.length === 0) return [];
|
|
481
|
+
let history = [];
|
|
482
|
+
if (records.some((item) => item.callId !== void 0)) try {
|
|
483
|
+
history = unwrapDsh(await this.api.sessions.history({
|
|
484
|
+
rpcId: randomUUID(),
|
|
485
|
+
payload: {
|
|
486
|
+
sessionId,
|
|
487
|
+
maxMessages: 100
|
|
488
|
+
}
|
|
489
|
+
})).events;
|
|
490
|
+
} catch {}
|
|
491
|
+
return records.sort((left, right) => left.requestedAt - right.requestedAt).map((record) => publicApproval(record, findToolCallDetail(history, record.callId)));
|
|
492
|
+
}
|
|
493
|
+
async decideApproval(principal, sessionId, approvalId, outcome) {
|
|
494
|
+
await this.requireSession(principal, sessionId);
|
|
495
|
+
const key = approvalKey(sessionId, approvalId);
|
|
496
|
+
const pending = this.pendingApprovals.get(key);
|
|
497
|
+
if (pending === void 0) throw new ApiError(409, "APPROVAL_NOT_PENDING", "This approval is no longer pending.");
|
|
498
|
+
if (!(await this.api.respond({
|
|
499
|
+
type: "client-response",
|
|
500
|
+
rpcId: pending.rpcId,
|
|
501
|
+
result: {
|
|
502
|
+
ok: true,
|
|
503
|
+
value: {
|
|
504
|
+
sessionId,
|
|
505
|
+
approvalId,
|
|
506
|
+
outcome
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
})).accepted) {
|
|
510
|
+
this.pendingApprovals.delete(key);
|
|
511
|
+
throw new ApiError(409, "APPROVAL_NOT_PENDING", "This approval is no longer pending.");
|
|
512
|
+
}
|
|
513
|
+
this.pendingApprovals.delete(key);
|
|
514
|
+
return publicApproval(pending);
|
|
515
|
+
}
|
|
516
|
+
async models(principal, sessionId) {
|
|
517
|
+
await this.requireSession(principal, sessionId);
|
|
518
|
+
return unwrapDsh(await this.api.sessions.models({
|
|
519
|
+
rpcId: randomUUID(),
|
|
520
|
+
payload: { sessionId }
|
|
521
|
+
}));
|
|
522
|
+
}
|
|
523
|
+
async selectModel(principal, sessionId, selection) {
|
|
524
|
+
await this.requireSession(principal, sessionId);
|
|
525
|
+
if (selection.provider.trim() === "" || selection.model.trim() === "") throw new ApiError(400, "MODEL_SELECTION_INVALID", "provider and model are required.");
|
|
526
|
+
const payload = {
|
|
527
|
+
sessionId,
|
|
528
|
+
provider: selection.provider,
|
|
529
|
+
model: selection.model,
|
|
530
|
+
...selection.reasoningEffort === void 0 ? {} : { reasoningEffort: selection.reasoningEffort }
|
|
531
|
+
};
|
|
532
|
+
return unwrapDsh(await this.api.sessions.selectModel({
|
|
533
|
+
rpcId: randomUUID(),
|
|
534
|
+
payload
|
|
535
|
+
})).selected;
|
|
536
|
+
}
|
|
537
|
+
async listCommands(principal, sessionId) {
|
|
538
|
+
const agent = await this.commandAgent(principal, sessionId);
|
|
539
|
+
if (agent === void 0) return [];
|
|
540
|
+
return this.commandServices.commands.list(agent).map((command) => ({
|
|
541
|
+
name: command.name,
|
|
542
|
+
description: command.description,
|
|
543
|
+
...command.input === void 0 ? {} : { input: { hint: command.input.hint } }
|
|
544
|
+
}));
|
|
545
|
+
}
|
|
546
|
+
async executeCommand(principal, sessionId, line) {
|
|
547
|
+
const normalized = line.trimEnd();
|
|
548
|
+
if (normalized.length === 0 || normalized.length > 16384 || normalized.includes("\0")) throw new ApiError(400, "COMMAND_INVALID", "The command line must contain 1 to 16384 characters and no null bytes.");
|
|
549
|
+
const match = /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u.exec(normalized);
|
|
550
|
+
if (match === null) throw new ApiError(400, "COMMAND_INVALID", "A slash command must start with a valid command name.");
|
|
551
|
+
const agent = await this.commandAgent(principal, sessionId);
|
|
552
|
+
if (agent === void 0) throw new ApiError(409, "COMMANDS_UNAVAILABLE", "The session could not be activated for command execution.");
|
|
553
|
+
if (!this.commandServices.commands.list(agent).some((command) => command.name === match[1])) throw new ApiError(404, "COMMAND_NOT_FOUND", "The command is not available for this session.");
|
|
554
|
+
try {
|
|
555
|
+
const execution = await this.commandServices.commands.execute(agent, normalized, new AbortController().signal);
|
|
556
|
+
if (execution === void 0) throw new ApiError(404, "COMMAND_NOT_FOUND", "The command is no longer available for this session.");
|
|
557
|
+
return {
|
|
558
|
+
commandId: String(execution.commandId),
|
|
559
|
+
result: {
|
|
560
|
+
kind: execution.result.kind,
|
|
561
|
+
...execution.result.text === void 0 ? {} : { text: execution.result.text },
|
|
562
|
+
...execution.result.sourceEventSeq === void 0 ? {} : { sourceEventSeq: execution.result.sourceEventSeq }
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
} catch (error) {
|
|
566
|
+
if (error instanceof ApiError) throw error;
|
|
567
|
+
throw new ApiError(409, "COMMAND_FAILED", error instanceof Error ? error.message : String(error));
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
async isSessionAuthorized(principal, sessionId) {
|
|
571
|
+
const rootId = await this.sessionRootId(sessionId);
|
|
572
|
+
return rootId !== void 0 && (principal.rootIds === "all" || principal.rootIds.has(rootId));
|
|
573
|
+
}
|
|
574
|
+
startEvents(signal, emit) {
|
|
575
|
+
const run = async (kind) => {
|
|
576
|
+
const stream = this.api.events[kind]({
|
|
577
|
+
rpcId: randomUUID(),
|
|
578
|
+
payload: {}
|
|
579
|
+
}, signal);
|
|
580
|
+
for await (const frame of stream) {
|
|
581
|
+
if (signal.aborted) break;
|
|
582
|
+
this.captureApproval(frame, kind);
|
|
583
|
+
emit(normalizeDshEvent(frame, kind));
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
run("mux").catch(() => void 0);
|
|
587
|
+
run("host").catch(() => void 0);
|
|
588
|
+
}
|
|
589
|
+
captureApproval(frame, kind) {
|
|
590
|
+
if (kind !== "mux") return;
|
|
591
|
+
const payload = frame.payload;
|
|
592
|
+
if (payload.type === "approval/requested" && typeof payload.sessionId === "string" && typeof payload.approvalId === "string" && typeof payload.toolName === "string") {
|
|
593
|
+
const key = approvalKey(payload.sessionId, payload.approvalId);
|
|
594
|
+
const previous = this.pendingApprovals.get(key);
|
|
595
|
+
this.pendingApprovals.set(key, {
|
|
596
|
+
rpcId: frame.rpcId,
|
|
597
|
+
sessionId: payload.sessionId,
|
|
598
|
+
approvalId: payload.approvalId,
|
|
599
|
+
toolName: redactApprovalText(payload.toolName, 120),
|
|
600
|
+
...typeof payload.callId === "string" ? { callId: payload.callId } : {},
|
|
601
|
+
...typeof payload.reason === "string" ? { reason: payload.reason } : {},
|
|
602
|
+
requestedAt: previous?.requestedAt ?? Date.now()
|
|
603
|
+
});
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (payload.type === "approval/resolved" && typeof payload.sessionId === "string" && typeof payload.approvalId === "string") this.pendingApprovals.delete(approvalKey(payload.sessionId, payload.approvalId));
|
|
607
|
+
}
|
|
608
|
+
hasPendingApproval(sessionId) {
|
|
609
|
+
for (const item of this.pendingApprovals.values()) if (item.sessionId === sessionId) return true;
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Resolve the Agent backing a session for command work.
|
|
614
|
+
*
|
|
615
|
+
* Returns `undefined` when the session exists but cannot be activated. 0.2.x
|
|
616
|
+
* rejects activation for a Session owned by a subagent, so that outcome is a
|
|
617
|
+
* property of the session rather than a failure of this call: the caller
|
|
618
|
+
* decides whether it means "no commands" (listing) or an error (execution).
|
|
619
|
+
*/
|
|
620
|
+
async commandAgent(principal, sessionId) {
|
|
621
|
+
await this.requireSession(principal, sessionId);
|
|
622
|
+
const services = this.commandServices;
|
|
623
|
+
if (services === void 0) throw new ApiError(503, "COMMANDS_UNAVAILABLE", "This DSH composition does not expose the command runtime.");
|
|
624
|
+
let agent = services.agents.get(sessionId);
|
|
625
|
+
if (agent !== void 0) return agent;
|
|
626
|
+
try {
|
|
627
|
+
unwrapDsh(await this.api.sessions.models({
|
|
628
|
+
rpcId: randomUUID(),
|
|
629
|
+
payload: { sessionId }
|
|
630
|
+
}));
|
|
631
|
+
} catch {}
|
|
632
|
+
return services.agents.get(sessionId);
|
|
633
|
+
}
|
|
634
|
+
async requireSession(principal, sessionId) {
|
|
635
|
+
const session = (await this.listSessions(principal)).find((item) => item.id === sessionId);
|
|
636
|
+
if (session === void 0) throw new ApiError(404, "SESSION_NOT_FOUND", "The session is not visible to this device.");
|
|
637
|
+
return session;
|
|
638
|
+
}
|
|
639
|
+
async requireWorkspace(principal, workspaceId) {
|
|
640
|
+
const workspace = (await this.listWorkspaces(principal)).find((item) => item.id === workspaceId);
|
|
641
|
+
if (workspace === void 0) throw new ApiError(404, "WORKSPACE_NOT_FOUND", "The DSH workspace is not visible to this device.");
|
|
642
|
+
return workspace;
|
|
643
|
+
}
|
|
644
|
+
visibleWorkspaces(principal, items) {
|
|
645
|
+
const roots = this.authorizedRoots(principal);
|
|
646
|
+
const visible = [];
|
|
647
|
+
for (const workspace of items) {
|
|
648
|
+
const match = matchRoot(roots, workspace.path);
|
|
649
|
+
if (match === void 0) continue;
|
|
650
|
+
visible.push({
|
|
651
|
+
id: workspace.workspaceId,
|
|
652
|
+
title: workspace.title,
|
|
653
|
+
rootId: match.root.id,
|
|
654
|
+
path: match.relative,
|
|
655
|
+
createdAt: workspace.createdAt,
|
|
656
|
+
updatedAt: workspace.updatedAt
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
return visible;
|
|
660
|
+
}
|
|
661
|
+
requireAuthorizedRoot(principal, rootId) {
|
|
662
|
+
if (principal.rootIds !== "all" && !principal.rootIds.has(rootId)) throw new ApiError(403, "ROOT_FORBIDDEN", "This device is not authorized for the requested root.");
|
|
663
|
+
const root = this.database.getRoot(rootId);
|
|
664
|
+
if (root === void 0) throw new ApiError(404, "ROOT_NOT_FOUND", "The authorized root does not exist.");
|
|
665
|
+
return root;
|
|
666
|
+
}
|
|
667
|
+
authorizedRoots(principal) {
|
|
668
|
+
const all = this.database.listRoots();
|
|
669
|
+
return principal.rootIds === "all" ? all : all.filter((root) => principal.rootIds.has(root.id));
|
|
670
|
+
}
|
|
671
|
+
async sessionRootId(sessionId) {
|
|
672
|
+
const cached = this.sessionRootIds.get(sessionId);
|
|
673
|
+
if (cached !== void 0) {
|
|
674
|
+
if (this.database.getRoot(cached) !== void 0) return cached;
|
|
675
|
+
this.sessionRootIds.delete(sessionId);
|
|
676
|
+
}
|
|
677
|
+
const pending = this.sessionRootLookups.get(sessionId);
|
|
678
|
+
if (pending !== void 0) return pending;
|
|
679
|
+
const lookup = (async () => {
|
|
680
|
+
const session = unwrapDsh(await this.api.sessions.list({
|
|
681
|
+
rpcId: randomUUID(),
|
|
682
|
+
payload: {}
|
|
683
|
+
})).items.find((item) => item.sessionId === sessionId);
|
|
684
|
+
if (session?.cwd === void 0) return void 0;
|
|
685
|
+
const match = matchRoot(this.database.listRoots(), session.cwd);
|
|
686
|
+
if (match === void 0) return void 0;
|
|
687
|
+
this.sessionRootIds.set(sessionId, match.root.id);
|
|
688
|
+
return match.root.id;
|
|
689
|
+
})();
|
|
690
|
+
this.sessionRootLookups.set(sessionId, lookup);
|
|
691
|
+
try {
|
|
692
|
+
return await lookup;
|
|
693
|
+
} finally {
|
|
694
|
+
if (this.sessionRootLookups.get(sessionId) === lookup) this.sessionRootLookups.delete(sessionId);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
function unwrapDsh(response) {
|
|
699
|
+
if (response.result.ok) return response.result.value;
|
|
700
|
+
const error = response.result.error;
|
|
701
|
+
throw new ApiError(error.code.includes("not-found") ? 404 : error.code.includes("busy") || error.code.includes("locked") ? 409 : 400, `DSH_${error.code.replaceAll("-", "_").toUpperCase()}`, error.message);
|
|
702
|
+
}
|
|
703
|
+
function normalizeDshEvent(frame, fallback, time = Date.now()) {
|
|
704
|
+
const payload = frame.payload;
|
|
705
|
+
const payloadType = typeof payload.type === "string" ? payload.type : fallback;
|
|
706
|
+
if (payloadType === "approval/requested" && typeof payload.sessionId === "string" && typeof payload.approvalId === "string" && typeof payload.toolName === "string") return {
|
|
707
|
+
id: `${payload.approvalId}:requested`,
|
|
708
|
+
type: "chat.approval.requested",
|
|
709
|
+
time,
|
|
710
|
+
data: {
|
|
711
|
+
sessionId: payload.sessionId,
|
|
712
|
+
approvalId: payload.approvalId,
|
|
713
|
+
toolName: payload.toolName,
|
|
714
|
+
risk: approvalRisk(typeof payload.reason === "string" ? payload.reason : void 0)
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
if (payloadType === "approval/resolved" && typeof payload.sessionId === "string" && typeof payload.approvalId === "string") return {
|
|
718
|
+
id: `${payload.approvalId}:resolved`,
|
|
719
|
+
type: "chat.approval.resolved",
|
|
720
|
+
time,
|
|
721
|
+
data: {
|
|
722
|
+
sessionId: payload.sessionId,
|
|
723
|
+
approvalId: payload.approvalId,
|
|
724
|
+
...typeof payload.outcome === "string" ? { outcome: payload.outcome } : {}
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
if (payloadType === "session/event" && typeof payload.sessionId === "string" && isRecord$1(payload.event)) {
|
|
728
|
+
const event = payload.event;
|
|
729
|
+
const eventType = typeof event.type === "string" ? event.type : "unknown";
|
|
730
|
+
const seq = typeof event.seq === "number" ? event.seq : void 0;
|
|
731
|
+
const base = {
|
|
732
|
+
sessionId: payload.sessionId,
|
|
733
|
+
eventType,
|
|
734
|
+
...seq === void 0 ? {} : { seq }
|
|
735
|
+
};
|
|
736
|
+
const assistantData = event.data;
|
|
737
|
+
if (eventType === "assistant/chunk" && isRecord$1(assistantData)) {
|
|
738
|
+
const rawChunk = assistantData.chunk;
|
|
739
|
+
if (isRecord$1(rawChunk)) {
|
|
740
|
+
const kind = rawChunk.type === "text-delta" ? "text" : rawChunk.type === "reasoning-delta" ? "reasoning" : void 0;
|
|
741
|
+
if (kind !== void 0 && typeof rawChunk.text === "string" && typeof rawChunk.index === "number") return {
|
|
742
|
+
id: frame.rpcId,
|
|
743
|
+
type: "chat.message.delta",
|
|
744
|
+
time,
|
|
745
|
+
data: {
|
|
746
|
+
...base,
|
|
747
|
+
kind,
|
|
748
|
+
text: rawChunk.text,
|
|
749
|
+
index: rawChunk.index,
|
|
750
|
+
...typeof assistantData.turn === "number" ? { turn: assistantData.turn } : {},
|
|
751
|
+
...typeof assistantData.step === "number" ? { step: assistantData.step } : {}
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
if (eventType === "user/message" || eventType === "assistant/message") {
|
|
757
|
+
const eventData = isRecord$1(event.data) ? event.data : {};
|
|
758
|
+
return {
|
|
759
|
+
id: frame.rpcId,
|
|
760
|
+
type: "chat.message.committed",
|
|
761
|
+
time,
|
|
762
|
+
data: {
|
|
763
|
+
...base,
|
|
764
|
+
role: eventType === "user/message" ? "user" : "assistant",
|
|
765
|
+
...typeof eventData.turn === "number" ? { turn: eventData.turn } : {},
|
|
766
|
+
...typeof eventData.step === "number" ? { step: eventData.step } : {}
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
if (eventType === "turn/start" || eventType === "turn/end") return {
|
|
771
|
+
id: frame.rpcId,
|
|
772
|
+
type: eventType === "turn/start" ? "chat.turn.start" : "chat.turn.end",
|
|
773
|
+
time,
|
|
774
|
+
data: base
|
|
775
|
+
};
|
|
776
|
+
return {
|
|
777
|
+
id: frame.rpcId,
|
|
778
|
+
type: "chat.session.event",
|
|
779
|
+
time,
|
|
780
|
+
data: base
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
if (payloadType === "host/session-status" && typeof payload.sessionId === "string") return {
|
|
784
|
+
id: frame.rpcId,
|
|
785
|
+
type: "chat.session.status",
|
|
786
|
+
time,
|
|
787
|
+
data: {
|
|
788
|
+
sessionId: payload.sessionId,
|
|
789
|
+
running: payload.running === true
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
if (payloadType === "host/remote-event" && payload.event === "agent-preset/selected" && Array.isArray(payload.args)) {
|
|
793
|
+
const [sessionId, agentPreset] = payload.args;
|
|
794
|
+
if (typeof sessionId === "string" && typeof agentPreset === "string") return {
|
|
795
|
+
id: frame.rpcId,
|
|
796
|
+
type: "chat.agent-preset.selected",
|
|
797
|
+
time,
|
|
798
|
+
data: {
|
|
799
|
+
sessionId,
|
|
800
|
+
agentPreset
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
return {
|
|
805
|
+
id: frame.rpcId,
|
|
806
|
+
type: `chat.${payloadType}`,
|
|
807
|
+
time,
|
|
808
|
+
data: payload
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
function isRecord$1(value) {
|
|
812
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
813
|
+
}
|
|
814
|
+
function matchRoot(roots, cwd) {
|
|
815
|
+
for (const root of roots) {
|
|
816
|
+
const relative = wireRelative(root.realPath, cwd);
|
|
817
|
+
if (relative !== void 0) return {
|
|
818
|
+
root,
|
|
819
|
+
relative
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
function sessionTitle(session) {
|
|
824
|
+
const title = session.projections?.values.title;
|
|
825
|
+
return typeof title === "string" && title.trim() !== "" ? title : void 0;
|
|
826
|
+
}
|
|
827
|
+
function approvalKey(sessionId, approvalId) {
|
|
828
|
+
return `${sessionId}\u0000${approvalId}`;
|
|
829
|
+
}
|
|
830
|
+
function approvalRisk(reason) {
|
|
831
|
+
return reason?.toLowerCase().includes("danger-full-access") === true ? "full-access" : "standard";
|
|
832
|
+
}
|
|
833
|
+
function publicApproval(record, detail) {
|
|
834
|
+
const reason = record.reason === void 0 ? void 0 : redactApprovalText(record.reason, 500);
|
|
835
|
+
const safeDetail = detail === void 0 ? void 0 : redactApprovalText(detail, 1200);
|
|
836
|
+
return {
|
|
837
|
+
id: record.approvalId,
|
|
838
|
+
sessionId: record.sessionId,
|
|
839
|
+
toolName: redactApprovalText(record.toolName, 120),
|
|
840
|
+
...reason === void 0 || reason === "" ? {} : { reason },
|
|
841
|
+
...safeDetail === void 0 || safeDetail === "" ? {} : { detail: safeDetail },
|
|
842
|
+
risk: approvalRisk(record.reason),
|
|
843
|
+
requestedAt: record.requestedAt
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
function findToolCallDetail(events, callId) {
|
|
847
|
+
if (callId === void 0) return void 0;
|
|
848
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
849
|
+
const candidate = events[index];
|
|
850
|
+
if (!isRecord$1(candidate)) continue;
|
|
851
|
+
const event = isRecord$1(candidate.event) ? candidate.event : candidate;
|
|
852
|
+
if (event.type !== "tool/call" || !isRecord$1(event.data)) continue;
|
|
853
|
+
const data = event.data;
|
|
854
|
+
if ((typeof data.callId === "string" ? data.callId : typeof data.id === "string" ? data.id : void 0) !== callId) continue;
|
|
855
|
+
const args = decodeToolArguments(data.arguments);
|
|
856
|
+
if (isRecord$1(args) && typeof args.command === "string") return args.command;
|
|
857
|
+
if (typeof args === "string") return args;
|
|
858
|
+
if (args !== void 0) return JSON.stringify(args, null, 2);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
function decodeToolArguments(value) {
|
|
862
|
+
if (typeof value !== "string") return value;
|
|
863
|
+
try {
|
|
864
|
+
return JSON.parse(value);
|
|
865
|
+
} catch {
|
|
866
|
+
return value;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
function redactApprovalText(value, maxLength = 1200) {
|
|
870
|
+
let redacted = value;
|
|
871
|
+
redacted = redacted.replace(/\bBearer\s+[^\s"']+/gi, "Bearer [REDACTED]");
|
|
872
|
+
redacted = redacted.replace(/\b(?:sk|as_sk|ghp|github_pat)[-_][A-Za-z0-9_-]{8,}\b/gi, "[REDACTED]");
|
|
873
|
+
redacted = redacted.replace(/(["']?(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password|passwd|credential)["']?\s*[=:]\s*)(["'])(.*?)\2/gi, "$1$2[REDACTED]$2");
|
|
874
|
+
redacted = redacted.replace(/((?:api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password|passwd|credential)\s*[=:]\s*)[^\s,;"']+/gi, "$1[REDACTED]");
|
|
875
|
+
redacted = redacted.replace(/([?&](?:api[_-]?key|access[_-]?token|token|secret|password)=)[^&#\s]+/gi, "$1[REDACTED]");
|
|
876
|
+
return redacted.length <= maxLength ? redacted : `${redacted.slice(0, Math.max(0, maxLength - 1))}…`;
|
|
877
|
+
}
|
|
878
|
+
//#endregion
|
|
879
|
+
//#region src/host/database.ts
|
|
880
|
+
var WorkspaceDatabase = class {
|
|
881
|
+
dataDir;
|
|
882
|
+
trashDir;
|
|
883
|
+
db;
|
|
884
|
+
constructor(dataDir) {
|
|
885
|
+
this.dataDir = path.resolve(dataDir);
|
|
886
|
+
this.trashDir = path.join(this.dataDir, "trash");
|
|
887
|
+
mkdirSync(this.trashDir, { recursive: true });
|
|
888
|
+
this.db = new DatabaseSync(path.join(this.dataDir, "workspace.sqlite"));
|
|
889
|
+
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;");
|
|
890
|
+
this.migrate();
|
|
891
|
+
}
|
|
892
|
+
close() {
|
|
893
|
+
this.db.close();
|
|
894
|
+
}
|
|
895
|
+
getSetting(key) {
|
|
896
|
+
return this.db.prepare("SELECT value FROM settings WHERE key = ?").get(key)?.value;
|
|
897
|
+
}
|
|
898
|
+
setSetting(key, value) {
|
|
899
|
+
this.db.prepare("INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(key, value);
|
|
900
|
+
}
|
|
901
|
+
async addRoot(inputPath, label) {
|
|
902
|
+
const realPath = await canonicalRoot(inputPath);
|
|
903
|
+
const duplicate = this.db.prepare("SELECT * FROM roots WHERE real_path = ?").get(realPath);
|
|
904
|
+
if (duplicate !== void 0) return rootFromRow(duplicate);
|
|
905
|
+
const id = randomUUID();
|
|
906
|
+
const createdAt = Date.now();
|
|
907
|
+
const resolvedLabel = label?.trim() || path.basename(realPath) || realPath;
|
|
908
|
+
this.db.prepare("INSERT INTO roots(id,label,real_path,created_at) VALUES(?,?,?,?)").run(id, resolvedLabel, realPath, createdAt);
|
|
909
|
+
this.audit("admin", "loopback-admin", "root.add", id, void 0, { label: resolvedLabel });
|
|
910
|
+
return {
|
|
911
|
+
id,
|
|
912
|
+
label: resolvedLabel,
|
|
913
|
+
realPath,
|
|
914
|
+
createdAt
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
listRoots() {
|
|
918
|
+
return this.db.prepare("SELECT * FROM roots ORDER BY created_at, id").all().map(rootFromRow);
|
|
919
|
+
}
|
|
920
|
+
getRoot(id) {
|
|
921
|
+
const row = this.db.prepare("SELECT * FROM roots WHERE id = ?").get(id);
|
|
922
|
+
return row === void 0 ? void 0 : rootFromRow(row);
|
|
923
|
+
}
|
|
924
|
+
removeRoot(id) {
|
|
925
|
+
if (this.db.prepare("DELETE FROM roots WHERE id = ?").run(id).changes === 0) throw new ApiError(404, "ROOT_NOT_FOUND", "The authorized root does not exist.");
|
|
926
|
+
this.audit("admin", "loopback-admin", "root.remove", id);
|
|
927
|
+
}
|
|
928
|
+
createPairing(rootIds, scopes, ttlMs = 6e5) {
|
|
929
|
+
assertScopes(scopes);
|
|
930
|
+
for (const rootId of rootIds) if (this.getRoot(rootId) === void 0) throw new ApiError(404, "ROOT_NOT_FOUND", `Unknown root ${rootId}.`);
|
|
931
|
+
const raw = randomBytes(5).toString("hex").toUpperCase();
|
|
932
|
+
const code = `${raw.slice(0, 5)}-${raw.slice(5)}`;
|
|
933
|
+
const expiresAt = Date.now() + ttlMs;
|
|
934
|
+
this.db.prepare("INSERT INTO pairings(code_hash,scopes_json,roots_json,expires_at,created_at) VALUES(?,?,?,?,?)").run(hashSecret(normalizePairingCode(code)), JSON.stringify(scopes), JSON.stringify(rootIds), expiresAt, Date.now());
|
|
935
|
+
this.audit("admin", "loopback-admin", "pairing.create", void 0, void 0, {
|
|
936
|
+
rootIds,
|
|
937
|
+
scopes,
|
|
938
|
+
expiresAt
|
|
939
|
+
});
|
|
940
|
+
return {
|
|
941
|
+
code,
|
|
942
|
+
expiresAt
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
exchangePairing(code, deviceName) {
|
|
946
|
+
const normalized = normalizePairingCode(code);
|
|
947
|
+
const now = Date.now();
|
|
948
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
949
|
+
try {
|
|
950
|
+
const row = this.db.prepare("SELECT code_hash,scopes_json,roots_json,expires_at,used_at FROM pairings WHERE code_hash = ?").get(hashSecret(normalized));
|
|
951
|
+
if (row === void 0 || row.used_at !== null || row.expires_at < now) throw new ApiError(401, "PAIRING_INVALID", "The pairing code is invalid, expired, or already used.");
|
|
952
|
+
const token = randomBytes(32).toString("base64url");
|
|
953
|
+
const id = randomUUID();
|
|
954
|
+
const name = deviceName.trim().slice(0, 120) || "Mobile device";
|
|
955
|
+
const scopes = parseScopes(row.scopes_json);
|
|
956
|
+
const rootIds = JSON.parse(row.roots_json);
|
|
957
|
+
this.db.prepare("UPDATE pairings SET used_at = ? WHERE code_hash = ?").run(now, row.code_hash);
|
|
958
|
+
this.db.prepare("INSERT INTO devices(id,name,token_hash,scopes_json,created_at) VALUES(?,?,?,?,?)").run(id, name, hashSecret(token), JSON.stringify(scopes), now);
|
|
959
|
+
const grant = this.db.prepare("INSERT INTO device_roots(device_id,root_id) VALUES(?,?)");
|
|
960
|
+
for (const rootId of rootIds) grant.run(id, rootId);
|
|
961
|
+
this.db.exec("COMMIT");
|
|
962
|
+
const device = {
|
|
963
|
+
id,
|
|
964
|
+
name,
|
|
965
|
+
scopes,
|
|
966
|
+
rootIds,
|
|
967
|
+
createdAt: now
|
|
968
|
+
};
|
|
969
|
+
this.audit("device", id, "pairing.exchange", void 0, void 0, { name });
|
|
970
|
+
return {
|
|
971
|
+
token,
|
|
972
|
+
device
|
|
973
|
+
};
|
|
974
|
+
} catch (error) {
|
|
975
|
+
this.db.exec("ROLLBACK");
|
|
976
|
+
throw error;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
authenticate(token) {
|
|
980
|
+
const row = this.db.prepare("SELECT * FROM devices WHERE token_hash = ? AND revoked_at IS NULL").get(hashSecret(token));
|
|
981
|
+
if (row === void 0) return void 0;
|
|
982
|
+
return this.hydrateDevice(row, true);
|
|
983
|
+
}
|
|
984
|
+
getActiveDevice(id) {
|
|
985
|
+
const row = this.db.prepare("SELECT * FROM devices WHERE id = ? AND revoked_at IS NULL").get(id);
|
|
986
|
+
return row === void 0 ? void 0 : this.hydrateDevice(row, false);
|
|
987
|
+
}
|
|
988
|
+
hydrateDevice(row, touchLastSeen) {
|
|
989
|
+
const rootRows = this.db.prepare("SELECT root_id FROM device_roots WHERE device_id = ? ORDER BY root_id").all(row.id);
|
|
990
|
+
const now = Date.now();
|
|
991
|
+
if (touchLastSeen) this.db.prepare("UPDATE devices SET last_seen_at = ? WHERE id = ?").run(now, row.id);
|
|
992
|
+
return {
|
|
993
|
+
id: row.id,
|
|
994
|
+
name: row.name,
|
|
995
|
+
tokenHash: row.token_hash,
|
|
996
|
+
scopes: parseScopes(row.scopes_json),
|
|
997
|
+
rootIds: rootRows.map((item) => item.root_id),
|
|
998
|
+
createdAt: row.created_at,
|
|
999
|
+
...touchLastSeen ? { lastSeenAt: now } : row.last_seen_at === null ? {} : { lastSeenAt: row.last_seen_at }
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
listDevices() {
|
|
1003
|
+
const rows = this.db.prepare("SELECT * FROM devices ORDER BY created_at DESC").all();
|
|
1004
|
+
const rootQuery = this.db.prepare("SELECT root_id FROM device_roots WHERE device_id = ? ORDER BY root_id");
|
|
1005
|
+
return rows.map((row) => ({
|
|
1006
|
+
id: row.id,
|
|
1007
|
+
name: row.name,
|
|
1008
|
+
scopes: parseScopes(row.scopes_json),
|
|
1009
|
+
rootIds: rootQuery.all(row.id).map((item) => item.root_id),
|
|
1010
|
+
createdAt: row.created_at,
|
|
1011
|
+
...row.last_seen_at === null ? {} : { lastSeenAt: row.last_seen_at },
|
|
1012
|
+
...row.revoked_at === null ? {} : { revokedAt: row.revoked_at }
|
|
1013
|
+
}));
|
|
1014
|
+
}
|
|
1015
|
+
isDeviceActive(id) {
|
|
1016
|
+
return this.db.prepare("SELECT 1 AS active FROM devices WHERE id = ? AND revoked_at IS NULL").get(id) !== void 0;
|
|
1017
|
+
}
|
|
1018
|
+
updateDevice(id, scopes, rootIds) {
|
|
1019
|
+
assertScopes(scopes);
|
|
1020
|
+
for (const rootId of rootIds) if (this.getRoot(rootId) === void 0) throw new ApiError(404, "ROOT_NOT_FOUND", `Unknown root ${rootId}.`);
|
|
1021
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1022
|
+
try {
|
|
1023
|
+
if (this.db.prepare("UPDATE devices SET scopes_json = ? WHERE id = ? AND revoked_at IS NULL").run(JSON.stringify(scopes), id).changes === 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
|
|
1024
|
+
this.db.prepare("DELETE FROM device_roots WHERE device_id = ?").run(id);
|
|
1025
|
+
const grant = this.db.prepare("INSERT INTO device_roots(device_id,root_id) VALUES(?,?)");
|
|
1026
|
+
for (const rootId of rootIds) grant.run(id, rootId);
|
|
1027
|
+
this.db.exec("COMMIT");
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
this.db.exec("ROLLBACK");
|
|
1030
|
+
throw error;
|
|
1031
|
+
}
|
|
1032
|
+
this.audit("admin", "loopback-admin", "device.update", void 0, void 0, {
|
|
1033
|
+
deviceId: id,
|
|
1034
|
+
scopes,
|
|
1035
|
+
rootIds
|
|
1036
|
+
});
|
|
1037
|
+
const device = this.listDevices().find((item) => item.id === id);
|
|
1038
|
+
if (device === void 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The device does not exist.");
|
|
1039
|
+
return device;
|
|
1040
|
+
}
|
|
1041
|
+
revokeDevice(id) {
|
|
1042
|
+
if (this.db.prepare("UPDATE devices SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(Date.now(), id).changes === 0) throw new ApiError(404, "DEVICE_NOT_FOUND", "The active device does not exist.");
|
|
1043
|
+
this.audit("admin", "loopback-admin", "device.revoke", void 0, void 0, { deviceId: id });
|
|
1044
|
+
}
|
|
1045
|
+
insertTrash(record) {
|
|
1046
|
+
this.db.prepare("INSERT INTO trash(id,root_id,relative_path,payload_path,kind,size,digest,created_at,status) VALUES(?,?,?,?,?,?,?,?,?)").run(record.id, record.rootId, record.relativePath, record.payloadPath, record.kind, record.size, record.digest, record.createdAt, record.status);
|
|
1047
|
+
}
|
|
1048
|
+
updateTrashStatus(id, status) {
|
|
1049
|
+
this.db.prepare("UPDATE trash SET status = ? WHERE id = ?").run(status, id);
|
|
1050
|
+
}
|
|
1051
|
+
getTrash(id) {
|
|
1052
|
+
const row = this.db.prepare("SELECT * FROM trash WHERE id = ?").get(id);
|
|
1053
|
+
return row === void 0 ? void 0 : trashFromRow(row);
|
|
1054
|
+
}
|
|
1055
|
+
listTrash(rootIds) {
|
|
1056
|
+
return this.db.prepare("SELECT * FROM trash WHERE status != ? ORDER BY created_at DESC").all("restored").map(trashFromRow).filter((item) => rootIds === void 0 || rootIds.has(item.rootId));
|
|
1057
|
+
}
|
|
1058
|
+
deleteTrashRecord(id) {
|
|
1059
|
+
this.db.prepare("DELETE FROM trash WHERE id = ?").run(id);
|
|
1060
|
+
}
|
|
1061
|
+
audit(actorType, actorId, action, rootId, relativePath, details = {}, requestId) {
|
|
1062
|
+
this.db.prepare("INSERT INTO audit(id,time,request_id,actor_type,actor_id,action,root_id,relative_path,details_json) VALUES(?,?,?,?,?,?,?,?,?)").run(randomUUID(), Date.now(), requestId ?? null, actorType, actorId, action, rootId ?? null, relativePath ?? null, JSON.stringify(details));
|
|
1063
|
+
}
|
|
1064
|
+
listAudit(limit = 200) {
|
|
1065
|
+
return this.db.prepare("SELECT id,time,request_id AS requestId,actor_type AS actorType,actor_id AS actorId,action,root_id AS rootId,relative_path AS relativePath,details_json AS detailsJson FROM audit ORDER BY time DESC LIMIT ?").all(Math.min(Math.max(limit, 1), 1e3));
|
|
1066
|
+
}
|
|
1067
|
+
readIdempotent(deviceId, key) {
|
|
1068
|
+
const row = this.db.prepare("SELECT status,response_json FROM idempotency WHERE device_id = ? AND key = ?").get(deviceId, key);
|
|
1069
|
+
return row === void 0 ? void 0 : {
|
|
1070
|
+
status: row.status,
|
|
1071
|
+
body: JSON.parse(row.response_json)
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
saveIdempotent(deviceId, key, status, body) {
|
|
1075
|
+
this.db.prepare("INSERT OR IGNORE INTO idempotency(device_id,key,status,response_json,created_at) VALUES(?,?,?,?,?)").run(deviceId, key, status, JSON.stringify(body), Date.now());
|
|
1076
|
+
}
|
|
1077
|
+
migrate() {
|
|
1078
|
+
this.db.exec(`
|
|
1079
|
+
CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
1080
|
+
CREATE TABLE IF NOT EXISTS roots(
|
|
1081
|
+
id TEXT PRIMARY KEY, label TEXT NOT NULL, real_path TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL
|
|
1082
|
+
);
|
|
1083
|
+
CREATE TABLE IF NOT EXISTS devices(
|
|
1084
|
+
id TEXT PRIMARY KEY, name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE,
|
|
1085
|
+
scopes_json TEXT NOT NULL, created_at INTEGER NOT NULL, last_seen_at INTEGER, revoked_at INTEGER
|
|
1086
|
+
);
|
|
1087
|
+
CREATE TABLE IF NOT EXISTS device_roots(
|
|
1088
|
+
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
|
1089
|
+
root_id TEXT NOT NULL REFERENCES roots(id) ON DELETE CASCADE,
|
|
1090
|
+
PRIMARY KEY(device_id, root_id)
|
|
1091
|
+
);
|
|
1092
|
+
CREATE TABLE IF NOT EXISTS pairings(
|
|
1093
|
+
code_hash TEXT PRIMARY KEY, scopes_json TEXT NOT NULL, roots_json TEXT NOT NULL,
|
|
1094
|
+
expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL, used_at INTEGER
|
|
1095
|
+
);
|
|
1096
|
+
CREATE TABLE IF NOT EXISTS trash(
|
|
1097
|
+
id TEXT PRIMARY KEY, root_id TEXT NOT NULL, relative_path TEXT NOT NULL,
|
|
1098
|
+
payload_path TEXT NOT NULL, kind TEXT NOT NULL, size INTEGER NOT NULL,
|
|
1099
|
+
digest TEXT NOT NULL, created_at INTEGER NOT NULL, status TEXT NOT NULL
|
|
1100
|
+
);
|
|
1101
|
+
CREATE TABLE IF NOT EXISTS audit(
|
|
1102
|
+
id TEXT PRIMARY KEY, time INTEGER NOT NULL, request_id TEXT, actor_type TEXT NOT NULL,
|
|
1103
|
+
actor_id TEXT NOT NULL, action TEXT NOT NULL, root_id TEXT, relative_path TEXT, details_json TEXT NOT NULL
|
|
1104
|
+
);
|
|
1105
|
+
CREATE TABLE IF NOT EXISTS idempotency(
|
|
1106
|
+
device_id TEXT NOT NULL, key TEXT NOT NULL, status INTEGER NOT NULL,
|
|
1107
|
+
response_json TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(device_id,key)
|
|
1108
|
+
);
|
|
1109
|
+
CREATE INDEX IF NOT EXISTS audit_time_idx ON audit(time DESC);
|
|
1110
|
+
CREATE INDEX IF NOT EXISTS trash_root_idx ON trash(root_id, created_at DESC);
|
|
1111
|
+
`);
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
function hashSecret(secret) {
|
|
1115
|
+
return createHash("sha256").update(secret, "utf8").digest("hex");
|
|
1116
|
+
}
|
|
1117
|
+
function normalizePairingCode(code) {
|
|
1118
|
+
return code.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
|
|
1119
|
+
}
|
|
1120
|
+
function parseScopes(value) {
|
|
1121
|
+
const parsed = JSON.parse(value);
|
|
1122
|
+
if (!Array.isArray(parsed)) return [];
|
|
1123
|
+
return parsed.filter((scope) => DEVICE_SCOPES.includes(scope));
|
|
1124
|
+
}
|
|
1125
|
+
function assertScopes(scopes) {
|
|
1126
|
+
if (scopes.some((scope) => !DEVICE_SCOPES.includes(scope))) throw new ApiError(400, "SCOPE_INVALID", "The request contains an unknown device scope.");
|
|
1127
|
+
}
|
|
1128
|
+
function rootFromRow(row) {
|
|
1129
|
+
return {
|
|
1130
|
+
id: row.id,
|
|
1131
|
+
label: row.label,
|
|
1132
|
+
realPath: row.real_path,
|
|
1133
|
+
createdAt: row.created_at
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
function trashFromRow(row) {
|
|
1137
|
+
return {
|
|
1138
|
+
id: String(row.id),
|
|
1139
|
+
rootId: String(row.root_id),
|
|
1140
|
+
relativePath: String(row.relative_path),
|
|
1141
|
+
payloadPath: String(row.payload_path),
|
|
1142
|
+
kind: String(row.kind),
|
|
1143
|
+
size: Number(row.size),
|
|
1144
|
+
digest: String(row.digest),
|
|
1145
|
+
createdAt: Number(row.created_at),
|
|
1146
|
+
status: String(row.status)
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
//#endregion
|
|
1150
|
+
//#region src/host/dsh-0.2-bridge.ts
|
|
1151
|
+
/**
|
|
1152
|
+
* DSH 0.2.x host-service bridge.
|
|
1153
|
+
*
|
|
1154
|
+
* dsh-workspace 1.x was written against the DSH `ctx.apiProxy` seam — a
|
|
1155
|
+
* client-shaped RPC facade that the old `@deepseek-ai/dsh-host-apiproxy`
|
|
1156
|
+
* package published to plugins. DSH 0.1.2-alpha.3 replaced that package with
|
|
1157
|
+
* `@deepseek-ai/dsh-api-gateway` (Cordis service `typertGateway`, mounted as the
|
|
1158
|
+
* `typert-gateway` row), so no package publishes `apiProxy` any more and this
|
|
1159
|
+
* plugin could never activate on 0.2.x.
|
|
1160
|
+
*
|
|
1161
|
+
* The controllers behind that facade did not go away: they now register
|
|
1162
|
+
* directly as host services (`ctx.sessionController`, `ctx.workspaceController`,
|
|
1163
|
+
* `ctx.settingsController`, `ctx.credentialsController`, `ctx.agentPresets`,
|
|
1164
|
+
* `ctx.llm`, `ctx.workspaceRegistry`). This module rebuilds the old `apiProxy`
|
|
1165
|
+
* shape on top of them, so `chat-adapter.ts` / `settings-adapter.ts` stay
|
|
1166
|
+
* untouched.
|
|
1167
|
+
*
|
|
1168
|
+
* The plugin deliberately does not import any `@deepseek-ai/*` package at
|
|
1169
|
+
* runtime (see docs/project/COMPATIBILITY.md): every host service below is
|
|
1170
|
+
* consumed through a structural type and injected by the running Host.
|
|
1171
|
+
*/
|
|
1172
|
+
/**
|
|
1173
|
+
* 容错读取一个 cordis 服务。
|
|
1174
|
+
*
|
|
1175
|
+
* 为什么需要它:读一个没人提供的注入属性,cordis 会抛
|
|
1176
|
+
* `cannot get property "x" without inject`。这个异常**不能**让插件整个激活失败 ——
|
|
1177
|
+
* 一个可选能力缺席,凭什么把会话、消息、设置这些无关功能一起拖下水。
|
|
1178
|
+
*
|
|
1179
|
+
* 代价要说清楚:返回 `null` 意味着这条能力在这套 profile 上就是没有。
|
|
1180
|
+
* 调用方必须自己给出可读的错误(见 readAttachment),而不是让用户看到一个
|
|
1181
|
+
* 语焉不详的 500。
|
|
1182
|
+
*/
|
|
1183
|
+
function optionalService(host, name) {
|
|
1184
|
+
try {
|
|
1185
|
+
return host[name];
|
|
1186
|
+
} catch {
|
|
1187
|
+
return null;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
function ok(value) {
|
|
1191
|
+
return { result: {
|
|
1192
|
+
ok: true,
|
|
1193
|
+
value
|
|
1194
|
+
} };
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Normalize a thrown host error into the `{ code, message }` shape that
|
|
1198
|
+
* `unwrapDsh` reads. 0.2.x throws `RemoteError`-alikes carrying a namespaced
|
|
1199
|
+
* `code` (e.g. `session/not-found`); older code paths used short codes, so both
|
|
1200
|
+
* spellings are preserved and callers keep matching on substrings.
|
|
1201
|
+
*/
|
|
1202
|
+
function toRpcError(error) {
|
|
1203
|
+
if (error && typeof error === "object") {
|
|
1204
|
+
const candidate = error;
|
|
1205
|
+
return {
|
|
1206
|
+
code: typeof candidate.code === "string" && candidate.code.length > 0 ? candidate.code : typeof candidate.name === "string" && candidate.name.length > 0 ? candidate.name : "internal",
|
|
1207
|
+
message: typeof candidate.message === "string" && candidate.message.length > 0 ? candidate.message : String(error)
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
return {
|
|
1211
|
+
code: "internal",
|
|
1212
|
+
message: String(error)
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
/** Run one host call and wrap its outcome or failure in the old envelope. */
|
|
1216
|
+
async function call(run) {
|
|
1217
|
+
try {
|
|
1218
|
+
return ok(await run());
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
return { result: {
|
|
1221
|
+
ok: false,
|
|
1222
|
+
error: toRpcError(error)
|
|
1223
|
+
} };
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Replay-free fan-out of host facts into the `{ rpcId, payload }` frames the
|
|
1228
|
+
* old `apiProxy` streams produced. Subscribers only observe frames emitted
|
|
1229
|
+
* after they attach, matching the old streams' live-only semantics.
|
|
1230
|
+
*/
|
|
1231
|
+
var FrameHub = class {
|
|
1232
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1233
|
+
push(payload) {
|
|
1234
|
+
const frame = {
|
|
1235
|
+
rpcId: randomUUID(),
|
|
1236
|
+
payload
|
|
1237
|
+
};
|
|
1238
|
+
for (const listener of this.listeners) try {
|
|
1239
|
+
listener(frame);
|
|
1240
|
+
} catch {}
|
|
1241
|
+
}
|
|
1242
|
+
subscribe(signal) {
|
|
1243
|
+
const queue = [];
|
|
1244
|
+
let wake;
|
|
1245
|
+
let closed = false;
|
|
1246
|
+
const onFrame = (frame) => {
|
|
1247
|
+
if (closed) return;
|
|
1248
|
+
queue.push(frame);
|
|
1249
|
+
wake?.();
|
|
1250
|
+
};
|
|
1251
|
+
this.listeners.add(onFrame);
|
|
1252
|
+
const detach = () => {
|
|
1253
|
+
if (closed) return;
|
|
1254
|
+
closed = true;
|
|
1255
|
+
this.listeners.delete(onFrame);
|
|
1256
|
+
wake?.();
|
|
1257
|
+
};
|
|
1258
|
+
signal.addEventListener("abort", detach, { once: true });
|
|
1259
|
+
return { async *[Symbol.asyncIterator]() {
|
|
1260
|
+
try {
|
|
1261
|
+
while (!closed && !signal.aborted) {
|
|
1262
|
+
if (queue.length === 0) {
|
|
1263
|
+
await new Promise((resolve) => {
|
|
1264
|
+
wake = resolve;
|
|
1265
|
+
});
|
|
1266
|
+
wake = void 0;
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
yield queue.shift();
|
|
1270
|
+
}
|
|
1271
|
+
} finally {
|
|
1272
|
+
detach();
|
|
1273
|
+
}
|
|
1274
|
+
} };
|
|
1275
|
+
}
|
|
1276
|
+
};
|
|
1277
|
+
/**
|
|
1278
|
+
* Relays live session frames into the old `session/event` shape.
|
|
1279
|
+
*
|
|
1280
|
+
* 0.2.x has no process-wide session event stream: both durable events and the
|
|
1281
|
+
* in-flight assistant stream arrive per session through `follow()`. The plugin's
|
|
1282
|
+
* event pipeline instead consumes one mux carrying `session/event` frames, so
|
|
1283
|
+
* this relay keeps a follow subscription for each *running* session and fans its
|
|
1284
|
+
* frames out. Without it the client never sees streamed assistant text — the
|
|
1285
|
+
* transcript would only appear once a message had been committed and re-read.
|
|
1286
|
+
*/
|
|
1287
|
+
var SessionEventRelay = class {
|
|
1288
|
+
host;
|
|
1289
|
+
mux;
|
|
1290
|
+
limit;
|
|
1291
|
+
active = /* @__PURE__ */ new Map();
|
|
1292
|
+
constructor(host, mux, limit = 8) {
|
|
1293
|
+
this.host = host;
|
|
1294
|
+
this.mux = mux;
|
|
1295
|
+
this.limit = limit;
|
|
1296
|
+
}
|
|
1297
|
+
/** Start following one session; idempotent per session. */
|
|
1298
|
+
attach(sessionId) {
|
|
1299
|
+
if (this.active.has(sessionId)) return;
|
|
1300
|
+
if (this.active.size >= this.limit) return;
|
|
1301
|
+
const controller = new AbortController();
|
|
1302
|
+
this.active.set(sessionId, controller);
|
|
1303
|
+
this.pump(sessionId, controller.signal).catch(() => {
|
|
1304
|
+
this.detach(sessionId);
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
/** Stop following one session. */
|
|
1308
|
+
detach(sessionId) {
|
|
1309
|
+
const controller = this.active.get(sessionId);
|
|
1310
|
+
if (controller === void 0) return;
|
|
1311
|
+
this.active.delete(sessionId);
|
|
1312
|
+
controller.abort();
|
|
1313
|
+
}
|
|
1314
|
+
/** Stop every subscription (plugin teardown). */
|
|
1315
|
+
detachAll() {
|
|
1316
|
+
for (const sessionId of [...this.active.keys()]) this.detach(sessionId);
|
|
1317
|
+
}
|
|
1318
|
+
async pump(sessionId, signal) {
|
|
1319
|
+
const stream = this.host.sessionController.follow({
|
|
1320
|
+
address: {
|
|
1321
|
+
kind: "session",
|
|
1322
|
+
sessionId
|
|
1323
|
+
},
|
|
1324
|
+
assistantStream: true
|
|
1325
|
+
}, signal);
|
|
1326
|
+
let turn;
|
|
1327
|
+
let step;
|
|
1328
|
+
for await (const rawFrame of stream) {
|
|
1329
|
+
if (signal.aborted) break;
|
|
1330
|
+
const frame = rawFrame;
|
|
1331
|
+
if (frame.type === "event") {
|
|
1332
|
+
this.mux.push({
|
|
1333
|
+
type: "session/event",
|
|
1334
|
+
sessionId,
|
|
1335
|
+
event: frame.event
|
|
1336
|
+
});
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
if (frame.type !== "assistant-stream" || frame.frame === void 0) continue;
|
|
1340
|
+
const inner = frame.frame;
|
|
1341
|
+
if (inner.type === "start") {
|
|
1342
|
+
turn = typeof inner.turn === "number" ? inner.turn : void 0;
|
|
1343
|
+
step = typeof inner.step === "number" ? inner.step : void 0;
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
if (inner.type !== "chunk") continue;
|
|
1347
|
+
this.mux.push({
|
|
1348
|
+
type: "session/event",
|
|
1349
|
+
sessionId,
|
|
1350
|
+
event: {
|
|
1351
|
+
type: "assistant/chunk",
|
|
1352
|
+
seq: typeof inner.index === "number" ? inner.index : 0,
|
|
1353
|
+
time: typeof inner.time === "number" ? inner.time : Date.now(),
|
|
1354
|
+
data: {
|
|
1355
|
+
chunk: inner.chunk,
|
|
1356
|
+
...turn === void 0 ? {} : { turn },
|
|
1357
|
+
...step === void 0 ? {} : { step }
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
/**
|
|
1365
|
+
* 0.2.x answers approvals through the Cordis waterfall `approval/request`,
|
|
1366
|
+
* whose listener claims a pending question by returning an outcome, while
|
|
1367
|
+
* `approval/asked` / `approval/decided` remain log-only audit session events.
|
|
1368
|
+
* The old `apiProxy` instead handed the client a `rpcId` and accepted the
|
|
1369
|
+
* decision back through `respond({ type: 'client-response' })`.
|
|
1370
|
+
*
|
|
1371
|
+
* This bridge joins the two: it claims a request, publishes the familiar
|
|
1372
|
+
* `approval/requested` frame carrying a bridge-minted `approvalId`, and settles
|
|
1373
|
+
* when the client answers through `respond()`. An unanswered request delegates
|
|
1374
|
+
* to the next answerer (the official WebUI) by calling `next()`, so installing
|
|
1375
|
+
* this plugin never removes the built-in approval surface.
|
|
1376
|
+
*/
|
|
1377
|
+
var ApprovalBridge = class {
|
|
1378
|
+
host;
|
|
1379
|
+
mux;
|
|
1380
|
+
pending = /* @__PURE__ */ new Map();
|
|
1381
|
+
constructor(host, mux) {
|
|
1382
|
+
this.host = host;
|
|
1383
|
+
this.mux = mux;
|
|
1384
|
+
}
|
|
1385
|
+
/** Register the answerer once, during plugin activation. */
|
|
1386
|
+
attach() {
|
|
1387
|
+
this.host.on("approval/request", async (...args) => {
|
|
1388
|
+
const req = args[0];
|
|
1389
|
+
const next = args[args.length - 1];
|
|
1390
|
+
const sessionId = sessionIdOfAgent(req.agent);
|
|
1391
|
+
const toolName = typeof req.toolName === "string" ? req.toolName : void 0;
|
|
1392
|
+
if (sessionId === void 0 || toolName === void 0) return next();
|
|
1393
|
+
const approvalId = randomUUID();
|
|
1394
|
+
const rpcId = randomUUID();
|
|
1395
|
+
const outcome = await new Promise((resolve) => {
|
|
1396
|
+
let done = false;
|
|
1397
|
+
const settle = (value) => {
|
|
1398
|
+
if (done) return;
|
|
1399
|
+
done = true;
|
|
1400
|
+
resolve(value);
|
|
1401
|
+
};
|
|
1402
|
+
this.pending.set(rpcId, { settle });
|
|
1403
|
+
req.signal?.addEventListener("abort", () => {
|
|
1404
|
+
this.pending.delete(rpcId);
|
|
1405
|
+
if (done) return;
|
|
1406
|
+
done = true;
|
|
1407
|
+
resolve("delegate");
|
|
1408
|
+
}, { once: true });
|
|
1409
|
+
this.mux.push({
|
|
1410
|
+
type: "approval/requested",
|
|
1411
|
+
sessionId,
|
|
1412
|
+
approvalId,
|
|
1413
|
+
rpcId,
|
|
1414
|
+
toolName,
|
|
1415
|
+
...typeof req.callId === "string" ? { callId: req.callId } : {},
|
|
1416
|
+
...typeof req.reason === "string" ? { reason: req.reason } : {}
|
|
1417
|
+
});
|
|
1418
|
+
});
|
|
1419
|
+
this.pending.delete(rpcId);
|
|
1420
|
+
this.mux.push({
|
|
1421
|
+
type: "approval/resolved",
|
|
1422
|
+
sessionId,
|
|
1423
|
+
approvalId,
|
|
1424
|
+
...outcome === "delegate" ? {} : { outcome }
|
|
1425
|
+
});
|
|
1426
|
+
if (outcome === "delegate") return next();
|
|
1427
|
+
return outcome;
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
async respond(message) {
|
|
1431
|
+
const entry = this.pending.get(message.rpcId);
|
|
1432
|
+
if (entry === void 0) return {
|
|
1433
|
+
accepted: false,
|
|
1434
|
+
reason: "not-pending"
|
|
1435
|
+
};
|
|
1436
|
+
const value = message.result.value;
|
|
1437
|
+
const outcome = value && typeof value === "object" ? value.outcome : void 0;
|
|
1438
|
+
if (outcome !== "allowed-once" && outcome !== "rejected") return {
|
|
1439
|
+
accepted: false,
|
|
1440
|
+
reason: "bad-response"
|
|
1441
|
+
};
|
|
1442
|
+
entry.settle(outcome);
|
|
1443
|
+
return { accepted: true };
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1446
|
+
/** Best-effort projection of an Agent identity to its session id. */
|
|
1447
|
+
function sessionIdOfAgent(agent) {
|
|
1448
|
+
if (!agent || typeof agent !== "object") return void 0;
|
|
1449
|
+
const candidate = agent;
|
|
1450
|
+
if (typeof candidate.sessionId === "string") return candidate.sessionId;
|
|
1451
|
+
if (candidate.session && typeof candidate.session === "object" && typeof candidate.session.id === "string") return candidate.session.id;
|
|
1452
|
+
}
|
|
1453
|
+
/** Best-effort projection of a session view to its session id. */
|
|
1454
|
+
function sessionIdOf(summary) {
|
|
1455
|
+
if (!summary || typeof summary !== "object") return void 0;
|
|
1456
|
+
const id = summary.sessionId;
|
|
1457
|
+
return typeof id === "string" ? id : void 0;
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Build the `apiProxy` replacement out of 0.2.x host services.
|
|
1461
|
+
*
|
|
1462
|
+
* Every method keeps the old `{ rpcId, payload }` request shape and the old
|
|
1463
|
+
* `{ result: { ok, value } }` envelope, because that is the contract
|
|
1464
|
+
* `chat-adapter.ts` and `settings-adapter.ts` are written against.
|
|
1465
|
+
*
|
|
1466
|
+
* @param host - host services resolved from the plugin context.
|
|
1467
|
+
* @returns a `SettingsApiProxy` (i.e. `DshApiProxy` plus the settings surfaces).
|
|
1468
|
+
*/
|
|
1469
|
+
function createDsh02ApiProxy(host) {
|
|
1470
|
+
const mux = new FrameHub();
|
|
1471
|
+
const hostFrames = new FrameHub();
|
|
1472
|
+
const attachmentStore = optionalService(host, "attachments");
|
|
1473
|
+
const fileUploadService = optionalService(host, "fileUploads");
|
|
1474
|
+
const approvals = new ApprovalBridge(host, mux);
|
|
1475
|
+
approvals.attach();
|
|
1476
|
+
const relay = new SessionEventRelay(host, mux);
|
|
1477
|
+
host.on("api-session/added", (summary) => {
|
|
1478
|
+
const sessionId = sessionIdOf(summary);
|
|
1479
|
+
if (sessionId === void 0) return;
|
|
1480
|
+
hostFrames.push({
|
|
1481
|
+
type: "session/added",
|
|
1482
|
+
sessionId,
|
|
1483
|
+
summary,
|
|
1484
|
+
session: summary
|
|
1485
|
+
});
|
|
1486
|
+
if (summary.running === true) relay.attach(sessionId);
|
|
1487
|
+
});
|
|
1488
|
+
host.on("api-session/removed", (sessionId) => {
|
|
1489
|
+
if (typeof sessionId !== "string") return;
|
|
1490
|
+
relay.detach(sessionId);
|
|
1491
|
+
hostFrames.push({
|
|
1492
|
+
type: "session/removed",
|
|
1493
|
+
sessionId
|
|
1494
|
+
});
|
|
1495
|
+
});
|
|
1496
|
+
host.on("api-session/status", (sessionId, running) => {
|
|
1497
|
+
if (typeof sessionId !== "string") return;
|
|
1498
|
+
if (running === true) relay.attach(sessionId);
|
|
1499
|
+
else relay.detach(sessionId);
|
|
1500
|
+
hostFrames.push({
|
|
1501
|
+
type: "host/session-status",
|
|
1502
|
+
sessionId,
|
|
1503
|
+
running
|
|
1504
|
+
});
|
|
1505
|
+
});
|
|
1506
|
+
host.on("agent-preset/selected", (sessionId, agentPreset) => {
|
|
1507
|
+
if (typeof sessionId !== "string" || typeof agentPreset !== "string") return;
|
|
1508
|
+
hostFrames.push({
|
|
1509
|
+
type: "host/remote-event",
|
|
1510
|
+
event: "agent-preset/selected",
|
|
1511
|
+
args: [sessionId, agentPreset]
|
|
1512
|
+
});
|
|
1513
|
+
});
|
|
1514
|
+
/** Ask the host for the Agent backing a session before agent-scoped calls. */
|
|
1515
|
+
async function agentFor(sessionId) {
|
|
1516
|
+
const agent = await host.sessionController.resolveAgent(sessionId);
|
|
1517
|
+
if (agent === void 0 || agent === null) throw Object.assign(/* @__PURE__ */ new Error(`no live Agent for session ${sessionId}`), { code: "session/agent-unavailable" });
|
|
1518
|
+
return agent;
|
|
1519
|
+
}
|
|
1520
|
+
return {
|
|
1521
|
+
respond: (message) => approvals.respond(message),
|
|
1522
|
+
sessions: {
|
|
1523
|
+
list: (request) => call(async () => {
|
|
1524
|
+
const page = await host.sessionController.list(request.payload, new AbortController().signal);
|
|
1525
|
+
const items = Array.isArray(page.items) ? page.items.filter((item) => item.origin !== "subagent") : page.items;
|
|
1526
|
+
return {
|
|
1527
|
+
...page,
|
|
1528
|
+
items
|
|
1529
|
+
};
|
|
1530
|
+
}),
|
|
1531
|
+
create: (request) => call(() => host.sessionController.create(request.payload)),
|
|
1532
|
+
history: (request) => call(async () => {
|
|
1533
|
+
const { sessionId, maxMessages } = request.payload;
|
|
1534
|
+
const controller = new AbortController();
|
|
1535
|
+
try {
|
|
1536
|
+
const snapshot = (await host.sessionController.follow({
|
|
1537
|
+
address: {
|
|
1538
|
+
kind: "session",
|
|
1539
|
+
sessionId
|
|
1540
|
+
},
|
|
1541
|
+
...maxMessages === void 0 ? {} : { maxMessages }
|
|
1542
|
+
}, controller.signal)[Symbol.asyncIterator]().next()).value;
|
|
1543
|
+
if (snapshot === void 0 || snapshot.type !== "snapshot") return {
|
|
1544
|
+
events: [],
|
|
1545
|
+
hasMore: false
|
|
1546
|
+
};
|
|
1547
|
+
return {
|
|
1548
|
+
events: Array.isArray(snapshot.records) ? [...snapshot.records] : [],
|
|
1549
|
+
hasMore: snapshot.hasMore === true
|
|
1550
|
+
};
|
|
1551
|
+
} finally {
|
|
1552
|
+
controller.abort();
|
|
1553
|
+
}
|
|
1554
|
+
}),
|
|
1555
|
+
prompt: (request) => call(async () => {
|
|
1556
|
+
const requestId = randomUUID();
|
|
1557
|
+
const receiptIds = request.payload.content.filter((part) => part.type === "file").map((part) => part.receiptId);
|
|
1558
|
+
const binding = receiptIds.length === 0 ? void 0 : await (async () => {
|
|
1559
|
+
if (fileUploadService == null) throw new Error("FILE_UPLOADS_UNAVAILABLE: this DSH build provides no ctx.fileUploads service, so file attachments cannot be sent");
|
|
1560
|
+
return fileUploadService.bindPrompt(await agentFor(request.payload.sessionId), receiptIds, requestId);
|
|
1561
|
+
})();
|
|
1562
|
+
await host.sessionController.prompt({
|
|
1563
|
+
requestId,
|
|
1564
|
+
...request.payload
|
|
1565
|
+
}, new AbortController().signal);
|
|
1566
|
+
binding?.commit();
|
|
1567
|
+
return { accepted: true };
|
|
1568
|
+
}),
|
|
1569
|
+
uploadAttachment: (request) => call(async () => {
|
|
1570
|
+
const { sessionId, data, name } = request.payload;
|
|
1571
|
+
if (fileUploadService == null) throw new Error("FILE_UPLOADS_UNAVAILABLE: this DSH build provides no ctx.fileUploads service, so file attachments cannot be sent");
|
|
1572
|
+
return {
|
|
1573
|
+
receiptId: (await fileUploadService.upload(await agentFor(sessionId), name === void 0 || name === "" ? { data } : {
|
|
1574
|
+
data,
|
|
1575
|
+
name
|
|
1576
|
+
}, new AbortController().signal)).receiptId,
|
|
1577
|
+
name: name ?? null
|
|
1578
|
+
};
|
|
1579
|
+
}),
|
|
1580
|
+
readAttachment: (request) => call(async () => {
|
|
1581
|
+
if (attachmentStore == null) throw new Error("ATTACHMENT_STORE_UNAVAILABLE: this DSH build provides no ctx.attachments service, so stored image bytes cannot be read");
|
|
1582
|
+
const stored = await attachmentStore.readImage({ attachmentId: request.payload.attachmentId }, new AbortController().signal);
|
|
1583
|
+
return {
|
|
1584
|
+
bytes: stored.data,
|
|
1585
|
+
mediaType: stored.ref.mediaType
|
|
1586
|
+
};
|
|
1587
|
+
}),
|
|
1588
|
+
cancel: (request) => call(() => host.sessionController.cancel(request.payload)),
|
|
1589
|
+
rename: (request) => call(() => host.sessionController.rename(request.payload)),
|
|
1590
|
+
fork: (request) => call(() => host.sessionController.fork(request.payload)),
|
|
1591
|
+
models: (request) => call(async () => {
|
|
1592
|
+
const { sessionId } = request.payload;
|
|
1593
|
+
try {
|
|
1594
|
+
await host.sessionController.resolveAgent(sessionId);
|
|
1595
|
+
} catch {}
|
|
1596
|
+
const catalog = await host.sessionController.modelCatalog();
|
|
1597
|
+
const selection = (await host.sessionController.projections({ sessionId }, new AbortController().signal))?.values?.["modelSelection"];
|
|
1598
|
+
return {
|
|
1599
|
+
current: selection?.pending ?? selection?.lastUsed ?? catalog.default,
|
|
1600
|
+
routable: Array.isArray(catalog.routableProviders) && catalog.routableProviders.length > 0,
|
|
1601
|
+
groups: Array.isArray(catalog.groups) ? catalog.groups : [],
|
|
1602
|
+
failures: Array.isArray(catalog.failures) ? catalog.failures.map((entry) => ({
|
|
1603
|
+
provider: typeof entry.id === "string" ? entry.id : String(entry.name ?? ""),
|
|
1604
|
+
message: typeof entry.message === "string" ? entry.message : ""
|
|
1605
|
+
})) : []
|
|
1606
|
+
};
|
|
1607
|
+
}),
|
|
1608
|
+
selectModel: (request) => call(() => host.sessionController.selectModel(request.payload))
|
|
1609
|
+
},
|
|
1610
|
+
workspace: {
|
|
1611
|
+
list: () => call(async () => {
|
|
1612
|
+
return {
|
|
1613
|
+
items: host.workspaceRegistry.list().map((row) => ({
|
|
1614
|
+
workspaceId: row.id,
|
|
1615
|
+
path: row.path,
|
|
1616
|
+
title: row.title,
|
|
1617
|
+
sessionIds: [...row.sessionIds ?? []],
|
|
1618
|
+
createdAt: row.createdAt,
|
|
1619
|
+
updatedAt: row.updatedAt
|
|
1620
|
+
})),
|
|
1621
|
+
archivedSessionIds: []
|
|
1622
|
+
};
|
|
1623
|
+
}),
|
|
1624
|
+
create: (request) => call(() => host.workspaceController.create(request.payload)),
|
|
1625
|
+
rename: (request) => call(() => host.workspaceController.rename(request.payload)),
|
|
1626
|
+
delete: (request) => call(async () => {
|
|
1627
|
+
await host.workspaceController.delete(request.payload);
|
|
1628
|
+
return { deleted: true };
|
|
1629
|
+
}),
|
|
1630
|
+
archiveSession: (request) => call(() => host.workspaceController.archiveSession(request.payload))
|
|
1631
|
+
},
|
|
1632
|
+
agentPresets: {
|
|
1633
|
+
list: () => call(async () => {
|
|
1634
|
+
const withTrust = (rows) => rows.map((row) => ({
|
|
1635
|
+
...row,
|
|
1636
|
+
trust: row.trust ?? "system"
|
|
1637
|
+
}));
|
|
1638
|
+
const roster = await host.agentPresets.remoteExportList();
|
|
1639
|
+
if (roster && typeof roster === "object") {
|
|
1640
|
+
const view = roster;
|
|
1641
|
+
if (Array.isArray(view.presets)) return {
|
|
1642
|
+
presets: withTrust(view.presets),
|
|
1643
|
+
authorable: view.authorable === true,
|
|
1644
|
+
hasDocument: view.hasDocument === true
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
return {
|
|
1648
|
+
presets: withTrust(await host.agentPresets.list()),
|
|
1649
|
+
authorable: false,
|
|
1650
|
+
hasDocument: false
|
|
1651
|
+
};
|
|
1652
|
+
}),
|
|
1653
|
+
select: (request) => call(async () => {
|
|
1654
|
+
const agent = await agentFor(request.payload.sessionId);
|
|
1655
|
+
return { agentPreset: await host.agentPresets.select(agent, request.payload.agentPreset) };
|
|
1656
|
+
})
|
|
1657
|
+
},
|
|
1658
|
+
settings: {
|
|
1659
|
+
describe: () => call(() => host.settingsController.describe()),
|
|
1660
|
+
mutate: (request) => call(() => host.settingsController.mutate(request.payload.ns, request.payload.ops, request.payload.expectedRevision))
|
|
1661
|
+
},
|
|
1662
|
+
credentials: {
|
|
1663
|
+
describe: (request) => call(async () => {
|
|
1664
|
+
return { credentials: await host.credentialsController.describe(request.payload.refs) };
|
|
1665
|
+
}),
|
|
1666
|
+
set: (request) => call(async () => {
|
|
1667
|
+
await host.credentialsController.set(request.payload.ref, request.payload.value);
|
|
1668
|
+
return {};
|
|
1669
|
+
}),
|
|
1670
|
+
unset: (request) => call(async () => {
|
|
1671
|
+
await host.credentialsController.unset(request.payload.ref);
|
|
1672
|
+
return {};
|
|
1673
|
+
})
|
|
1674
|
+
},
|
|
1675
|
+
llm: {
|
|
1676
|
+
providers: () => call(async () => {
|
|
1677
|
+
const directory = host.llm.listConfigurableProviders();
|
|
1678
|
+
const live = new Set(host.llm.listProviders().map((entry) => entry.id));
|
|
1679
|
+
return { providers: directory.map((entry) => ({
|
|
1680
|
+
provider: entry.provider,
|
|
1681
|
+
displayName: entry.displayName,
|
|
1682
|
+
settingsNs: entry.settingsNs,
|
|
1683
|
+
settingsPath: [...entry.settingsPath],
|
|
1684
|
+
active: live.has(entry.provider),
|
|
1685
|
+
...entry.declared === void 0 ? {} : { declared: entry.declared }
|
|
1686
|
+
})) };
|
|
1687
|
+
}),
|
|
1688
|
+
models: () => call(async () => {
|
|
1689
|
+
const view = await host.sessionController.modelCatalog();
|
|
1690
|
+
return {
|
|
1691
|
+
groups: Array.isArray(view.groups) ? view.groups : [],
|
|
1692
|
+
failures: Array.isArray(view.failures) ? view.failures : []
|
|
1693
|
+
};
|
|
1694
|
+
}),
|
|
1695
|
+
discoverModels: (request) => call(async () => {
|
|
1696
|
+
const { settingsNs, ...rest } = request.payload;
|
|
1697
|
+
return { models: [...await host.llm.discoverModels(settingsNs, rest)] };
|
|
1698
|
+
})
|
|
1699
|
+
},
|
|
1700
|
+
events: {
|
|
1701
|
+
mux: (request, signal) => mux.subscribe(signal),
|
|
1702
|
+
host: (request, signal) => hostFrames.subscribe(signal)
|
|
1703
|
+
}
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
//#endregion
|
|
1707
|
+
//#region src/host/event-bus.ts
|
|
1708
|
+
var WorkspaceEventBus = class {
|
|
1709
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1710
|
+
emit(event) {
|
|
1711
|
+
for (const listener of this.listeners) listener(event);
|
|
1712
|
+
}
|
|
1713
|
+
subscribe(listener) {
|
|
1714
|
+
this.listeners.add(listener);
|
|
1715
|
+
return () => {
|
|
1716
|
+
this.listeners.delete(listener);
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
};
|
|
1720
|
+
//#endregion
|
|
1721
|
+
//#region src/host/file-service.ts
|
|
1722
|
+
var FileService = class {
|
|
1723
|
+
database;
|
|
1724
|
+
emit;
|
|
1725
|
+
constructor(database, emit) {
|
|
1726
|
+
this.database = database;
|
|
1727
|
+
this.emit = emit;
|
|
1728
|
+
}
|
|
1729
|
+
async list(root, relativePath, limit = 200, cursor) {
|
|
1730
|
+
const resolved = await resolveAuthorizedPath(root.realPath, relativePath);
|
|
1731
|
+
if (!(await stat(resolved.absolutePath)).isDirectory()) throw new ApiError(400, "PATH_NOT_DIRECTORY", "The requested path is not a directory.");
|
|
1732
|
+
const names = await readdir(resolved.absolutePath);
|
|
1733
|
+
names.sort((left, right) => left.localeCompare(right));
|
|
1734
|
+
const after = cursor === void 0 ? void 0 : decodeCursor(cursor);
|
|
1735
|
+
const afterIndex = after === void 0 ? 0 : names.findIndex((name) => name > after);
|
|
1736
|
+
const start = after === void 0 ? 0 : afterIndex === -1 ? names.length : afterIndex;
|
|
1737
|
+
const pageSize = Math.min(Math.max(limit, 1), 500);
|
|
1738
|
+
const selected = names.slice(start, start + pageSize);
|
|
1739
|
+
const entries = [];
|
|
1740
|
+
for (const name of selected) {
|
|
1741
|
+
const absolute = path.join(resolved.absolutePath, name);
|
|
1742
|
+
const info = await lstat(absolute);
|
|
1743
|
+
const childPath = relativePath === "" ? name : `${relativePath}/${name}`;
|
|
1744
|
+
entries.push({
|
|
1745
|
+
name,
|
|
1746
|
+
path: childPath,
|
|
1747
|
+
kind: kindOf(info),
|
|
1748
|
+
size: info.isFile() ? info.size : 0,
|
|
1749
|
+
modifiedAt: info.mtimeMs,
|
|
1750
|
+
writable: (info.mode & 128) !== 0
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
const last = selected.at(-1);
|
|
1754
|
+
return {
|
|
1755
|
+
path: relativePath,
|
|
1756
|
+
entries,
|
|
1757
|
+
...start + selected.length < names.length && last !== void 0 ? { nextCursor: encodeCursor(last) } : {}
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
async inspectContent(root, relativePath) {
|
|
1761
|
+
if (relativePath === "") throw new ApiError(400, "PATH_NOT_FILE", "The root directory has no file content.");
|
|
1762
|
+
const resolved = await resolveAuthorizedPath(root.realPath, relativePath);
|
|
1763
|
+
const info = await lstat(resolved.absolutePath);
|
|
1764
|
+
if (!info.isFile()) throw new ApiError(400, "PATH_NOT_FILE", "Only regular files have readable content.");
|
|
1765
|
+
const etag = await fileEtag(resolved.absolutePath);
|
|
1766
|
+
return {
|
|
1767
|
+
absolutePath: resolved.absolutePath,
|
|
1768
|
+
size: info.size,
|
|
1769
|
+
modifiedAt: info.mtimeMs,
|
|
1770
|
+
etag,
|
|
1771
|
+
contentType: contentTypeFor$1(relativePath)
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
streamContent(descriptor, range) {
|
|
1775
|
+
return createReadStream(descriptor.absolutePath, range);
|
|
1776
|
+
}
|
|
1777
|
+
async writeContent(root, relativePath, content, conditions) {
|
|
1778
|
+
if (relativePath === "") throw new ApiError(400, "PATH_NOT_FILE", "A root directory cannot be overwritten.");
|
|
1779
|
+
const resolved = await resolveAuthorizedPath(root.realPath, relativePath, { allowMissingLeaf: true });
|
|
1780
|
+
const parent = await resolveAuthorizedPath(root.realPath, parentWirePath(relativePath));
|
|
1781
|
+
if (!(await stat(parent.absolutePath)).isDirectory()) throw new ApiError(400, "PATH_NOT_DIRECTORY", "The destination parent is not a directory.");
|
|
1782
|
+
let mode = 384;
|
|
1783
|
+
if (resolved.exists) {
|
|
1784
|
+
const info = await lstat(resolved.absolutePath);
|
|
1785
|
+
if (!info.isFile()) throw new ApiError(400, "PATH_NOT_FILE", "Only regular files can be overwritten.");
|
|
1786
|
+
if (conditions.ifNoneMatch === "*") throw new ApiError(412, "PATH_ALREADY_EXISTS", "The file already exists.");
|
|
1787
|
+
if (conditions.ifMatch === void 0) throw new ApiError(428, "ETAG_REQUIRED", "If-Match is required when overwriting a file.");
|
|
1788
|
+
const currentEtag = await fileEtag(resolved.absolutePath);
|
|
1789
|
+
if (conditions.ifMatch !== currentEtag) throw new ApiError(412, "ETAG_MISMATCH", "The file changed after it was read.", { currentEtag });
|
|
1790
|
+
mode = info.mode;
|
|
1791
|
+
} else if (conditions.ifNoneMatch !== "*") throw new ApiError(428, "CREATE_CONDITION_REQUIRED", "If-None-Match: * is required when creating file content.");
|
|
1792
|
+
const temp = path.join(parent.absolutePath, `.${path.basename(resolved.absolutePath)}.dsh-${randomUUID()}.tmp`);
|
|
1793
|
+
try {
|
|
1794
|
+
const handle = await open(temp, "wx", mode);
|
|
1795
|
+
try {
|
|
1796
|
+
await handle.writeFile(content);
|
|
1797
|
+
await handle.sync();
|
|
1798
|
+
} finally {
|
|
1799
|
+
await handle.close();
|
|
1800
|
+
}
|
|
1801
|
+
if (resolved.exists) {
|
|
1802
|
+
const currentEtag = await fileEtag(resolved.absolutePath);
|
|
1803
|
+
if (conditions.ifMatch !== currentEtag) throw new ApiError(412, "ETAG_MISMATCH", "The file changed while the replacement was prepared.", { currentEtag });
|
|
1804
|
+
}
|
|
1805
|
+
await replaceFile(temp, resolved.absolutePath, resolved.exists);
|
|
1806
|
+
} catch (error) {
|
|
1807
|
+
await rm(temp, { force: true }).catch(() => void 0);
|
|
1808
|
+
throw error;
|
|
1809
|
+
}
|
|
1810
|
+
const info = await stat(resolved.absolutePath);
|
|
1811
|
+
const etag = await fileEtag(resolved.absolutePath);
|
|
1812
|
+
this.notify("file.changed", root.id, relativePath, {
|
|
1813
|
+
etag,
|
|
1814
|
+
size: info.size
|
|
1815
|
+
});
|
|
1816
|
+
return {
|
|
1817
|
+
etag,
|
|
1818
|
+
size: info.size,
|
|
1819
|
+
modifiedAt: info.mtimeMs
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
async createEntry(root, relativePath, kind) {
|
|
1823
|
+
if (relativePath === "") throw new ApiError(400, "PATH_INVALID", "A root already exists.");
|
|
1824
|
+
const resolved = await resolveAuthorizedPath(root.realPath, relativePath, { allowMissingLeaf: true });
|
|
1825
|
+
if (resolved.exists) throw new ApiError(409, "PATH_ALREADY_EXISTS", "The destination already exists.");
|
|
1826
|
+
await resolveAuthorizedPath(root.realPath, parentWirePath(relativePath));
|
|
1827
|
+
if (kind === "directory") await mkdir(resolved.absolutePath);
|
|
1828
|
+
else await writeFile(resolved.absolutePath, /* @__PURE__ */ new Uint8Array(), {
|
|
1829
|
+
flag: "wx",
|
|
1830
|
+
mode: 384
|
|
1831
|
+
});
|
|
1832
|
+
const info = await lstat(resolved.absolutePath);
|
|
1833
|
+
const name = validateRelativePath(relativePath).at(-1);
|
|
1834
|
+
this.notify("file.created", root.id, relativePath, { kind });
|
|
1835
|
+
return {
|
|
1836
|
+
name,
|
|
1837
|
+
path: relativePath,
|
|
1838
|
+
kind,
|
|
1839
|
+
size: info.isFile() ? info.size : 0,
|
|
1840
|
+
modifiedAt: info.mtimeMs,
|
|
1841
|
+
writable: (info.mode & 128) !== 0
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
async moveEntry(root, sourcePath, destinationPath) {
|
|
1845
|
+
if (sourcePath === "" || destinationPath === "") throw new ApiError(400, "PATH_INVALID", "The root itself cannot be moved.");
|
|
1846
|
+
const source = await resolveAuthorizedPath(root.realPath, sourcePath, { allowLeafLink: true });
|
|
1847
|
+
const destination = await resolveAuthorizedPath(root.realPath, destinationPath, { allowMissingLeaf: true });
|
|
1848
|
+
if (destination.exists) throw new ApiError(409, "PATH_ALREADY_EXISTS", "The destination already exists.");
|
|
1849
|
+
await resolveAuthorizedPath(root.realPath, parentWirePath(destinationPath));
|
|
1850
|
+
if (destination.absolutePath.startsWith(`${source.absolutePath}${path.sep}`)) throw new ApiError(400, "MOVE_INVALID", "A directory cannot be moved inside itself.");
|
|
1851
|
+
try {
|
|
1852
|
+
await rename(source.absolutePath, destination.absolutePath);
|
|
1853
|
+
} catch (error) {
|
|
1854
|
+
if (error.code !== "EXDEV") throw error;
|
|
1855
|
+
await copyVerified(source.absolutePath, destination.absolutePath);
|
|
1856
|
+
await removeEntry(source.absolutePath);
|
|
1857
|
+
}
|
|
1858
|
+
this.notify("file.moved", root.id, sourcePath, { destinationPath });
|
|
1859
|
+
}
|
|
1860
|
+
async trashEntry(root, relativePath) {
|
|
1861
|
+
if (relativePath === "") throw new ApiError(400, "PATH_INVALID", "An authorized root cannot be moved to trash.");
|
|
1862
|
+
const source = await resolveAuthorizedPath(root.realPath, relativePath, { allowLeafLink: true });
|
|
1863
|
+
const info = await lstat(source.absolutePath);
|
|
1864
|
+
const id = randomUUID();
|
|
1865
|
+
const itemDir = path.join(this.database.trashDir, root.id, id);
|
|
1866
|
+
const staging = path.join(itemDir, "staging");
|
|
1867
|
+
const payload = path.join(itemDir, "payload");
|
|
1868
|
+
await mkdir(itemDir, { recursive: true });
|
|
1869
|
+
const digest = await treeDigest(source.absolutePath);
|
|
1870
|
+
const record = {
|
|
1871
|
+
id,
|
|
1872
|
+
rootId: root.id,
|
|
1873
|
+
relativePath,
|
|
1874
|
+
payloadPath: payload,
|
|
1875
|
+
kind: kindOf(info),
|
|
1876
|
+
size: await treeSize(source.absolutePath),
|
|
1877
|
+
digest,
|
|
1878
|
+
createdAt: Date.now(),
|
|
1879
|
+
status: "moving"
|
|
1880
|
+
};
|
|
1881
|
+
this.database.insertTrash(record);
|
|
1882
|
+
try {
|
|
1883
|
+
try {
|
|
1884
|
+
await rename(source.absolutePath, payload);
|
|
1885
|
+
} catch (error) {
|
|
1886
|
+
if (error.code !== "EXDEV") throw error;
|
|
1887
|
+
await copyEntry(source.absolutePath, staging);
|
|
1888
|
+
if (await treeDigest(staging) !== digest) throw new ApiError(500, "TRASH_VERIFY_FAILED", "The cross-volume trash copy failed verification.");
|
|
1889
|
+
await rename(staging, payload);
|
|
1890
|
+
this.database.updateTrashStatus(id, "committed");
|
|
1891
|
+
await removeEntry(source.absolutePath);
|
|
1892
|
+
}
|
|
1893
|
+
this.database.updateTrashStatus(id, "trashed");
|
|
1894
|
+
} catch (error) {
|
|
1895
|
+
this.database.updateTrashStatus(id, "failed");
|
|
1896
|
+
throw error;
|
|
1897
|
+
}
|
|
1898
|
+
this.notify("file.trashed", root.id, relativePath, { trashId: id });
|
|
1899
|
+
return trashView({
|
|
1900
|
+
...record,
|
|
1901
|
+
status: "trashed"
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
listTrash(rootIds) {
|
|
1905
|
+
return this.database.listTrash(rootIds).map(trashView);
|
|
1906
|
+
}
|
|
1907
|
+
async restoreTrash(root, trashId) {
|
|
1908
|
+
const record = this.database.getTrash(trashId);
|
|
1909
|
+
if (record === void 0 || record.rootId !== root.id || record.status === "restored") throw new ApiError(404, "TRASH_NOT_FOUND", "The trash item does not exist for this root.");
|
|
1910
|
+
const destination = await resolveAuthorizedPath(root.realPath, record.relativePath, { allowMissingLeaf: true });
|
|
1911
|
+
if (destination.exists) throw new ApiError(409, "RESTORE_CONFLICT", "The original path is already occupied.");
|
|
1912
|
+
await resolveAuthorizedPath(root.realPath, parentWirePath(record.relativePath));
|
|
1913
|
+
try {
|
|
1914
|
+
await rename(record.payloadPath, destination.absolutePath);
|
|
1915
|
+
} catch (error) {
|
|
1916
|
+
if (error.code !== "EXDEV") throw error;
|
|
1917
|
+
await copyVerified(record.payloadPath, destination.absolutePath);
|
|
1918
|
+
await removeEntry(record.payloadPath);
|
|
1919
|
+
}
|
|
1920
|
+
this.database.updateTrashStatus(trashId, "restored");
|
|
1921
|
+
await rm(path.dirname(record.payloadPath), {
|
|
1922
|
+
recursive: true,
|
|
1923
|
+
force: true
|
|
1924
|
+
}).catch(() => void 0);
|
|
1925
|
+
this.notify("file.restored", root.id, record.relativePath, { trashId });
|
|
1926
|
+
}
|
|
1927
|
+
async purgeTrash(trashId) {
|
|
1928
|
+
const record = this.database.getTrash(trashId);
|
|
1929
|
+
if (record === void 0) throw new ApiError(404, "TRASH_NOT_FOUND", "The trash item does not exist.");
|
|
1930
|
+
await rm(path.dirname(record.payloadPath), {
|
|
1931
|
+
recursive: true,
|
|
1932
|
+
force: true
|
|
1933
|
+
});
|
|
1934
|
+
this.database.deleteTrashRecord(trashId);
|
|
1935
|
+
}
|
|
1936
|
+
notify(type, rootId, relativePath, extra) {
|
|
1937
|
+
this.emit({
|
|
1938
|
+
id: randomUUID(),
|
|
1939
|
+
type,
|
|
1940
|
+
time: Date.now(),
|
|
1941
|
+
data: {
|
|
1942
|
+
rootId,
|
|
1943
|
+
path: relativePath,
|
|
1944
|
+
...extra
|
|
1945
|
+
}
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
};
|
|
1949
|
+
async function fileEtag(filePath) {
|
|
1950
|
+
const hash = createHash("sha256");
|
|
1951
|
+
await pipeline(createReadStream(filePath), hash);
|
|
1952
|
+
return `"sha256-${hash.digest("base64url")}"`;
|
|
1953
|
+
}
|
|
1954
|
+
async function replaceFile(temp, destination, destinationExists) {
|
|
1955
|
+
try {
|
|
1956
|
+
await rename(temp, destination);
|
|
1957
|
+
return;
|
|
1958
|
+
} catch (error) {
|
|
1959
|
+
const code = error.code;
|
|
1960
|
+
if (!destinationExists || code !== "EEXIST" && code !== "EPERM" && code !== "EACCES") throw error;
|
|
1961
|
+
}
|
|
1962
|
+
const backup = `${destination}.dsh-backup-${randomUUID()}`;
|
|
1963
|
+
await rename(destination, backup);
|
|
1964
|
+
try {
|
|
1965
|
+
await rename(temp, destination);
|
|
1966
|
+
await unlink(backup);
|
|
1967
|
+
} catch (error) {
|
|
1968
|
+
await rename(backup, destination).catch(() => void 0);
|
|
1969
|
+
throw error;
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
async function copyVerified(source, destination) {
|
|
1973
|
+
const expected = await treeDigest(source);
|
|
1974
|
+
try {
|
|
1975
|
+
await copyEntry(source, destination);
|
|
1976
|
+
if (await treeDigest(destination) !== expected) throw new ApiError(500, "COPY_VERIFY_FAILED", "The copied entry failed digest verification.");
|
|
1977
|
+
} catch (error) {
|
|
1978
|
+
await rm(destination, {
|
|
1979
|
+
recursive: true,
|
|
1980
|
+
force: true
|
|
1981
|
+
}).catch(() => void 0);
|
|
1982
|
+
throw error;
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
async function copyEntry(source, destination) {
|
|
1986
|
+
const info = await lstat(source);
|
|
1987
|
+
if (info.isSymbolicLink()) {
|
|
1988
|
+
await symlink(await readlink(source), destination, process.platform === "win32" ? "junction" : void 0);
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
if (info.isDirectory()) {
|
|
1992
|
+
await mkdir(destination, { mode: info.mode });
|
|
1993
|
+
for (const name of await readdir(source)) await copyEntry(path.join(source, name), path.join(destination, name));
|
|
1994
|
+
await chmod(destination, info.mode).catch(() => void 0);
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
if (info.isFile()) {
|
|
1998
|
+
await copyFile(source, destination);
|
|
1999
|
+
await chmod(destination, info.mode).catch(() => void 0);
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
throw new ApiError(400, "FILE_TYPE_UNSUPPORTED", "This filesystem entry type cannot be copied safely.");
|
|
2003
|
+
}
|
|
2004
|
+
async function removeEntry(target) {
|
|
2005
|
+
const info = await lstat(target);
|
|
2006
|
+
if (info.isDirectory() && !info.isSymbolicLink()) await rm(target, { recursive: true });
|
|
2007
|
+
else await unlink(target);
|
|
2008
|
+
}
|
|
2009
|
+
async function treeDigest(target) {
|
|
2010
|
+
const hash = createHash("sha256");
|
|
2011
|
+
await appendDigest(hash, target, "");
|
|
2012
|
+
return hash.digest("hex");
|
|
2013
|
+
}
|
|
2014
|
+
async function appendDigest(hash, target, relative) {
|
|
2015
|
+
const info = await lstat(target);
|
|
2016
|
+
const kind = kindOf(info);
|
|
2017
|
+
hash.update(`${kind}\0${relative}\0${info.mode}\0`);
|
|
2018
|
+
if (info.isSymbolicLink()) {
|
|
2019
|
+
hash.update(await readlink(target));
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
if (info.isFile()) {
|
|
2023
|
+
await pipeline(createReadStream(target), hash, { end: false });
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
if (info.isDirectory()) {
|
|
2027
|
+
const names = await readdir(target);
|
|
2028
|
+
names.sort();
|
|
2029
|
+
for (const name of names) await appendDigest(hash, path.join(target, name), relative === "" ? name : `${relative}/${name}`);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
async function treeSize(target) {
|
|
2033
|
+
const info = await lstat(target);
|
|
2034
|
+
if (info.isFile()) return info.size;
|
|
2035
|
+
if (!info.isDirectory() || info.isSymbolicLink()) return 0;
|
|
2036
|
+
let total = 0;
|
|
2037
|
+
for (const name of await readdir(target)) total += await treeSize(path.join(target, name));
|
|
2038
|
+
return total;
|
|
2039
|
+
}
|
|
2040
|
+
function kindOf(info) {
|
|
2041
|
+
if (info.isSymbolicLink()) return "symlink";
|
|
2042
|
+
if (info.isDirectory()) return "directory";
|
|
2043
|
+
if (info.isFile()) return "file";
|
|
2044
|
+
return "other";
|
|
2045
|
+
}
|
|
2046
|
+
function contentTypeFor$1(filePath) {
|
|
2047
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
2048
|
+
if ([
|
|
2049
|
+
".ts",
|
|
2050
|
+
".tsx",
|
|
2051
|
+
".js",
|
|
2052
|
+
".jsx",
|
|
2053
|
+
".mjs",
|
|
2054
|
+
".cjs",
|
|
2055
|
+
".json",
|
|
2056
|
+
".md",
|
|
2057
|
+
".txt",
|
|
2058
|
+
".css",
|
|
2059
|
+
".html",
|
|
2060
|
+
".xml",
|
|
2061
|
+
".yml",
|
|
2062
|
+
".yaml",
|
|
2063
|
+
".toml",
|
|
2064
|
+
".ini",
|
|
2065
|
+
".sh",
|
|
2066
|
+
".ps1",
|
|
2067
|
+
".py",
|
|
2068
|
+
".go",
|
|
2069
|
+
".rs",
|
|
2070
|
+
".java",
|
|
2071
|
+
".kt"
|
|
2072
|
+
].includes(extension)) return extension === ".json" ? "application/json; charset=utf-8" : "text/plain; charset=utf-8";
|
|
2073
|
+
return "application/octet-stream";
|
|
2074
|
+
}
|
|
2075
|
+
function encodeCursor(name) {
|
|
2076
|
+
return Buffer.from(name, "utf8").toString("base64url");
|
|
2077
|
+
}
|
|
2078
|
+
function decodeCursor(cursor) {
|
|
2079
|
+
try {
|
|
2080
|
+
return Buffer.from(cursor, "base64url").toString("utf8");
|
|
2081
|
+
} catch {
|
|
2082
|
+
throw new ApiError(400, "CURSOR_INVALID", "The directory cursor is invalid.");
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
function trashView(record) {
|
|
2086
|
+
return {
|
|
2087
|
+
id: record.id,
|
|
2088
|
+
rootId: record.rootId,
|
|
2089
|
+
path: record.relativePath,
|
|
2090
|
+
kind: record.kind,
|
|
2091
|
+
size: record.size,
|
|
2092
|
+
createdAt: record.createdAt,
|
|
2093
|
+
status: record.status
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
function normalizeListenerHost(value) {
|
|
2097
|
+
if (typeof value !== "string" || value.trim() === "" || isIP(value.trim()) === 0) throw new ApiError(400, "LISTENER_HOST_INVALID", "host must be a numeric IPv4 or IPv6 address.");
|
|
2098
|
+
return value.trim();
|
|
2099
|
+
}
|
|
2100
|
+
function normalizeListenerPort(value, allowZero = false) {
|
|
2101
|
+
if (!Number.isInteger(value) || value < (allowZero ? 0 : 1) || value > 65535) throw new ApiError(400, "LISTENER_PORT_INVALID", `port must be between ${allowZero ? 0 : 1} and 65535.`);
|
|
2102
|
+
return value;
|
|
2103
|
+
}
|
|
2104
|
+
function loopbackFor(host) {
|
|
2105
|
+
return isIP(host) === 6 ? "::1" : "127.0.0.1";
|
|
2106
|
+
}
|
|
2107
|
+
//#endregion
|
|
2108
|
+
//#region src/host/config-text.ts
|
|
2109
|
+
/** 只用于展示与日志的地址类型标签。 */
|
|
2110
|
+
const KIND_LABEL = {
|
|
2111
|
+
LAN: "局域网",
|
|
2112
|
+
VIRTUAL_NET: "虚拟网",
|
|
2113
|
+
WAN: "公网",
|
|
2114
|
+
UNKNOWN: "未知网络"
|
|
2115
|
+
};
|
|
2116
|
+
const SORT_RANK = {
|
|
2117
|
+
LAN: 0,
|
|
2118
|
+
VIRTUAL_NET: 1,
|
|
2119
|
+
WAN: 2,
|
|
2120
|
+
UNKNOWN: 3
|
|
2121
|
+
};
|
|
2122
|
+
/** 默认监听端口 —— 与 `DEFAULT_PORT` 在脚本里保持一致。 */
|
|
2123
|
+
const DEFAULT_PORT = 3090;
|
|
2124
|
+
/**
|
|
2125
|
+
* 按 Kotlin `EndpointKind.infer` 的规则粗分类地址。
|
|
2126
|
+
*
|
|
2127
|
+
* 只用于标签与排序;真实可用性始终由客户端探活决定。
|
|
2128
|
+
*/
|
|
2129
|
+
function classifyHost(host) {
|
|
2130
|
+
const value = String(host ?? "").toLowerCase();
|
|
2131
|
+
if (value === "localhost" || value === "127.0.0.1" || value === "::1" || value.endsWith(".local")) return "LAN";
|
|
2132
|
+
const octets = value.split(".").map(Number);
|
|
2133
|
+
if (octets.length !== 4 || octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return "UNKNOWN";
|
|
2134
|
+
const [a, b] = octets;
|
|
2135
|
+
if (a === 10) return "LAN";
|
|
2136
|
+
if (a === 192 && b === 168) return "LAN";
|
|
2137
|
+
if (a === 172 && b >= 16 && b <= 31) return "LAN";
|
|
2138
|
+
if (a === 100 && b >= 64 && b <= 127) return "VIRTUAL_NET";
|
|
2139
|
+
if (a === 169 && b === 254) return "LAN";
|
|
2140
|
+
return "WAN";
|
|
2141
|
+
}
|
|
2142
|
+
/**
|
|
2143
|
+
* 枚举本机可用于对外连接的 IPv4 地址。
|
|
2144
|
+
*
|
|
2145
|
+
* 排除 loopback(`address.internal`)与 169.254.* —— 后者只在没有 DHCP 时出现,
|
|
2146
|
+
* 塞给用户只会造成「为什么这个地址连不上」的困惑。
|
|
2147
|
+
*/
|
|
2148
|
+
function detectEndpoints(port = DEFAULT_PORT) {
|
|
2149
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2150
|
+
const found = [];
|
|
2151
|
+
for (const [iface, addresses] of Object.entries(networkInterfaces())) for (const address of addresses ?? []) {
|
|
2152
|
+
if (address.family !== "IPv4" || address.internal) continue;
|
|
2153
|
+
if (address.address.startsWith("169.254.")) continue;
|
|
2154
|
+
if (seen.has(address.address)) continue;
|
|
2155
|
+
seen.add(address.address);
|
|
2156
|
+
const kind = classifyHost(address.address);
|
|
2157
|
+
found.push({
|
|
2158
|
+
kind,
|
|
2159
|
+
iface,
|
|
2160
|
+
baseUrl: `http://${address.address}:${port}`,
|
|
2161
|
+
label: `${KIND_LABEL[kind]}(${iface})`
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
found.sort((a, b) => SORT_RANK[a.kind] - SORT_RANK[b.kind] || a.iface.localeCompare(b.iface));
|
|
2165
|
+
return found.map(({ kind, label, baseUrl }) => ({
|
|
2166
|
+
kind,
|
|
2167
|
+
label,
|
|
2168
|
+
baseUrl
|
|
2169
|
+
}));
|
|
2170
|
+
}
|
|
2171
|
+
function trimOrNull(value) {
|
|
2172
|
+
if (value === void 0 || value === null) return null;
|
|
2173
|
+
const text = String(value).trim();
|
|
2174
|
+
return text === "" ? null : text;
|
|
2175
|
+
}
|
|
2176
|
+
/** 剥掉 `/api/v1` 后缀 —— 它是 DshClient 的接口前缀,不是地址的一部分。 */
|
|
2177
|
+
function normalizeBaseUrl(raw) {
|
|
2178
|
+
let value = String(raw ?? "").trim().replace(/\/+$/, "");
|
|
2179
|
+
if (value.toLowerCase().endsWith("/api/v1")) value = value.slice(0, -7).replace(/\/+$/, "");
|
|
2180
|
+
if (/^https?:\/\//i.test(value)) return value.replace(/^http:\/\//i, "http://").replace(/^https:\/\//i, "https://");
|
|
2181
|
+
return value;
|
|
2182
|
+
}
|
|
2183
|
+
/**
|
|
2184
|
+
* 编码成 `DSH1:` 文本。
|
|
2185
|
+
*
|
|
2186
|
+
* 键顺序与「空值不写键」的规则必须与 Kotlin 端一致 —— 见文件头说明。
|
|
2187
|
+
*/
|
|
2188
|
+
function encodeConfigText(payload) {
|
|
2189
|
+
const endpoints = payload.endpoints.map((endpoint) => ({
|
|
2190
|
+
label: endpoint.label.trim(),
|
|
2191
|
+
baseUrl: normalizeBaseUrl(endpoint.baseUrl)
|
|
2192
|
+
}));
|
|
2193
|
+
if (endpoints.length === 0) throw new Error("至少需要一个地址才能生成配置文本");
|
|
2194
|
+
const wire = {
|
|
2195
|
+
displayName: payload.displayName.trim(),
|
|
2196
|
+
endpoints
|
|
2197
|
+
};
|
|
2198
|
+
const token = trimOrNull(payload.token);
|
|
2199
|
+
if (token !== null) wire.token = token;
|
|
2200
|
+
const deviceName = trimOrNull(payload.deviceName);
|
|
2201
|
+
if (deviceName !== null) wire.deviceName = deviceName;
|
|
2202
|
+
const scopes = payload.scopes.map((scope) => scope.trim()).filter((scope) => scope !== "");
|
|
2203
|
+
if (scopes.length > 0) wire.scopes = scopes;
|
|
2204
|
+
const body = JSON.stringify(wire);
|
|
2205
|
+
return `DSH1:${Buffer.from(body, "utf8").toString("base64url")}`;
|
|
2206
|
+
}
|
|
2207
|
+
/** 本机显示名:主机名的首段,为空时退回一个中性名字。 */
|
|
2208
|
+
function defaultDisplayName() {
|
|
2209
|
+
const name = hostname().split(".")[0]?.trim();
|
|
2210
|
+
return name && name !== "" ? name : "电脑";
|
|
2211
|
+
}
|
|
2212
|
+
//#endregion
|
|
2213
|
+
//#region src/host/plugin-inventory.ts
|
|
2214
|
+
/**
|
|
2215
|
+
* 解析当前 profile 名。
|
|
2216
|
+
*
|
|
2217
|
+
* 依次尝试:`DSH_PROFILE` 环境变量 → `--profile=<name>` → `--profile <name>`。
|
|
2218
|
+
* 都拿不到时返回 null(调用方不要猜,猜错会读到另一个 profile 的清单)。
|
|
2219
|
+
*/
|
|
2220
|
+
function resolveProfileName(input = {}) {
|
|
2221
|
+
const env = input.env ?? process.env;
|
|
2222
|
+
const argv = input.argv ?? process.argv;
|
|
2223
|
+
const fromEnv = env.DSH_PROFILE?.trim();
|
|
2224
|
+
if (fromEnv) return fromEnv;
|
|
2225
|
+
for (const arg of argv) if (arg.startsWith("--profile=")) {
|
|
2226
|
+
const value = arg.slice(10).trim();
|
|
2227
|
+
if (value) return value;
|
|
2228
|
+
}
|
|
2229
|
+
const index = argv.indexOf("--profile");
|
|
2230
|
+
if (index >= 0) {
|
|
2231
|
+
const value = argv[index + 1]?.trim();
|
|
2232
|
+
if (value && !value.startsWith("-")) return value;
|
|
2233
|
+
}
|
|
2234
|
+
return null;
|
|
2235
|
+
}
|
|
2236
|
+
/** 解析 `$DSH_HOME`,缺省为 `~/.dsh`。 */
|
|
2237
|
+
function resolveDshHome(input = {}) {
|
|
2238
|
+
const configured = (input.env ?? process.env).DSH_HOME?.trim();
|
|
2239
|
+
if (configured) return configured;
|
|
2240
|
+
return path.join(input.homeDir ?? os.homedir(), ".dsh");
|
|
2241
|
+
}
|
|
2242
|
+
async function readJson$1(file) {
|
|
2243
|
+
try {
|
|
2244
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
2245
|
+
} catch {
|
|
2246
|
+
return null;
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
function stringRecord(value) {
|
|
2250
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
|
2251
|
+
const out = {};
|
|
2252
|
+
for (const [key, raw] of Object.entries(value)) if (typeof raw === "string") out[key] = raw;
|
|
2253
|
+
return out;
|
|
2254
|
+
}
|
|
2255
|
+
function stringArray(value) {
|
|
2256
|
+
if (!Array.isArray(value)) return [];
|
|
2257
|
+
return value.filter((item) => typeof item === "string");
|
|
2258
|
+
}
|
|
2259
|
+
/**
|
|
2260
|
+
* 读取插件清单。
|
|
2261
|
+
*
|
|
2262
|
+
* 任何读取失败都不抛异常,而是返回 `available: false` 与 `reason` ——
|
|
2263
|
+
* 一个只读的信息端点不该因为文件缺失就让整个请求 500,用户更需要看到「读不到,原因是什么」。
|
|
2264
|
+
*/
|
|
2265
|
+
async function readPluginInventory(input = {}) {
|
|
2266
|
+
const profile = resolveProfileName(input);
|
|
2267
|
+
if (!profile) return unavailable(null, null, "PROFILE_UNKNOWN");
|
|
2268
|
+
const dshHome = resolveDshHome(input);
|
|
2269
|
+
const profilePath = path.join(dshHome, "profiles", profile);
|
|
2270
|
+
const manifest = await readJson$1(path.join(profilePath, "package.json"));
|
|
2271
|
+
if (!manifest) return unavailable(profile, profilePath, "MANIFEST_UNREADABLE");
|
|
2272
|
+
const dependencies = stringRecord(manifest.dependencies);
|
|
2273
|
+
const dshSection = typeof manifest.dsh === "object" && manifest.dsh !== null ? manifest.dsh : {};
|
|
2274
|
+
const bundles = stringArray((typeof dshSection.profile === "object" && dshSection.profile !== null ? dshSection.profile : {}).bundles);
|
|
2275
|
+
const names = [.../* @__PURE__ */ new Set([...Object.keys(dependencies), ...bundles])].sort();
|
|
2276
|
+
const items = [];
|
|
2277
|
+
for (const name of names) {
|
|
2278
|
+
const installed = await readInstalledVersion(profilePath, name);
|
|
2279
|
+
const loaded = bundles.includes(name);
|
|
2280
|
+
const declared = dependencies[name] ?? null;
|
|
2281
|
+
const official = name.startsWith("@deepseek-ai/");
|
|
2282
|
+
items.push({
|
|
2283
|
+
name,
|
|
2284
|
+
declared,
|
|
2285
|
+
installed,
|
|
2286
|
+
loaded,
|
|
2287
|
+
official,
|
|
2288
|
+
state: resolveState({
|
|
2289
|
+
loaded,
|
|
2290
|
+
installed,
|
|
2291
|
+
official
|
|
2292
|
+
})
|
|
2293
|
+
});
|
|
2294
|
+
}
|
|
2295
|
+
return {
|
|
2296
|
+
profile,
|
|
2297
|
+
profilePath,
|
|
2298
|
+
available: true,
|
|
2299
|
+
reason: null,
|
|
2300
|
+
loadedCount: items.filter((item) => item.loaded).length,
|
|
2301
|
+
problemCount: items.filter((item) => item.state === "declared-missing" || item.state === "installed-not-loaded").length,
|
|
2302
|
+
items
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
/**
|
|
2306
|
+
* 判定条目状态。
|
|
2307
|
+
*
|
|
2308
|
+
* 顺序很重要:**先判「官方包 + 未落盘」**,否则它们会被判成 `declared-missing` ——
|
|
2309
|
+
* 这正是在真实机器上踩到的问题(5 个官方内核包一次性变成假警报)。
|
|
2310
|
+
*/
|
|
2311
|
+
function resolveState(input) {
|
|
2312
|
+
if (!input.loaded) return "installed-not-loaded";
|
|
2313
|
+
if (input.installed) return "loaded";
|
|
2314
|
+
if (input.official) return "runtime-provided";
|
|
2315
|
+
return "declared-missing";
|
|
2316
|
+
}
|
|
2317
|
+
/** 读 `node_modules/<name>/package.json` 的 version;包名含 scope 时要按路径展开。 */
|
|
2318
|
+
async function readInstalledVersion(profilePath, packageName) {
|
|
2319
|
+
const modulePath = path.join(profilePath, "node_modules", ...packageName.split("/"));
|
|
2320
|
+
const version = (await readJson$1(path.join(modulePath, "package.json")))?.version;
|
|
2321
|
+
return typeof version === "string" && version.trim() ? version : null;
|
|
2322
|
+
}
|
|
2323
|
+
function unavailable(profile, profilePath, reason) {
|
|
2324
|
+
return {
|
|
2325
|
+
profile,
|
|
2326
|
+
profilePath,
|
|
2327
|
+
available: false,
|
|
2328
|
+
reason,
|
|
2329
|
+
loadedCount: 0,
|
|
2330
|
+
problemCount: 0,
|
|
2331
|
+
items: []
|
|
2332
|
+
};
|
|
2333
|
+
}
|
|
2334
|
+
//#endregion
|
|
2335
|
+
//#region src/host/root-resolver.ts
|
|
2336
|
+
/**
|
|
2337
|
+
* 在授权根集合里找出包含该绝对路径的那一个(**纯字符串匹配,不触碰文件系统**)。
|
|
2338
|
+
*
|
|
2339
|
+
* 多个根相互嵌套时取**最长**的那个 —— 否则外层根会把内层根下的文件解析成更长的相对路径,
|
|
2340
|
+
* 虽然仍能读到同一文件,但会绕过内层根更严格的授权语义。
|
|
2341
|
+
*
|
|
2342
|
+
* 注意这只是粗筛:字符串前缀相等不代表真实落在根内(根内可能存在指向外部的符号链接)。
|
|
2343
|
+
* 真正的安全校验由 [inspectResolvedPath] 经 `resolveAuthorizedPath` 完成。
|
|
2344
|
+
*/
|
|
2345
|
+
function matchAuthorizedRoot(absolutePath, roots) {
|
|
2346
|
+
let best;
|
|
2347
|
+
for (const root of roots) {
|
|
2348
|
+
const relative = wireRelative(root.realPath, absolutePath);
|
|
2349
|
+
if (relative === void 0) continue;
|
|
2350
|
+
if (best === void 0 || root.realPath.length > best.root.realPath.length) best = {
|
|
2351
|
+
root,
|
|
2352
|
+
relative
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2355
|
+
return best === void 0 ? void 0 : {
|
|
2356
|
+
rootId: best.root.id,
|
|
2357
|
+
path: best.relative
|
|
2358
|
+
};
|
|
2359
|
+
}
|
|
2360
|
+
const CONTENT_TYPES = {
|
|
2361
|
+
".md": "text/markdown",
|
|
2362
|
+
".txt": "text/plain",
|
|
2363
|
+
".json": "application/json",
|
|
2364
|
+
".kt": "text/plain",
|
|
2365
|
+
".ts": "text/plain",
|
|
2366
|
+
".js": "text/plain",
|
|
2367
|
+
".yml": "text/plain",
|
|
2368
|
+
".yaml": "text/plain",
|
|
2369
|
+
".png": "image/png",
|
|
2370
|
+
".jpg": "image/jpeg",
|
|
2371
|
+
".jpeg": "image/jpeg",
|
|
2372
|
+
".gif": "image/gif",
|
|
2373
|
+
".pdf": "application/pdf",
|
|
2374
|
+
".zip": "application/zip"
|
|
2375
|
+
};
|
|
2376
|
+
function contentTypeFor(wirePath) {
|
|
2377
|
+
return CONTENT_TYPES[path.extname(wirePath).toLowerCase()] ?? null;
|
|
2378
|
+
}
|
|
2379
|
+
/**
|
|
2380
|
+
* 确认该路径确实可访问,并返回可外传的元数据。
|
|
2381
|
+
*
|
|
2382
|
+
* 安全校验交给 `resolveAuthorizedPath`(它逐段 `lstat` 并拒绝路径中的符号链接),
|
|
2383
|
+
* 因此这里既不重复实现校验,也不会因为字符串匹配的局限而放行越界访问。
|
|
2384
|
+
*
|
|
2385
|
+
* 不存在 → `404 PATH_NOT_FOUND`;目录不会被当成错误(会话里提到的路径可能是目录,
|
|
2386
|
+
* 用户点开它远比收到一个 400 更合理)。
|
|
2387
|
+
*/
|
|
2388
|
+
async function inspectResolvedPath(root, wirePath) {
|
|
2389
|
+
const resolved = await resolveAuthorizedPath(root.realPath, wirePath);
|
|
2390
|
+
const info = await lstat(resolved.absolutePath);
|
|
2391
|
+
if (info.isDirectory()) return {
|
|
2392
|
+
rootId: root.id,
|
|
2393
|
+
path: wirePath,
|
|
2394
|
+
kind: "directory",
|
|
2395
|
+
size: null,
|
|
2396
|
+
modifiedAt: info.mtimeMs,
|
|
2397
|
+
contentType: null
|
|
2398
|
+
};
|
|
2399
|
+
if (!info.isFile()) throw new ApiError(400, "PATH_NOT_FILE", "Only regular files and directories can be resolved.");
|
|
2400
|
+
return {
|
|
2401
|
+
rootId: root.id,
|
|
2402
|
+
path: wirePath,
|
|
2403
|
+
kind: "file",
|
|
2404
|
+
size: info.size,
|
|
2405
|
+
modifiedAt: info.mtimeMs,
|
|
2406
|
+
contentType: contentTypeFor(wirePath)
|
|
2407
|
+
};
|
|
2408
|
+
}
|
|
2409
|
+
//#endregion
|
|
2410
|
+
//#region src/shared/version.ts
|
|
2411
|
+
const PLUGIN_VERSION = "2.2.0";
|
|
2412
|
+
//#endregion
|
|
2413
|
+
//#region src/host/router.ts
|
|
2414
|
+
var ApiRouter = class {
|
|
2415
|
+
options;
|
|
2416
|
+
constructor(options) {
|
|
2417
|
+
this.options = options;
|
|
2418
|
+
}
|
|
2419
|
+
async handle(req, res, stripPrefix = "") {
|
|
2420
|
+
const requestId = header(req, "x-request-id") || randomUUID();
|
|
2421
|
+
setCors(req, res);
|
|
2422
|
+
res.setHeader("X-Request-Id", requestId);
|
|
2423
|
+
if (req.method === "OPTIONS") {
|
|
2424
|
+
res.writeHead(204);
|
|
2425
|
+
res.end();
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
try {
|
|
2429
|
+
const rawUrl = req.url ?? "/";
|
|
2430
|
+
const url = new URL(rawUrl, "http://workspace.local");
|
|
2431
|
+
if (stripPrefix !== "" && url.pathname.startsWith(stripPrefix)) url.pathname = url.pathname.slice(stripPrefix.length) || "/";
|
|
2432
|
+
await this.dispatch(req, res, url, requestId);
|
|
2433
|
+
} catch (error) {
|
|
2434
|
+
if (res.headersSent) {
|
|
2435
|
+
res.destroy(error instanceof Error ? error : void 0);
|
|
2436
|
+
return;
|
|
2437
|
+
}
|
|
2438
|
+
const apiError = asApiError(error);
|
|
2439
|
+
sendJson(res, apiError.status, { error: {
|
|
2440
|
+
code: apiError.code,
|
|
2441
|
+
message: apiError.message,
|
|
2442
|
+
requestId,
|
|
2443
|
+
...apiError.details === void 0 ? {} : { details: apiError.details }
|
|
2444
|
+
} });
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
async dispatch(req, res, url, requestId) {
|
|
2448
|
+
const method = req.method ?? "GET";
|
|
2449
|
+
const pathname = url.pathname.replace(/\/$/, "") || "/";
|
|
2450
|
+
if (method === "GET" && pathname === "/api/v1/healthz") {
|
|
2451
|
+
sendJson(res, 200, {
|
|
2452
|
+
ok: true,
|
|
2453
|
+
version: "v1",
|
|
2454
|
+
pluginVersion: PLUGIN_VERSION
|
|
2455
|
+
});
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
if (method === "POST" && pathname === "/api/v1/pairings/exchange") {
|
|
2459
|
+
this.options.auth.pairingAttempts.consume(requestAddress(req));
|
|
2460
|
+
const body = await readJson(req, 16384);
|
|
2461
|
+
if (typeof body.code !== "string" || typeof body.deviceName !== "string") throw new ApiError(400, "BODY_INVALID", "code and deviceName are required strings.");
|
|
2462
|
+
sendJson(res, 201, this.options.database.exchangePairing(body.code, body.deviceName));
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
if (pathname.startsWith("/manage")) {
|
|
2466
|
+
this.options.auth.requireAdmin(req);
|
|
2467
|
+
await this.dispatchManagement(req, res, url, requestId);
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
const principal = this.options.auth.principal(req, { allowAdmin: true });
|
|
2471
|
+
if (method === "GET" && pathname === "/api/v1/devices/self") {
|
|
2472
|
+
if (principal.kind === "admin") sendJson(res, 200, {
|
|
2473
|
+
id: principal.id,
|
|
2474
|
+
name: "Local administrator",
|
|
2475
|
+
scopes: [...principal.scopes],
|
|
2476
|
+
rootIds: "all"
|
|
2477
|
+
});
|
|
2478
|
+
else sendJson(res, 200, {
|
|
2479
|
+
id: principal.id,
|
|
2480
|
+
name: principal.name,
|
|
2481
|
+
scopes: [...principal.scopes],
|
|
2482
|
+
rootIds: [...principal.rootIds]
|
|
2483
|
+
});
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2486
|
+
if (method === "GET" && pathname === "/api/v1/roots") {
|
|
2487
|
+
this.options.auth.requireScope(principal, "files.read");
|
|
2488
|
+
sendJson(res, 200, { items: this.authorizedRoots(principal).map(({ id, label, createdAt }) => ({
|
|
2489
|
+
id,
|
|
2490
|
+
label,
|
|
2491
|
+
createdAt
|
|
2492
|
+
})) });
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
if (method === "GET" && pathname === "/api/v1/roots/resolve") {
|
|
2496
|
+
this.options.auth.requireScope(principal, "files.read");
|
|
2497
|
+
const requested = url.searchParams.get("path")?.trim() ?? "";
|
|
2498
|
+
if (requested === "") throw new ApiError(400, "BODY_INVALID", "path is required.");
|
|
2499
|
+
if (!path.isAbsolute(requested)) throw new ApiError(400, "PATH_NOT_ABSOLUTE", "path must be an absolute path.");
|
|
2500
|
+
const match = matchAuthorizedRoot(path.resolve(requested), this.authorizedRoots(principal));
|
|
2501
|
+
if (match === void 0) throw new ApiError(404, "PATH_OUTSIDE_ROOTS", "The path is not inside any root this device may access.");
|
|
2502
|
+
sendJson(res, 200, await inspectResolvedPath(this.requireRoot(principal, match.rootId), match.path));
|
|
2503
|
+
return;
|
|
2504
|
+
}
|
|
2505
|
+
const rootRoute = pathname.match(/^\/api\/v1\/roots\/([^/]+)\/(entries|content)$/);
|
|
2506
|
+
if (rootRoute !== null) {
|
|
2507
|
+
const rootId = decodeURIComponent(rootRoute[1]);
|
|
2508
|
+
const resource = rootRoute[2];
|
|
2509
|
+
const root = this.requireRoot(principal, rootId);
|
|
2510
|
+
if (resource === "entries") {
|
|
2511
|
+
await this.dispatchEntries(req, res, url, principal, root, requestId);
|
|
2512
|
+
return;
|
|
2513
|
+
}
|
|
2514
|
+
await this.dispatchContent(req, res, url, principal, root, requestId);
|
|
2515
|
+
return;
|
|
2516
|
+
}
|
|
2517
|
+
if (method === "GET" && pathname === "/api/v1/trash") {
|
|
2518
|
+
this.options.auth.requireScope(principal, "files.read");
|
|
2519
|
+
const rootIds = principal.rootIds === "all" ? void 0 : principal.rootIds;
|
|
2520
|
+
sendJson(res, 200, { items: this.options.files.listTrash(rootIds) });
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
const restoreRoute = pathname.match(/^\/api\/v1\/trash\/([^/]+)\/restore$/);
|
|
2524
|
+
if (method === "POST" && restoreRoute !== null) {
|
|
2525
|
+
this.options.auth.requireScope(principal, "files.delete");
|
|
2526
|
+
const record = this.options.database.getTrash(decodeURIComponent(restoreRoute[1]));
|
|
2527
|
+
if (record === void 0) throw new ApiError(404, "TRASH_NOT_FOUND", "The trash item does not exist.");
|
|
2528
|
+
const root = this.requireRoot(principal, record.rootId);
|
|
2529
|
+
await this.options.files.restoreTrash(root, record.id);
|
|
2530
|
+
this.audit(principal, "trash.restore", root.id, record.relativePath, requestId);
|
|
2531
|
+
sendJson(res, 200, { restored: true });
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
if (pathname === "/api/v1/chat/workspaces") {
|
|
2535
|
+
if (method === "GET") {
|
|
2536
|
+
this.options.auth.requireScope(principal, "chat.read");
|
|
2537
|
+
sendJson(res, 200, { items: await this.options.chat.listWorkspaces(principal) });
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2540
|
+
if (method === "POST") {
|
|
2541
|
+
this.options.auth.requireScope(principal, "chat.write");
|
|
2542
|
+
const body = await readJson(req, 64e3);
|
|
2543
|
+
if (typeof body.rootId !== "string" || typeof body.path !== "string") throw new ApiError(400, "BODY_INVALID", "rootId and path are required strings.");
|
|
2544
|
+
const workspace = await this.options.chat.createWorkspace(principal, body.rootId, body.path);
|
|
2545
|
+
this.audit(principal, "chat.workspace.create", body.rootId, body.path, requestId, { workspaceId: workspace.id });
|
|
2546
|
+
sendJson(res, 201, { workspace });
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
const workspaceRoute = pathname.match(/^\/api\/v1\/chat\/workspaces\/([^/]+)$/);
|
|
2551
|
+
if (workspaceRoute !== null) {
|
|
2552
|
+
const workspaceId = decodeURIComponent(workspaceRoute[1]);
|
|
2553
|
+
if (method === "PATCH") {
|
|
2554
|
+
this.options.auth.requireScope(principal, "chat.write");
|
|
2555
|
+
const body = await readJson(req, 64e3);
|
|
2556
|
+
if (typeof body.title !== "string") throw new ApiError(400, "BODY_INVALID", "title is required.");
|
|
2557
|
+
const workspace = await this.options.chat.renameWorkspace(principal, workspaceId, body.title);
|
|
2558
|
+
this.audit(principal, "chat.workspace.rename", workspace.rootId, workspace.path, requestId, { workspaceId });
|
|
2559
|
+
sendJson(res, 200, { workspace });
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
if (method === "DELETE") {
|
|
2563
|
+
this.options.auth.requireScope(principal, "chat.write");
|
|
2564
|
+
await this.options.chat.deleteWorkspace(principal, workspaceId);
|
|
2565
|
+
this.audit(principal, "chat.workspace.delete", void 0, void 0, requestId, { workspaceId });
|
|
2566
|
+
sendJson(res, 200, { deleted: true });
|
|
2567
|
+
return;
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
if (method === "GET" && pathname === "/api/v1/chat/agent-presets") {
|
|
2571
|
+
this.options.auth.requireScope(principal, "chat.read");
|
|
2572
|
+
sendJson(res, 200, { items: await this.options.chat.listAgentPresets() });
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
if (pathname === "/api/v1/settings/providers") {
|
|
2576
|
+
if (method === "GET") {
|
|
2577
|
+
this.options.auth.requireScope(principal, "settings.read");
|
|
2578
|
+
sendJson(res, 200, await this.options.settings.listProviders());
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2581
|
+
if (method === "POST") {
|
|
2582
|
+
this.options.auth.requireScope(principal, "settings.write");
|
|
2583
|
+
const body = await readJson(req, 1e6);
|
|
2584
|
+
if (typeof body.id !== "string" || typeof body.baseURL !== "string" || typeof body.api !== "string" || !Array.isArray(body.models)) throw new ApiError(400, "BODY_INVALID", "id, baseURL, api, and models are required.");
|
|
2585
|
+
const input = {
|
|
2586
|
+
id: body.id,
|
|
2587
|
+
baseURL: body.baseURL,
|
|
2588
|
+
api: body.api,
|
|
2589
|
+
models: body.models,
|
|
2590
|
+
...typeof body.displayName === "string" ? { displayName: body.displayName } : {},
|
|
2591
|
+
...typeof body.apiKey === "string" ? { apiKey: body.apiKey } : {},
|
|
2592
|
+
...Number.isSafeInteger(body.expectedRevision) ? { expectedRevision: body.expectedRevision } : {}
|
|
2593
|
+
};
|
|
2594
|
+
await this.options.settings.createProvider(input);
|
|
2595
|
+
this.audit(principal, "settings.provider.create", void 0, void 0, requestId, {
|
|
2596
|
+
providerId: input.id,
|
|
2597
|
+
credentialChanged: typeof input.apiKey === "string" && input.apiKey.trim() !== ""
|
|
2598
|
+
});
|
|
2599
|
+
sendJson(res, 201, await this.options.settings.listProviders());
|
|
2600
|
+
return;
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
if (method === "GET" && pathname === "/api/v1/settings/models") {
|
|
2604
|
+
this.options.auth.requireScope(principal, "settings.read");
|
|
2605
|
+
sendJson(res, 200, await this.options.settings.catalog());
|
|
2606
|
+
return;
|
|
2607
|
+
}
|
|
2608
|
+
if (method === "GET" && pathname === "/api/v1/settings/plugins") {
|
|
2609
|
+
this.options.auth.requireScope(principal, "settings.read");
|
|
2610
|
+
sendJson(res, 200, await readPluginInventory());
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
const providerRoute = pathname.match(/^\/api\/v1\/settings\/providers\/([^/]+)$/);
|
|
2614
|
+
if (providerRoute !== null) {
|
|
2615
|
+
const providerId = decodeURIComponent(providerRoute[1]);
|
|
2616
|
+
if (method === "PATCH") {
|
|
2617
|
+
this.options.auth.requireScope(principal, "settings.write");
|
|
2618
|
+
const body = await readJson(req, 1e6);
|
|
2619
|
+
await this.options.settings.updateProvider(providerId, body);
|
|
2620
|
+
this.audit(principal, "settings.provider.update", void 0, void 0, requestId, {
|
|
2621
|
+
providerId,
|
|
2622
|
+
fields: Object.keys(body).filter((field) => field !== "apiKey"),
|
|
2623
|
+
credentialChanged: Object.hasOwn(body, "apiKey")
|
|
2624
|
+
});
|
|
2625
|
+
sendJson(res, 200, await this.options.settings.listProviders());
|
|
2626
|
+
return;
|
|
2627
|
+
}
|
|
2628
|
+
if (method === "DELETE") {
|
|
2629
|
+
this.options.auth.requireScope(principal, "settings.write");
|
|
2630
|
+
const expectedRevision = optionalInteger(url.searchParams.get("expectedRevision"));
|
|
2631
|
+
await this.options.settings.removeProvider(providerId, expectedRevision);
|
|
2632
|
+
this.audit(principal, "settings.provider.remove", void 0, void 0, requestId, { providerId });
|
|
2633
|
+
res.writeHead(204);
|
|
2634
|
+
res.end();
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
const discoverProviderRoute = pathname.match(/^\/api\/v1\/settings\/providers\/([^/]+)\/discover$/);
|
|
2639
|
+
if (method === "POST" && discoverProviderRoute !== null) {
|
|
2640
|
+
this.options.auth.requireScope(principal, "settings.write");
|
|
2641
|
+
const providerId = decodeURIComponent(discoverProviderRoute[1]);
|
|
2642
|
+
const body = await readJson(req, 64e3);
|
|
2643
|
+
const draft = {
|
|
2644
|
+
...typeof body.baseURL === "string" ? { baseURL: body.baseURL } : {},
|
|
2645
|
+
...typeof body.api === "string" ? { api: body.api } : {},
|
|
2646
|
+
...typeof body.apiKey === "string" ? { apiKey: body.apiKey } : {}
|
|
2647
|
+
};
|
|
2648
|
+
const models = await this.options.settings.discover(providerId, draft);
|
|
2649
|
+
this.audit(principal, "settings.provider.discover", void 0, void 0, requestId, { providerId });
|
|
2650
|
+
sendJson(res, 200, { models });
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
if (pathname === "/api/v1/chat/sessions") {
|
|
2654
|
+
if (method === "GET") {
|
|
2655
|
+
this.options.auth.requireScope(principal, "chat.read");
|
|
2656
|
+
sendJson(res, 200, { items: await this.options.chat.listSessions(principal) });
|
|
2657
|
+
return;
|
|
2658
|
+
}
|
|
2659
|
+
if (method === "POST") {
|
|
2660
|
+
this.options.auth.requireScope(principal, "chat.write");
|
|
2661
|
+
const body = await readJson(req, 64e3);
|
|
2662
|
+
const cached = this.readIdempotent(principal, body.clientRequestId);
|
|
2663
|
+
if (cached !== void 0) {
|
|
2664
|
+
sendJson(res, cached.status, cached.body);
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
const options = {
|
|
2668
|
+
...typeof body.sessionId === "string" ? { sessionId: body.sessionId } : {},
|
|
2669
|
+
...typeof body.agentPreset === "string" ? { agentPreset: body.agentPreset } : {}
|
|
2670
|
+
};
|
|
2671
|
+
const usesWorkspace = typeof body.workspaceId === "string";
|
|
2672
|
+
const usesLegacyPath = typeof body.rootId === "string" && typeof body.path === "string";
|
|
2673
|
+
if (!usesWorkspace && !usesLegacyPath) throw new ApiError(400, "BODY_INVALID", "workspaceId or rootId with path is required.");
|
|
2674
|
+
const response = { session: usesWorkspace ? await this.options.chat.createSessionInWorkspace(principal, body.workspaceId, options) : await this.options.chat.createSession(principal, body.rootId, body.path, options) };
|
|
2675
|
+
this.saveIdempotent(principal, body.clientRequestId, 201, response);
|
|
2676
|
+
this.audit(principal, "chat.session.create", usesLegacyPath ? body.rootId : void 0, usesLegacyPath ? body.path : void 0, requestId, usesWorkspace ? { workspaceId: body.workspaceId } : {});
|
|
2677
|
+
sendJson(res, 201, response);
|
|
2678
|
+
return;
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
const sessionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)$/);
|
|
2682
|
+
if (method === "PATCH" && sessionRoute !== null) {
|
|
2683
|
+
this.options.auth.requireScope(principal, "chat.write");
|
|
2684
|
+
const sessionId = decodeURIComponent(sessionRoute[1]);
|
|
2685
|
+
const body = await readJson(req, 64e3);
|
|
2686
|
+
if (typeof body.title !== "string") throw new ApiError(400, "BODY_INVALID", "title is required.");
|
|
2687
|
+
const result = await this.options.chat.renameSession(principal, sessionId, body.title);
|
|
2688
|
+
this.audit(principal, "chat.session.rename", void 0, void 0, requestId, { sessionId });
|
|
2689
|
+
sendJson(res, 200, result);
|
|
2690
|
+
return;
|
|
2691
|
+
}
|
|
2692
|
+
const forkSessionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/fork$/);
|
|
2693
|
+
if (method === "POST" && forkSessionRoute !== null) {
|
|
2694
|
+
this.options.auth.requireScope(principal, "chat.write");
|
|
2695
|
+
const sessionId = decodeURIComponent(forkSessionRoute[1]);
|
|
2696
|
+
const body = await readJson(req, 16384);
|
|
2697
|
+
const atSeq = body.atSeq === void 0 ? void 0 : body.atSeq;
|
|
2698
|
+
if (atSeq !== void 0 && (!Number.isSafeInteger(atSeq) || atSeq < 0)) throw new ApiError(400, "BODY_INVALID", "atSeq must be a non-negative integer.");
|
|
2699
|
+
const forkedSessionId = await this.options.chat.forkSession(principal, sessionId, atSeq);
|
|
2700
|
+
this.audit(principal, "chat.session.fork", void 0, void 0, requestId, {
|
|
2701
|
+
sessionId,
|
|
2702
|
+
forkedSessionId
|
|
2703
|
+
});
|
|
2704
|
+
sendJson(res, 201, { sessionId: forkedSessionId });
|
|
2705
|
+
return;
|
|
2706
|
+
}
|
|
2707
|
+
const archiveSessionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/archive$/);
|
|
2708
|
+
if (method === "POST" && archiveSessionRoute !== null) {
|
|
2709
|
+
this.options.auth.requireScope(principal, "chat.write");
|
|
2710
|
+
const sessionId = decodeURIComponent(archiveSessionRoute[1]);
|
|
2711
|
+
await this.options.chat.archiveSession(principal, sessionId);
|
|
2712
|
+
this.audit(principal, "chat.session.archive", void 0, void 0, requestId, { sessionId });
|
|
2713
|
+
sendJson(res, 200, { archived: true });
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
const messagesRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/messages$/);
|
|
2717
|
+
if (messagesRoute !== null) {
|
|
2718
|
+
const sessionId = decodeURIComponent(messagesRoute[1]);
|
|
2719
|
+
if (method === "GET") {
|
|
2720
|
+
this.options.auth.requireScope(principal, "chat.read");
|
|
2721
|
+
const beforeSeq = optionalInteger(url.searchParams.get("beforeSeq"));
|
|
2722
|
+
const maxMessages = optionalInteger(url.searchParams.get("maxMessages"));
|
|
2723
|
+
sendJson(res, 200, await this.options.chat.history(principal, sessionId, {
|
|
2724
|
+
...beforeSeq === void 0 ? {} : { beforeSeq },
|
|
2725
|
+
...maxMessages === void 0 ? {} : { maxMessages }
|
|
2726
|
+
}));
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2729
|
+
if (method === "POST") {
|
|
2730
|
+
this.options.auth.requireScope(principal, "chat.write");
|
|
2731
|
+
const body = await readJson(req, 12e6);
|
|
2732
|
+
const cached = this.readIdempotent(principal, body.clientRequestId);
|
|
2733
|
+
if (cached !== void 0) {
|
|
2734
|
+
sendJson(res, cached.status, cached.body);
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
const parts = readPromptParts(body);
|
|
2738
|
+
const mode = body.mode === "steer" ? "steer" : "queue";
|
|
2739
|
+
const response = { message: await this.options.chat.prompt(principal, sessionId, parts, mode, typeof body.clientTimeZone === "string" ? body.clientTimeZone : void 0) };
|
|
2740
|
+
this.saveIdempotent(principal, body.clientRequestId, 202, response);
|
|
2741
|
+
this.audit(principal, "chat.message.submit", void 0, void 0, requestId, {
|
|
2742
|
+
sessionId,
|
|
2743
|
+
mode
|
|
2744
|
+
});
|
|
2745
|
+
sendJson(res, 202, response);
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
const attachmentRoute = pathname.match(/^\/api\/v1\/attachments\/([^/]+)$/);
|
|
2750
|
+
if (attachmentRoute !== null && method === "GET") {
|
|
2751
|
+
this.options.auth.requireScope(principal, "files.read");
|
|
2752
|
+
const attachmentId = decodeURIComponent(attachmentRoute[1]);
|
|
2753
|
+
const { bytes, mediaType } = await this.options.chat.readAttachment(principal, attachmentId);
|
|
2754
|
+
res.statusCode = 200;
|
|
2755
|
+
res.setHeader("Content-Type", mediaType);
|
|
2756
|
+
res.setHeader("Content-Length", String(bytes.byteLength));
|
|
2757
|
+
res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
|
|
2758
|
+
res.end(Buffer.from(bytes));
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
const attachmentsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/attachments$/);
|
|
2762
|
+
if (attachmentsRoute !== null && method === "POST") {
|
|
2763
|
+
this.options.auth.requireScope(principal, "chat.write");
|
|
2764
|
+
const body = await readJson(req, 24e6);
|
|
2765
|
+
if (typeof body.data !== "string" || body.data === "") throw new ApiError(400, "BODY_INVALID", "data (base64) is required.");
|
|
2766
|
+
const name = typeof body.name === "string" && body.name.trim() !== "" ? body.name.trim() : void 0;
|
|
2767
|
+
sendJson(res, 201, await this.options.chat.uploadAttachment(principal, decodeURIComponent(attachmentsRoute[1]), body.data, name));
|
|
2768
|
+
return;
|
|
2769
|
+
}
|
|
2770
|
+
const commandsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/commands$/);
|
|
2771
|
+
if (commandsRoute !== null) {
|
|
2772
|
+
const sessionId = decodeURIComponent(commandsRoute[1]);
|
|
2773
|
+
if (method === "GET") {
|
|
2774
|
+
this.options.auth.requireScope(principal, "chat.read");
|
|
2775
|
+
sendJson(res, 200, { items: await this.options.chat.listCommands(principal, sessionId) });
|
|
2776
|
+
return;
|
|
2777
|
+
}
|
|
2778
|
+
if (method === "POST") {
|
|
2779
|
+
this.options.auth.requireScope(principal, "chat.write");
|
|
2780
|
+
const body = await readJson(req, 32768);
|
|
2781
|
+
if (typeof body.line !== "string") throw new ApiError(400, "BODY_INVALID", "line is required.");
|
|
2782
|
+
const execution = await this.options.chat.executeCommand(principal, sessionId, body.line);
|
|
2783
|
+
this.audit(principal, "chat.command.execute", void 0, void 0, requestId, {
|
|
2784
|
+
sessionId,
|
|
2785
|
+
command: commandName(body.line)
|
|
2786
|
+
});
|
|
2787
|
+
sendJson(res, 200, { execution });
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
const approvalsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/approvals$/);
|
|
2792
|
+
if (method === "GET" && approvalsRoute !== null) {
|
|
2793
|
+
this.options.auth.requireScope(principal, "chat.read");
|
|
2794
|
+
const sessionId = decodeURIComponent(approvalsRoute[1]);
|
|
2795
|
+
sendJson(res, 200, { items: await this.options.chat.listPendingApprovals(principal, sessionId) });
|
|
2796
|
+
return;
|
|
2797
|
+
}
|
|
2798
|
+
const approvalDecisionRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/approvals\/([^/]+)\/decision$/);
|
|
2799
|
+
if (method === "POST" && approvalDecisionRoute !== null) {
|
|
2800
|
+
this.options.auth.requireScope(principal, "chat.write");
|
|
2801
|
+
const sessionId = decodeURIComponent(approvalDecisionRoute[1]);
|
|
2802
|
+
const approvalId = decodeURIComponent(approvalDecisionRoute[2]);
|
|
2803
|
+
const body = await readJson(req, 16384);
|
|
2804
|
+
if (body.outcome !== "allowed-once" && body.outcome !== "rejected") throw new ApiError(400, "BODY_INVALID", "outcome must be allowed-once or rejected.");
|
|
2805
|
+
const approval = await this.options.chat.decideApproval(principal, sessionId, approvalId, body.outcome);
|
|
2806
|
+
this.audit(principal, "chat.approval.decide", void 0, void 0, requestId, {
|
|
2807
|
+
sessionId,
|
|
2808
|
+
approvalId,
|
|
2809
|
+
outcome: body.outcome,
|
|
2810
|
+
toolName: approval.toolName,
|
|
2811
|
+
risk: approval.risk
|
|
2812
|
+
});
|
|
2813
|
+
sendJson(res, 202, { accepted: true });
|
|
2814
|
+
return;
|
|
2815
|
+
}
|
|
2816
|
+
const sessionModelsRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/model(?:s)?$/);
|
|
2817
|
+
if (sessionModelsRoute !== null) {
|
|
2818
|
+
const sessionId = decodeURIComponent(sessionModelsRoute[1]);
|
|
2819
|
+
if (method === "GET") {
|
|
2820
|
+
this.options.auth.requireScope(principal, "chat.read");
|
|
2821
|
+
sendJson(res, 200, await this.options.chat.models(principal, sessionId));
|
|
2822
|
+
return;
|
|
2823
|
+
}
|
|
2824
|
+
if (method === "PUT" && pathname.endsWith("/model")) {
|
|
2825
|
+
this.options.auth.requireScope(principal, "chat.write");
|
|
2826
|
+
const body = await readJson(req, 64e3);
|
|
2827
|
+
if (typeof body.provider !== "string" || typeof body.model !== "string") throw new ApiError(400, "BODY_INVALID", "provider and model are required strings.");
|
|
2828
|
+
const selected = await this.options.chat.selectModel(principal, sessionId, {
|
|
2829
|
+
provider: body.provider,
|
|
2830
|
+
model: body.model,
|
|
2831
|
+
...typeof body.reasoningEffort === "string" ? { reasoningEffort: body.reasoningEffort } : {}
|
|
2832
|
+
});
|
|
2833
|
+
this.audit(principal, "chat.model.select", void 0, void 0, requestId, {
|
|
2834
|
+
sessionId,
|
|
2835
|
+
provider: selected.provider,
|
|
2836
|
+
model: selected.model,
|
|
2837
|
+
...selected.reasoningEffort === void 0 ? {} : { reasoningEffort: selected.reasoningEffort }
|
|
2838
|
+
});
|
|
2839
|
+
sendJson(res, 200, { selected });
|
|
2840
|
+
return;
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
const sessionAgentPresetRoute = pathname.match(/^\/api\/v1\/chat\/sessions\/([^/]+)\/agent-preset$/);
|
|
2844
|
+
if (method === "PUT" && sessionAgentPresetRoute !== null) {
|
|
2845
|
+
this.options.auth.requireScope(principal, "chat.write");
|
|
2846
|
+
const sessionId = decodeURIComponent(sessionAgentPresetRoute[1]);
|
|
2847
|
+
const body = await readJson(req, 64e3);
|
|
2848
|
+
if (typeof body.agentPreset !== "string") throw new ApiError(400, "BODY_INVALID", "agentPreset is required.");
|
|
2849
|
+
const agentPreset = await this.options.chat.selectAgentPreset(principal, sessionId, body.agentPreset);
|
|
2850
|
+
this.audit(principal, "chat.agent-preset.select", void 0, void 0, requestId, {
|
|
2851
|
+
sessionId,
|
|
2852
|
+
agentPreset
|
|
2853
|
+
});
|
|
2854
|
+
sendJson(res, 200, { agentPreset });
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2857
|
+
const cancelRoute = pathname.match(/^\/api\/v1\/chat\/runs\/([^/]+)\/cancel$/);
|
|
2858
|
+
if (method === "POST" && cancelRoute !== null) {
|
|
2859
|
+
this.options.auth.requireScope(principal, "chat.write");
|
|
2860
|
+
const sessionId = decodeURIComponent(cancelRoute[1]);
|
|
2861
|
+
await this.options.chat.cancel(principal, sessionId);
|
|
2862
|
+
this.audit(principal, "chat.run.cancel", void 0, void 0, requestId, { sessionId });
|
|
2863
|
+
sendJson(res, 202, { accepted: true });
|
|
2864
|
+
return;
|
|
2865
|
+
}
|
|
2866
|
+
throw new ApiError(404, "ROUTE_NOT_FOUND", "The requested API route does not exist.");
|
|
2867
|
+
}
|
|
2868
|
+
async dispatchManagement(req, res, url, requestId) {
|
|
2869
|
+
const method = req.method ?? "GET";
|
|
2870
|
+
const pathname = url.pathname.replace(/\/$/, "");
|
|
2871
|
+
if (method === "GET" && pathname === "/manage/status") {
|
|
2872
|
+
const operationId = url.searchParams.get("operationId") ?? void 0;
|
|
2873
|
+
sendJson(res, 200, {
|
|
2874
|
+
...this.options.remote.status(operationId),
|
|
2875
|
+
roots: this.options.database.listRoots(),
|
|
2876
|
+
devices: this.options.database.listDevices()
|
|
2877
|
+
});
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
2880
|
+
if (method === "GET" && pathname === "/manage/roots") {
|
|
2881
|
+
sendJson(res, 200, { items: this.options.database.listRoots() });
|
|
2882
|
+
return;
|
|
2883
|
+
}
|
|
2884
|
+
if (method === "POST" && pathname === "/manage/roots") {
|
|
2885
|
+
const body = await readJson(req, 64e3);
|
|
2886
|
+
if (typeof body.path !== "string") throw new ApiError(400, "BODY_INVALID", "path is required.");
|
|
2887
|
+
sendJson(res, 201, await this.options.database.addRoot(body.path, typeof body.label === "string" ? body.label : void 0));
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
const rootRoute = pathname.match(/^\/manage\/roots\/([^/]+)$/);
|
|
2891
|
+
if (method === "DELETE" && rootRoute !== null) {
|
|
2892
|
+
this.options.database.removeRoot(decodeURIComponent(rootRoute[1]));
|
|
2893
|
+
res.writeHead(204);
|
|
2894
|
+
res.end();
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
if (method === "GET" && pathname === "/manage/devices") {
|
|
2898
|
+
sendJson(res, 200, { items: this.options.database.listDevices() });
|
|
2899
|
+
return;
|
|
2900
|
+
}
|
|
2901
|
+
const deviceRoute = pathname.match(/^\/manage\/devices\/([^/]+)$/);
|
|
2902
|
+
if (deviceRoute !== null && method === "PATCH") {
|
|
2903
|
+
const body = await readJson(req, 64e3);
|
|
2904
|
+
const scopes = validateScopes(body.scopes);
|
|
2905
|
+
const rootIds = validateStringArray(body.rootIds, "rootIds");
|
|
2906
|
+
sendJson(res, 200, this.options.database.updateDevice(decodeURIComponent(deviceRoute[1]), scopes, rootIds));
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
if (deviceRoute !== null && method === "DELETE") {
|
|
2910
|
+
this.options.database.revokeDevice(decodeURIComponent(deviceRoute[1]));
|
|
2911
|
+
res.writeHead(204);
|
|
2912
|
+
res.end();
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
2915
|
+
if (method === "POST" && pathname === "/manage/pairings") {
|
|
2916
|
+
const body = await readJson(req, 64e3);
|
|
2917
|
+
sendJson(res, 201, this.options.database.createPairing(validateStringArray(body.rootIds, "rootIds"), validateScopes(body.scopes)));
|
|
2918
|
+
return;
|
|
2919
|
+
}
|
|
2920
|
+
if (method === "POST" && pathname === "/manage/config/text") {
|
|
2921
|
+
const body = await readJson(req, 64e3);
|
|
2922
|
+
const port = Number.isInteger(body.port) && Number(body.port) > 0 && Number(body.port) < 65536 ? Number(body.port) : DEFAULT_PORT;
|
|
2923
|
+
const endpoints = readRequestedEndpoints(body.endpoints) ?? detectEndpoints(port);
|
|
2924
|
+
if (endpoints.length === 0) throw new ApiError(400, "NO_ENDPOINT", "No usable local address was detected. Provide endpoints explicitly.");
|
|
2925
|
+
const scopes = body.scopes === void 0 ? [...DEFAULT_CONFIG_SCOPES] : validateScopes(body.scopes);
|
|
2926
|
+
const rootIds = body.rootIds === void 0 ? this.options.database.listRoots().map((root) => root.id) : validateStringArray(body.rootIds, "rootIds");
|
|
2927
|
+
const deviceName = readOptionalText(body.deviceName) ?? "Android 设备";
|
|
2928
|
+
const displayName = readOptionalText(body.displayName) ?? defaultDisplayName();
|
|
2929
|
+
const pairing = this.options.database.createPairing(rootIds, scopes);
|
|
2930
|
+
const exchanged = this.options.database.exchangePairing(pairing.code, deviceName);
|
|
2931
|
+
sendJson(res, 201, {
|
|
2932
|
+
text: encodeConfigText({
|
|
2933
|
+
displayName,
|
|
2934
|
+
endpoints,
|
|
2935
|
+
token: exchanged.token,
|
|
2936
|
+
deviceName,
|
|
2937
|
+
scopes
|
|
2938
|
+
}),
|
|
2939
|
+
displayName,
|
|
2940
|
+
deviceName,
|
|
2941
|
+
endpoints,
|
|
2942
|
+
deviceId: exchanged.device.id
|
|
2943
|
+
});
|
|
2944
|
+
return;
|
|
2945
|
+
}
|
|
2946
|
+
if (method === "POST" && pathname === "/manage/remote/enable") {
|
|
2947
|
+
const body = await readJson(req, 64e3);
|
|
2948
|
+
const operation = this.options.remote.enable(validateStringArray(body.rootIds, "rootIds"), validateScopes(body.scopes));
|
|
2949
|
+
res.setHeader("Connection", "close");
|
|
2950
|
+
sendJson(res, 202, operation);
|
|
2951
|
+
return;
|
|
2952
|
+
}
|
|
2953
|
+
if (method === "POST" && pathname === "/manage/remote/disable") {
|
|
2954
|
+
const operation = this.options.remote.disable();
|
|
2955
|
+
res.setHeader("Connection", "close");
|
|
2956
|
+
sendJson(res, 202, operation);
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
if (method === "PUT" && pathname === "/manage/remote/listener") {
|
|
2960
|
+
const body = await readJson(req, 16384);
|
|
2961
|
+
const operation = this.options.remote.configure(typeof body.host === "string" ? body.host : "", body.port);
|
|
2962
|
+
res.setHeader("Connection", "close");
|
|
2963
|
+
sendJson(res, 202, operation);
|
|
2964
|
+
return;
|
|
2965
|
+
}
|
|
2966
|
+
if (method === "GET" && pathname === "/manage/audit") {
|
|
2967
|
+
sendJson(res, 200, { items: this.options.database.listAudit(optionalInteger(url.searchParams.get("limit")) ?? 200) });
|
|
2968
|
+
return;
|
|
2969
|
+
}
|
|
2970
|
+
const purgeRoute = pathname.match(/^\/manage\/trash\/([^/]+)$/);
|
|
2971
|
+
if (method === "DELETE" && purgeRoute !== null) {
|
|
2972
|
+
await this.options.files.purgeTrash(decodeURIComponent(purgeRoute[1]));
|
|
2973
|
+
this.options.database.audit("admin", "loopback-admin", "trash.purge", void 0, void 0, {}, requestId);
|
|
2974
|
+
res.writeHead(204);
|
|
2975
|
+
res.end();
|
|
2976
|
+
return;
|
|
2977
|
+
}
|
|
2978
|
+
throw new ApiError(404, "ROUTE_NOT_FOUND", "The requested management route does not exist.");
|
|
2979
|
+
}
|
|
2980
|
+
async dispatchEntries(req, res, url, principal, root, requestId) {
|
|
2981
|
+
const method = req.method ?? "GET";
|
|
2982
|
+
if (method === "GET") {
|
|
2983
|
+
this.options.auth.requireScope(principal, "files.read");
|
|
2984
|
+
sendJson(res, 200, await this.options.files.list(root, url.searchParams.get("path") ?? "", optionalInteger(url.searchParams.get("limit")) ?? 200, url.searchParams.get("cursor") ?? void 0));
|
|
2985
|
+
return;
|
|
2986
|
+
}
|
|
2987
|
+
if (method === "POST") {
|
|
2988
|
+
this.options.auth.requireScope(principal, "files.write");
|
|
2989
|
+
const body = await readJson(req, 64e3);
|
|
2990
|
+
if (typeof body.path !== "string" || body.kind !== "file" && body.kind !== "directory") throw new ApiError(400, "BODY_INVALID", "path and a file/directory kind are required.");
|
|
2991
|
+
const entry = await this.options.files.createEntry(root, body.path, body.kind);
|
|
2992
|
+
this.audit(principal, "file.create", root.id, body.path, requestId, { kind: body.kind });
|
|
2993
|
+
sendJson(res, 201, entry);
|
|
2994
|
+
return;
|
|
2995
|
+
}
|
|
2996
|
+
if (method === "PATCH") {
|
|
2997
|
+
this.options.auth.requireScope(principal, "files.write");
|
|
2998
|
+
const body = await readJson(req, 64e3);
|
|
2999
|
+
if (typeof body.path !== "string" || typeof body.destinationPath !== "string") throw new ApiError(400, "BODY_INVALID", "path and destinationPath are required.");
|
|
3000
|
+
await this.options.files.moveEntry(root, body.path, body.destinationPath);
|
|
3001
|
+
this.audit(principal, "file.move", root.id, body.path, requestId, { destinationPath: body.destinationPath });
|
|
3002
|
+
sendJson(res, 200, { moved: true });
|
|
3003
|
+
return;
|
|
3004
|
+
}
|
|
3005
|
+
if (method === "DELETE") {
|
|
3006
|
+
this.options.auth.requireScope(principal, "files.delete");
|
|
3007
|
+
const relativePath = url.searchParams.get("path");
|
|
3008
|
+
if (relativePath === null) throw new ApiError(400, "PATH_REQUIRED", "The path query parameter is required.");
|
|
3009
|
+
const item = await this.options.files.trashEntry(root, relativePath);
|
|
3010
|
+
this.audit(principal, "file.trash", root.id, relativePath, requestId, { trashId: item.id });
|
|
3011
|
+
sendJson(res, 200, item);
|
|
3012
|
+
return;
|
|
3013
|
+
}
|
|
3014
|
+
throw new ApiError(405, "METHOD_NOT_ALLOWED", "The method is not supported for entries.");
|
|
3015
|
+
}
|
|
3016
|
+
async dispatchContent(req, res, url, principal, root, requestId) {
|
|
3017
|
+
const relativePath = url.searchParams.get("path");
|
|
3018
|
+
if (relativePath === null) throw new ApiError(400, "PATH_REQUIRED", "The path query parameter is required.");
|
|
3019
|
+
if (req.method === "GET") {
|
|
3020
|
+
this.options.auth.requireScope(principal, "files.read");
|
|
3021
|
+
const descriptor = await this.options.files.inspectContent(root, relativePath);
|
|
3022
|
+
const range = parseRange(req.headers.range, descriptor.size);
|
|
3023
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
3024
|
+
res.setHeader("ETag", descriptor.etag);
|
|
3025
|
+
res.setHeader("Last-Modified", new Date(descriptor.modifiedAt).toUTCString());
|
|
3026
|
+
res.setHeader("Content-Type", descriptor.contentType);
|
|
3027
|
+
res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(relativePath.split("/").at(-1) ?? "file")}`);
|
|
3028
|
+
if (range === void 0) {
|
|
3029
|
+
res.writeHead(200, { "Content-Length": descriptor.size });
|
|
3030
|
+
this.options.files.streamContent(descriptor).pipe(res);
|
|
3031
|
+
} else {
|
|
3032
|
+
res.writeHead(206, {
|
|
3033
|
+
"Content-Length": range.end - range.start + 1,
|
|
3034
|
+
"Content-Range": `bytes ${range.start}-${range.end}/${descriptor.size}`
|
|
3035
|
+
});
|
|
3036
|
+
this.options.files.streamContent(descriptor, range).pipe(res);
|
|
3037
|
+
}
|
|
3038
|
+
return;
|
|
3039
|
+
}
|
|
3040
|
+
if (req.method === "PUT") {
|
|
3041
|
+
this.options.auth.requireScope(principal, "files.write");
|
|
3042
|
+
const content = await readBytes(req, this.options.maxUploadBytes);
|
|
3043
|
+
const result = await this.options.files.writeContent(root, relativePath, content, {
|
|
3044
|
+
...req.headers["if-match"] === void 0 ? {} : { ifMatch: String(req.headers["if-match"]) },
|
|
3045
|
+
...req.headers["if-none-match"] === void 0 ? {} : { ifNoneMatch: String(req.headers["if-none-match"]) }
|
|
3046
|
+
});
|
|
3047
|
+
this.audit(principal, "file.write", root.id, relativePath, requestId, {
|
|
3048
|
+
size: result.size,
|
|
3049
|
+
etag: result.etag
|
|
3050
|
+
});
|
|
3051
|
+
res.setHeader("ETag", result.etag);
|
|
3052
|
+
sendJson(res, 200, result);
|
|
3053
|
+
return;
|
|
3054
|
+
}
|
|
3055
|
+
throw new ApiError(405, "METHOD_NOT_ALLOWED", "The method is not supported for content.");
|
|
3056
|
+
}
|
|
3057
|
+
requireRoot(principal, rootId) {
|
|
3058
|
+
this.options.auth.requireRoot(principal, rootId);
|
|
3059
|
+
const root = this.options.database.getRoot(rootId);
|
|
3060
|
+
if (root === void 0) throw new ApiError(404, "ROOT_NOT_FOUND", "The authorized root does not exist.");
|
|
3061
|
+
return root;
|
|
3062
|
+
}
|
|
3063
|
+
authorizedRoots(principal) {
|
|
3064
|
+
const roots = this.options.database.listRoots();
|
|
3065
|
+
return principal.rootIds === "all" ? roots : roots.filter((root) => principal.rootIds.has(root.id));
|
|
3066
|
+
}
|
|
3067
|
+
audit(principal, action, rootId, relativePath, requestId, details = {}) {
|
|
3068
|
+
this.options.database.audit(principal.kind, principal.id, action, rootId, relativePath, details, requestId);
|
|
3069
|
+
}
|
|
3070
|
+
readIdempotent(principal, key) {
|
|
3071
|
+
if (principal.kind !== "device" || typeof key !== "string" || key === "") return void 0;
|
|
3072
|
+
return this.options.database.readIdempotent(principal.id, key);
|
|
3073
|
+
}
|
|
3074
|
+
saveIdempotent(principal, key, status, body) {
|
|
3075
|
+
if (principal.kind === "device" && typeof key === "string" && key !== "") this.options.database.saveIdempotent(principal.id, key, status, body);
|
|
3076
|
+
}
|
|
3077
|
+
};
|
|
3078
|
+
async function readJson(req, maxBytes) {
|
|
3079
|
+
const bytes = await readBytes(req, maxBytes);
|
|
3080
|
+
if (bytes.length === 0) return {};
|
|
3081
|
+
try {
|
|
3082
|
+
return JSON.parse(Buffer.from(bytes).toString("utf8"));
|
|
3083
|
+
} catch {
|
|
3084
|
+
throw new ApiError(400, "JSON_INVALID", "The request body is not valid JSON.");
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
async function readBytes(req, maxBytes) {
|
|
3088
|
+
const declared = Number(req.headers["content-length"] ?? 0);
|
|
3089
|
+
if (Number.isFinite(declared) && declared > maxBytes) throw new ApiError(413, "BODY_TOO_LARGE", "The request body is too large.");
|
|
3090
|
+
const chunks = [];
|
|
3091
|
+
let total = 0;
|
|
3092
|
+
for await (const chunk of req) {
|
|
3093
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
3094
|
+
total += buffer.length;
|
|
3095
|
+
if (total > maxBytes) throw new ApiError(413, "BODY_TOO_LARGE", "The request body is too large.");
|
|
3096
|
+
chunks.push(buffer);
|
|
3097
|
+
}
|
|
3098
|
+
return Buffer.concat(chunks);
|
|
3099
|
+
}
|
|
3100
|
+
function sendJson(res, status, body) {
|
|
3101
|
+
const encoded = Buffer.from(JSON.stringify(body));
|
|
3102
|
+
res.writeHead(status, {
|
|
3103
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
3104
|
+
"Content-Length": encoded.length
|
|
3105
|
+
});
|
|
3106
|
+
res.end(encoded);
|
|
3107
|
+
}
|
|
3108
|
+
function setCors(req, res) {
|
|
3109
|
+
const origin = req.headers.origin;
|
|
3110
|
+
if (origin !== void 0) {
|
|
3111
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
3112
|
+
res.setHeader("Vary", "Origin");
|
|
3113
|
+
}
|
|
3114
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, If-None-Match, X-Request-Id, X-Dsh-Workspace-Admin");
|
|
3115
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
3116
|
+
res.setHeader("Access-Control-Expose-Headers", "ETag, Content-Range, X-Request-Id");
|
|
3117
|
+
res.setHeader("Cache-Control", "no-store");
|
|
3118
|
+
}
|
|
3119
|
+
function validateScopes(value) {
|
|
3120
|
+
const values = validateStringArray(value, "scopes");
|
|
3121
|
+
if (values.some((item) => !DEVICE_SCOPES.includes(item))) throw new ApiError(400, "SCOPE_INVALID", "The request contains an unknown device scope.");
|
|
3122
|
+
return [...new Set(values)];
|
|
3123
|
+
}
|
|
3124
|
+
/**
|
|
3125
|
+
* 把请求体解析成 prompt 内容片段。
|
|
3126
|
+
*
|
|
3127
|
+
* **向后兼容是硬要求**:旧客户端只发 `{"text":"..."}`,新客户端发 `{"content":[...]}`。
|
|
3128
|
+
* 两种都必须能用 —— 否则一次服务端升级就会让所有还在用旧版的人发不出消息。
|
|
3129
|
+
*
|
|
3130
|
+
* 这里逐项校验而不是直接透传:`content` 里的 `type` 是判别器,透传非法结构给内核
|
|
3131
|
+
* 只会换来一个更难懂的报错。
|
|
3132
|
+
*/
|
|
3133
|
+
function readPromptParts(body) {
|
|
3134
|
+
if (body.content === void 0) {
|
|
3135
|
+
if (typeof body.text !== "string") throw new ApiError(400, "BODY_INVALID", "text or content is required.");
|
|
3136
|
+
return [{
|
|
3137
|
+
type: "text",
|
|
3138
|
+
text: body.text
|
|
3139
|
+
}];
|
|
3140
|
+
}
|
|
3141
|
+
if (!Array.isArray(body.content) || body.content.length === 0) throw new ApiError(400, "BODY_INVALID", "content must be a non-empty array.");
|
|
3142
|
+
return body.content.map((raw, index) => {
|
|
3143
|
+
if (typeof raw !== "object" || raw === null) throw new ApiError(400, "BODY_INVALID", `content[${index}] must be an object.`);
|
|
3144
|
+
const part = raw;
|
|
3145
|
+
if (part.type === "text") {
|
|
3146
|
+
if (typeof part.text !== "string") throw new ApiError(400, "BODY_INVALID", `content[${index}].text is required.`);
|
|
3147
|
+
return {
|
|
3148
|
+
type: "text",
|
|
3149
|
+
text: part.text
|
|
3150
|
+
};
|
|
3151
|
+
}
|
|
3152
|
+
if (part.type === "image") {
|
|
3153
|
+
if (typeof part.mediaType !== "string" || typeof part.data !== "string") throw new ApiError(400, "BODY_INVALID", `content[${index}] needs mediaType and data.`);
|
|
3154
|
+
return {
|
|
3155
|
+
type: "image",
|
|
3156
|
+
mediaType: part.mediaType,
|
|
3157
|
+
data: part.data,
|
|
3158
|
+
...typeof part.name === "string" && part.name !== "" ? { name: part.name } : {}
|
|
3159
|
+
};
|
|
3160
|
+
}
|
|
3161
|
+
if (part.type === "file") {
|
|
3162
|
+
if (typeof part.receiptId !== "string" || part.receiptId === "") throw new ApiError(400, "BODY_INVALID", `content[${index}].receiptId is required.`);
|
|
3163
|
+
return {
|
|
3164
|
+
type: "file",
|
|
3165
|
+
receiptId: part.receiptId
|
|
3166
|
+
};
|
|
3167
|
+
}
|
|
3168
|
+
throw new ApiError(400, "BODY_INVALID", `content[${index}].type is not supported.`);
|
|
3169
|
+
});
|
|
3170
|
+
}
|
|
3171
|
+
function validateStringArray(value, name) {
|
|
3172
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new ApiError(400, "BODY_INVALID", `${name} must be an array of strings.`);
|
|
3173
|
+
return [...new Set(value)];
|
|
3174
|
+
}
|
|
3175
|
+
/**
|
|
3176
|
+
* 生成配置文本时的默认设备权限。
|
|
3177
|
+
*
|
|
3178
|
+
* 刻意**不含** `files.delete` 与 `settings.write`:这两个是破坏性权限,应当是用户显式
|
|
3179
|
+
* 要求才授予,而不是因为「生成了一段配置文本」就顺手给了。需要时可在请求里显式传 scopes。
|
|
3180
|
+
*/
|
|
3181
|
+
const DEFAULT_CONFIG_SCOPES = [
|
|
3182
|
+
"chat.read",
|
|
3183
|
+
"chat.write",
|
|
3184
|
+
"files.read",
|
|
3185
|
+
"files.write",
|
|
3186
|
+
"settings.read"
|
|
3187
|
+
];
|
|
3188
|
+
function readOptionalText(value) {
|
|
3189
|
+
if (typeof value !== "string") return null;
|
|
3190
|
+
const text = value.trim();
|
|
3191
|
+
return text === "" ? null : text;
|
|
3192
|
+
}
|
|
3193
|
+
/**
|
|
3194
|
+
* 读取请求里显式给出的地址列表;没给或格式不对时返回 null(调用方改用自动探测)。
|
|
3195
|
+
*
|
|
3196
|
+
* 这里对空数组也返回 null —— 「用户明确说一个地址都不要」不是有意义的输入。
|
|
3197
|
+
*/
|
|
3198
|
+
function readRequestedEndpoints(value) {
|
|
3199
|
+
if (!Array.isArray(value) || value.length === 0) return null;
|
|
3200
|
+
const endpoints = [];
|
|
3201
|
+
for (const item of value) {
|
|
3202
|
+
if (typeof item !== "object" || item === null) throw new ApiError(400, "BODY_INVALID", "endpoints must be an array of objects.");
|
|
3203
|
+
const baseUrl = readOptionalText(item.baseUrl);
|
|
3204
|
+
if (baseUrl === null) throw new ApiError(400, "BODY_INVALID", "Each endpoint requires a non-empty baseUrl.");
|
|
3205
|
+
const label = readOptionalText(item.label) ?? baseUrl;
|
|
3206
|
+
const host = baseUrl.replace(/^https?:\/\//i, "").split("/")[0]?.split(":")[0] ?? "";
|
|
3207
|
+
endpoints.push({
|
|
3208
|
+
label,
|
|
3209
|
+
baseUrl,
|
|
3210
|
+
kind: classifyHost(host)
|
|
3211
|
+
});
|
|
3212
|
+
}
|
|
3213
|
+
return endpoints;
|
|
3214
|
+
}
|
|
3215
|
+
function optionalInteger(value) {
|
|
3216
|
+
if (value === null || value === "") return void 0;
|
|
3217
|
+
const parsed = Number(value);
|
|
3218
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) throw new ApiError(400, "NUMBER_INVALID", "A query parameter must be a non-negative integer.");
|
|
3219
|
+
return parsed;
|
|
3220
|
+
}
|
|
3221
|
+
function commandName(line) {
|
|
3222
|
+
return /^\/([a-z][a-z0-9_-]*)/u.exec(line)?.[1] ?? "unknown";
|
|
3223
|
+
}
|
|
3224
|
+
function parseRange(value, size) {
|
|
3225
|
+
if (value === void 0) return void 0;
|
|
3226
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(value);
|
|
3227
|
+
if (match === null) throw new ApiError(416, "RANGE_INVALID", "Only a single byte range is supported.");
|
|
3228
|
+
let start;
|
|
3229
|
+
let end;
|
|
3230
|
+
if (match[1] === "") {
|
|
3231
|
+
const suffix = Number(match[2]);
|
|
3232
|
+
if (!Number.isSafeInteger(suffix) || suffix <= 0) throw new ApiError(416, "RANGE_INVALID", "The byte range is invalid.");
|
|
3233
|
+
start = Math.max(0, size - suffix);
|
|
3234
|
+
end = size - 1;
|
|
3235
|
+
} else {
|
|
3236
|
+
start = Number(match[1]);
|
|
3237
|
+
end = match[2] === "" ? size - 1 : Number(match[2]);
|
|
3238
|
+
}
|
|
3239
|
+
if (start < 0 || end < start || start >= size) throw new ApiError(416, "RANGE_INVALID", "The byte range is outside the file.");
|
|
3240
|
+
return {
|
|
3241
|
+
start,
|
|
3242
|
+
end: Math.min(end, size - 1)
|
|
3243
|
+
};
|
|
3244
|
+
}
|
|
3245
|
+
function header(req, name) {
|
|
3246
|
+
const value = req.headers[name];
|
|
3247
|
+
return Array.isArray(value) ? value[0] : value;
|
|
3248
|
+
}
|
|
3249
|
+
//#endregion
|
|
3250
|
+
//#region src/host/server.ts
|
|
3251
|
+
var RemoteApiServer = class {
|
|
3252
|
+
options;
|
|
3253
|
+
server;
|
|
3254
|
+
host = "127.0.0.1";
|
|
3255
|
+
actualPort;
|
|
3256
|
+
configuredHost;
|
|
3257
|
+
configuredPort;
|
|
3258
|
+
remoteEnabled = false;
|
|
3259
|
+
operations = /* @__PURE__ */ new Map();
|
|
3260
|
+
sockets = /* @__PURE__ */ new Map();
|
|
3261
|
+
webSockets = new WebSocketServer({
|
|
3262
|
+
noServer: true,
|
|
3263
|
+
clientTracking: false
|
|
3264
|
+
});
|
|
3265
|
+
eventsAbort = new AbortController();
|
|
3266
|
+
unsubscribeEvents;
|
|
3267
|
+
broadcastQueue = Promise.resolve();
|
|
3268
|
+
changing = false;
|
|
3269
|
+
constructor(options) {
|
|
3270
|
+
this.options = options;
|
|
3271
|
+
const fallbackHost = normalizeListenerHost(options.remoteHost ?? "0.0.0.0");
|
|
3272
|
+
const fallbackPort = normalizeListenerPort(options.port, true);
|
|
3273
|
+
this.configuredHost = readStoredHost(options.database, fallbackHost);
|
|
3274
|
+
this.configuredPort = readStoredPort(options.database, fallbackPort);
|
|
3275
|
+
this.actualPort = this.configuredPort;
|
|
3276
|
+
}
|
|
3277
|
+
async start() {
|
|
3278
|
+
this.remoteEnabled = this.options.database.getSetting("remote_enabled") === "true";
|
|
3279
|
+
const requestedHost = this.remoteEnabled ? this.configuredHost : loopbackFor(this.configuredHost);
|
|
3280
|
+
try {
|
|
3281
|
+
await this.bind(requestedHost, this.configuredPort);
|
|
3282
|
+
} catch (error) {
|
|
3283
|
+
if (!this.remoteEnabled) throw error;
|
|
3284
|
+
this.remoteEnabled = false;
|
|
3285
|
+
this.options.database.setSetting("remote_enabled", "false");
|
|
3286
|
+
await this.bind(loopbackFor(this.configuredHost), this.configuredPort);
|
|
3287
|
+
}
|
|
3288
|
+
this.unsubscribeEvents = this.options.events.subscribe((event) => {
|
|
3289
|
+
this.broadcastQueue = this.broadcastQueue.then(() => this.broadcast(event)).catch(() => void 0);
|
|
3290
|
+
});
|
|
3291
|
+
this.options.chat.startEvents(this.eventsAbort.signal, (event) => {
|
|
3292
|
+
this.options.events.emit(event);
|
|
3293
|
+
});
|
|
3294
|
+
}
|
|
3295
|
+
async stop() {
|
|
3296
|
+
this.eventsAbort.abort();
|
|
3297
|
+
this.unsubscribeEvents?.();
|
|
3298
|
+
this.unsubscribeEvents = void 0;
|
|
3299
|
+
await this.broadcastQueue;
|
|
3300
|
+
await this.closeCurrent();
|
|
3301
|
+
this.webSockets.close();
|
|
3302
|
+
}
|
|
3303
|
+
status(operationId) {
|
|
3304
|
+
const operation = operationId === void 0 ? void 0 : this.operations.get(operationId);
|
|
3305
|
+
return {
|
|
3306
|
+
host: this.host,
|
|
3307
|
+
port: this.actualPort,
|
|
3308
|
+
configuredHost: this.configuredHost,
|
|
3309
|
+
configuredPort: this.configuredPort,
|
|
3310
|
+
remoteEnabled: this.remoteEnabled,
|
|
3311
|
+
...operation === void 0 ? {} : { operation: publicOperation(operation) }
|
|
3312
|
+
};
|
|
3313
|
+
}
|
|
3314
|
+
enable(rootIds, scopes) {
|
|
3315
|
+
return this.scheduleChange({
|
|
3316
|
+
kind: "enable",
|
|
3317
|
+
targetHost: this.configuredHost,
|
|
3318
|
+
targetPort: this.configuredPort,
|
|
3319
|
+
nextRemoteEnabled: true,
|
|
3320
|
+
rootIds,
|
|
3321
|
+
scopes
|
|
3322
|
+
});
|
|
3323
|
+
}
|
|
3324
|
+
disable() {
|
|
3325
|
+
return this.scheduleChange({
|
|
3326
|
+
kind: "disable",
|
|
3327
|
+
targetHost: loopbackFor(this.configuredHost),
|
|
3328
|
+
targetPort: this.configuredPort,
|
|
3329
|
+
nextRemoteEnabled: false
|
|
3330
|
+
});
|
|
3331
|
+
}
|
|
3332
|
+
configure(host, port) {
|
|
3333
|
+
if (this.remoteEnabled) throw new ApiError(409, "REMOTE_ENABLED", "Disable remote access before changing the listener address.");
|
|
3334
|
+
const configuredHost = normalizeListenerHost(host);
|
|
3335
|
+
const configuredPort = normalizeListenerPort(port);
|
|
3336
|
+
return this.scheduleChange({
|
|
3337
|
+
kind: "configure",
|
|
3338
|
+
targetHost: loopbackFor(configuredHost),
|
|
3339
|
+
targetPort: configuredPort,
|
|
3340
|
+
nextRemoteEnabled: false,
|
|
3341
|
+
configuredHost,
|
|
3342
|
+
configuredPort
|
|
3343
|
+
});
|
|
3344
|
+
}
|
|
3345
|
+
scheduleChange(input) {
|
|
3346
|
+
if (this.changing) throw new ApiError(409, "LISTENER_CHANGE_PENDING", "A listener change is already in progress.");
|
|
3347
|
+
const operation = {
|
|
3348
|
+
id: randomUUID(),
|
|
3349
|
+
state: "pending",
|
|
3350
|
+
...input
|
|
3351
|
+
};
|
|
3352
|
+
this.operations.set(operation.id, operation);
|
|
3353
|
+
this.changing = true;
|
|
3354
|
+
setTimeout(() => {
|
|
3355
|
+
this.performChange(operation);
|
|
3356
|
+
}, 75);
|
|
3357
|
+
return publicOperation(operation);
|
|
3358
|
+
}
|
|
3359
|
+
async performChange(operation) {
|
|
3360
|
+
const previousHost = this.host;
|
|
3361
|
+
const previousPort = this.actualPort;
|
|
3362
|
+
try {
|
|
3363
|
+
if (previousHost !== operation.targetHost || previousPort !== operation.targetPort) await this.rebind(operation.targetHost, operation.targetPort, previousHost, previousPort);
|
|
3364
|
+
if (operation.configuredHost !== void 0 && operation.configuredPort !== void 0) {
|
|
3365
|
+
this.configuredHost = operation.configuredHost;
|
|
3366
|
+
this.configuredPort = operation.configuredPort;
|
|
3367
|
+
this.options.database.setSetting("listener_host", this.configuredHost);
|
|
3368
|
+
this.options.database.setSetting("listener_port", String(this.configuredPort));
|
|
3369
|
+
}
|
|
3370
|
+
this.remoteEnabled = operation.nextRemoteEnabled;
|
|
3371
|
+
this.options.database.setSetting("remote_enabled", String(this.remoteEnabled));
|
|
3372
|
+
if (this.remoteEnabled) {
|
|
3373
|
+
const pairing = this.options.database.createPairing(operation.rootIds ?? [], operation.scopes ?? []);
|
|
3374
|
+
operation.pairingCode = pairing.code;
|
|
3375
|
+
operation.pairingExpiresAt = pairing.expiresAt;
|
|
3376
|
+
}
|
|
3377
|
+
operation.state = "succeeded";
|
|
3378
|
+
this.options.database.audit("admin", "loopback-admin", `listener.${operation.kind}`, void 0, void 0, {
|
|
3379
|
+
host: operation.kind === "configure" ? operation.configuredHost : operation.targetHost,
|
|
3380
|
+
port: operation.targetPort
|
|
3381
|
+
});
|
|
3382
|
+
} catch (error) {
|
|
3383
|
+
operation.state = "failed";
|
|
3384
|
+
operation.code = listenerErrorCode(error);
|
|
3385
|
+
operation.message = error instanceof Error ? error.message : String(error);
|
|
3386
|
+
} finally {
|
|
3387
|
+
this.changing = false;
|
|
3388
|
+
this.trimOperations();
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
async rebind(targetHost, targetPort, previousHost, previousPort) {
|
|
3392
|
+
if (targetPort !== previousPort) {
|
|
3393
|
+
const replacement = await this.createBoundServer(targetHost, targetPort);
|
|
3394
|
+
await this.closeCurrent();
|
|
3395
|
+
this.server = replacement.server;
|
|
3396
|
+
this.host = targetHost;
|
|
3397
|
+
this.actualPort = replacement.port;
|
|
3398
|
+
return;
|
|
3399
|
+
}
|
|
3400
|
+
await this.closeCurrent();
|
|
3401
|
+
try {
|
|
3402
|
+
await this.bind(targetHost, targetPort);
|
|
3403
|
+
} catch (error) {
|
|
3404
|
+
await this.bind(previousHost, previousPort);
|
|
3405
|
+
throw error;
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
async bind(host, port) {
|
|
3409
|
+
const bound = await this.createBoundServer(host, port);
|
|
3410
|
+
this.server = bound.server;
|
|
3411
|
+
this.host = host;
|
|
3412
|
+
this.actualPort = bound.port;
|
|
3413
|
+
}
|
|
3414
|
+
async createBoundServer(host, port) {
|
|
3415
|
+
const server = createServer((req, res) => {
|
|
3416
|
+
this.options.handle(req, res).catch((error) => failResponse(res, error));
|
|
3417
|
+
});
|
|
3418
|
+
server.requestTimeout = 3e4;
|
|
3419
|
+
server.headersTimeout = 1e4;
|
|
3420
|
+
server.keepAliveTimeout = 5e3;
|
|
3421
|
+
server.maxHeadersCount = 100;
|
|
3422
|
+
server.on("upgrade", (req, socket, head) => {
|
|
3423
|
+
this.handleUpgrade(req, socket, head);
|
|
3424
|
+
});
|
|
3425
|
+
await new Promise((resolve, reject) => {
|
|
3426
|
+
const onError = (error) => reject(error);
|
|
3427
|
+
server.once("error", onError);
|
|
3428
|
+
server.listen(port, host, () => {
|
|
3429
|
+
server.off("error", onError);
|
|
3430
|
+
resolve();
|
|
3431
|
+
});
|
|
3432
|
+
});
|
|
3433
|
+
return {
|
|
3434
|
+
server,
|
|
3435
|
+
port: server.address().port
|
|
3436
|
+
};
|
|
3437
|
+
}
|
|
3438
|
+
async closeCurrent() {
|
|
3439
|
+
const server = this.server;
|
|
3440
|
+
if (server === void 0) return;
|
|
3441
|
+
this.server = void 0;
|
|
3442
|
+
for (const socket of this.sockets.keys()) socket.close(1012, "Listener rebinding");
|
|
3443
|
+
this.sockets.clear();
|
|
3444
|
+
await new Promise((resolve) => {
|
|
3445
|
+
server.close(() => resolve());
|
|
3446
|
+
server.closeAllConnections();
|
|
3447
|
+
});
|
|
3448
|
+
}
|
|
3449
|
+
handleUpgrade(req, socket, head) {
|
|
3450
|
+
try {
|
|
3451
|
+
if (new URL(req.url ?? "/", "http://workspace.local").pathname !== "/api/v1/events") {
|
|
3452
|
+
socket.destroy();
|
|
3453
|
+
return;
|
|
3454
|
+
}
|
|
3455
|
+
const protocolToken = parseProtocolToken(req.headers["sec-websocket-protocol"]);
|
|
3456
|
+
if (protocolToken !== void 0) req.headers.authorization = `Bearer ${protocolToken}`;
|
|
3457
|
+
const principal = this.options.auth.principal(req, { allowAdmin: true });
|
|
3458
|
+
this.webSockets.handleUpgrade(req, socket, head, (webSocket) => {
|
|
3459
|
+
this.sockets.set(webSocket, principal);
|
|
3460
|
+
webSocket.on("close", () => {
|
|
3461
|
+
this.sockets.delete(webSocket);
|
|
3462
|
+
});
|
|
3463
|
+
webSocket.on("error", () => {
|
|
3464
|
+
this.sockets.delete(webSocket);
|
|
3465
|
+
});
|
|
3466
|
+
webSocket.send(JSON.stringify({
|
|
3467
|
+
id: randomUUID(),
|
|
3468
|
+
type: "connection.ready",
|
|
3469
|
+
time: Date.now(),
|
|
3470
|
+
data: {}
|
|
3471
|
+
}));
|
|
3472
|
+
});
|
|
3473
|
+
} catch {
|
|
3474
|
+
socket.destroy();
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
async broadcast(event) {
|
|
3478
|
+
for (const [socket, principal] of this.sockets) {
|
|
3479
|
+
if (socket.readyState !== WebSocket.OPEN) continue;
|
|
3480
|
+
let currentPrincipal = principal;
|
|
3481
|
+
if (principal.kind === "device") {
|
|
3482
|
+
const device = this.options.database.getActiveDevice(principal.id);
|
|
3483
|
+
if (device === void 0) {
|
|
3484
|
+
socket.close(4001, "Device revoked");
|
|
3485
|
+
continue;
|
|
3486
|
+
}
|
|
3487
|
+
currentPrincipal = {
|
|
3488
|
+
kind: "device",
|
|
3489
|
+
id: device.id,
|
|
3490
|
+
name: device.name,
|
|
3491
|
+
scopes: new Set(device.scopes),
|
|
3492
|
+
rootIds: new Set(device.rootIds)
|
|
3493
|
+
};
|
|
3494
|
+
this.sockets.set(socket, currentPrincipal);
|
|
3495
|
+
}
|
|
3496
|
+
if (!await this.eventAllowed(currentPrincipal, event)) continue;
|
|
3497
|
+
socket.send(JSON.stringify(event));
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
async eventAllowed(principal, event) {
|
|
3501
|
+
if (event.type.startsWith("file.") && !principal.scopes.has("files.read")) return false;
|
|
3502
|
+
if (event.type.startsWith("chat.") && !principal.scopes.has("chat.read")) return false;
|
|
3503
|
+
const rootId = typeof event.data.rootId === "string" ? event.data.rootId : void 0;
|
|
3504
|
+
if (rootId !== void 0 && principal.rootIds !== "all" && !principal.rootIds.has(rootId)) return false;
|
|
3505
|
+
const sessionId = typeof event.data.sessionId === "string" ? event.data.sessionId : void 0;
|
|
3506
|
+
if (sessionId !== void 0) return this.options.chat.isSessionAuthorized(principal, sessionId);
|
|
3507
|
+
return rootId !== void 0 || principal.kind === "admin";
|
|
3508
|
+
}
|
|
3509
|
+
trimOperations() {
|
|
3510
|
+
while (this.operations.size > 20) {
|
|
3511
|
+
const first = this.operations.keys().next().value;
|
|
3512
|
+
if (first === void 0) break;
|
|
3513
|
+
this.operations.delete(first);
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
};
|
|
3517
|
+
function attachEmbeddedRoutes(webServer, router, remote) {
|
|
3518
|
+
const disposers = [webServer.register({
|
|
3519
|
+
kind: "prefix",
|
|
3520
|
+
path: "/dsh-workspace-api",
|
|
3521
|
+
handler: (req, res) => router.handle(req, res, "/dsh-workspace-api")
|
|
3522
|
+
})];
|
|
3523
|
+
return () => {
|
|
3524
|
+
for (const dispose of disposers.reverse()) dispose();
|
|
3525
|
+
};
|
|
3526
|
+
}
|
|
3527
|
+
function publicOperation(operation) {
|
|
3528
|
+
return {
|
|
3529
|
+
id: operation.id,
|
|
3530
|
+
state: operation.state,
|
|
3531
|
+
targetHost: operation.targetHost,
|
|
3532
|
+
targetPort: operation.targetPort,
|
|
3533
|
+
...operation.code === void 0 ? {} : { code: operation.code },
|
|
3534
|
+
...operation.message === void 0 ? {} : { message: operation.message },
|
|
3535
|
+
...operation.pairingCode === void 0 ? {} : { pairingCode: operation.pairingCode },
|
|
3536
|
+
...operation.pairingExpiresAt === void 0 ? {} : { pairingExpiresAt: operation.pairingExpiresAt }
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3539
|
+
function listenerErrorCode(error) {
|
|
3540
|
+
const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
|
|
3541
|
+
if (code === "EADDRINUSE") return "LISTENER_ADDRESS_IN_USE";
|
|
3542
|
+
if (code === "EADDRNOTAVAIL" || code === "ENODEV") return "LISTENER_ADDRESS_UNAVAILABLE";
|
|
3543
|
+
if (code === "EACCES") return "LISTENER_PERMISSION_DENIED";
|
|
3544
|
+
return "LISTENER_CHANGE_FAILED";
|
|
3545
|
+
}
|
|
3546
|
+
function readStoredHost(database, fallback) {
|
|
3547
|
+
const stored = database.getSetting("listener_host");
|
|
3548
|
+
if (stored === void 0) return fallback;
|
|
3549
|
+
try {
|
|
3550
|
+
return normalizeListenerHost(stored);
|
|
3551
|
+
} catch {
|
|
3552
|
+
database.setSetting("listener_host", fallback);
|
|
3553
|
+
return fallback;
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
function readStoredPort(database, fallback) {
|
|
3557
|
+
const stored = database.getSetting("listener_port");
|
|
3558
|
+
if (stored === void 0) return fallback;
|
|
3559
|
+
try {
|
|
3560
|
+
return normalizeListenerPort(Number(stored), fallback === 0);
|
|
3561
|
+
} catch {
|
|
3562
|
+
database.setSetting("listener_port", String(fallback));
|
|
3563
|
+
return fallback;
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
function parseProtocolToken(value) {
|
|
3567
|
+
const joined = Array.isArray(value) ? value.join(",") : value;
|
|
3568
|
+
if (joined === void 0) return void 0;
|
|
3569
|
+
return joined.split(",").map((item) => item.trim()).find((item) => item.startsWith("bearer."))?.slice(7);
|
|
3570
|
+
}
|
|
3571
|
+
function failResponse(res, error) {
|
|
3572
|
+
if (res.headersSent) {
|
|
3573
|
+
res.destroy(error instanceof Error ? error : void 0);
|
|
3574
|
+
return;
|
|
3575
|
+
}
|
|
3576
|
+
const body = Buffer.from(JSON.stringify({ error: {
|
|
3577
|
+
code: "INTERNAL_ERROR",
|
|
3578
|
+
message: "An internal server error occurred.",
|
|
3579
|
+
requestId: randomUUID()
|
|
3580
|
+
} }));
|
|
3581
|
+
res.writeHead(500, {
|
|
3582
|
+
"Content-Type": "application/json",
|
|
3583
|
+
"Content-Length": body.length
|
|
3584
|
+
});
|
|
3585
|
+
res.end(body);
|
|
3586
|
+
}
|
|
3587
|
+
//#endregion
|
|
3588
|
+
//#region src/host/settings-adapter.ts
|
|
3589
|
+
var DshSettingsAdapter = class {
|
|
3590
|
+
api;
|
|
3591
|
+
constructor(api) {
|
|
3592
|
+
this.api = api;
|
|
3593
|
+
}
|
|
3594
|
+
async listProviders() {
|
|
3595
|
+
const [directoryResponse, settingsResponse, catalogResponse] = await Promise.all([
|
|
3596
|
+
this.api.llm.providers({
|
|
3597
|
+
rpcId: randomUUID(),
|
|
3598
|
+
payload: {}
|
|
3599
|
+
}),
|
|
3600
|
+
this.api.settings.describe({
|
|
3601
|
+
rpcId: randomUUID(),
|
|
3602
|
+
payload: {}
|
|
3603
|
+
}),
|
|
3604
|
+
this.api.llm.models({
|
|
3605
|
+
rpcId: randomUUID(),
|
|
3606
|
+
payload: {}
|
|
3607
|
+
})
|
|
3608
|
+
]);
|
|
3609
|
+
const directory = unwrapDsh(directoryResponse);
|
|
3610
|
+
const settings = unwrapDsh(settingsResponse);
|
|
3611
|
+
const catalog = unwrapDsh(catalogResponse);
|
|
3612
|
+
const namespaces = new Map(settings.namespaces.map((item) => [item.ns, item]));
|
|
3613
|
+
const customNamespace = namespaces.get("llm-pi-ai");
|
|
3614
|
+
const protocols = protocolChoices(customNamespace);
|
|
3615
|
+
const rows = directory.providers.map((entry) => {
|
|
3616
|
+
const namespace = namespaces.get(entry.settingsNs);
|
|
3617
|
+
const profile = mergeRecords(recordAt(namespace?.base, entry.settingsPath), recordAt(namespace?.value, entry.settingsPath));
|
|
3618
|
+
const userProfile = recordAt(namespace?.user, entry.settingsPath);
|
|
3619
|
+
const ref = text(profile?.apiKeyEnv) ?? keyRef(entry.provider);
|
|
3620
|
+
return {
|
|
3621
|
+
entry,
|
|
3622
|
+
namespace,
|
|
3623
|
+
profile,
|
|
3624
|
+
configured: profile !== void 0,
|
|
3625
|
+
removable: userProfile !== void 0,
|
|
3626
|
+
ref
|
|
3627
|
+
};
|
|
3628
|
+
});
|
|
3629
|
+
const refs = [...new Set(rows.map((row) => row.ref))];
|
|
3630
|
+
const described = refs.length === 0 ? { credentials: {} } : unwrapDsh(await this.api.credentials.describe({
|
|
3631
|
+
rpcId: randomUUID(),
|
|
3632
|
+
payload: { refs }
|
|
3633
|
+
}));
|
|
3634
|
+
return {
|
|
3635
|
+
writable: settings.writable,
|
|
3636
|
+
revisionByNamespace: Object.fromEntries(settings.namespaces.map((item) => [item.ns, item.revision])),
|
|
3637
|
+
customProvider: {
|
|
3638
|
+
available: customNamespace !== void 0 && protocols.length > 0,
|
|
3639
|
+
protocols,
|
|
3640
|
+
...customNamespace === void 0 ? {} : { revision: customNamespace.revision }
|
|
3641
|
+
},
|
|
3642
|
+
providers: rows.map(({ entry, profile, configured, removable, ref }) => {
|
|
3643
|
+
const credential = described.credentials[ref] ?? {
|
|
3644
|
+
configured: false,
|
|
3645
|
+
writable: false
|
|
3646
|
+
};
|
|
3647
|
+
const inheritedModels = catalog.groups.find((group) => group.id === entry.provider)?.models;
|
|
3648
|
+
return {
|
|
3649
|
+
id: entry.provider,
|
|
3650
|
+
displayName: text(profile?.displayName) ?? entry.displayName,
|
|
3651
|
+
active: entry.active,
|
|
3652
|
+
...entry.declared === void 0 ? {} : { declared: entry.declared },
|
|
3653
|
+
configurable: entry.settingsNs !== "",
|
|
3654
|
+
configured,
|
|
3655
|
+
removable,
|
|
3656
|
+
credential: {
|
|
3657
|
+
ref,
|
|
3658
|
+
...credential
|
|
3659
|
+
},
|
|
3660
|
+
config: projectConfig(profile, inheritedModels)
|
|
3661
|
+
};
|
|
3662
|
+
})
|
|
3663
|
+
};
|
|
3664
|
+
}
|
|
3665
|
+
async catalog() {
|
|
3666
|
+
return unwrapDsh(await this.api.llm.models({
|
|
3667
|
+
rpcId: randomUUID(),
|
|
3668
|
+
payload: {}
|
|
3669
|
+
}));
|
|
3670
|
+
}
|
|
3671
|
+
async updateProvider(providerId, patch) {
|
|
3672
|
+
const context = await this.providerContext(providerId);
|
|
3673
|
+
if (!context.writable) throw new ApiError(409, "SETTINGS_READ_ONLY", "DSH settings are read-only.");
|
|
3674
|
+
if (context.entry.settingsNs === "") throw new ApiError(409, "PROVIDER_NOT_CONFIGURABLE", "This provider has no writable settings section.");
|
|
3675
|
+
const ops = [];
|
|
3676
|
+
for (const field of [
|
|
3677
|
+
"displayName",
|
|
3678
|
+
"baseURL",
|
|
3679
|
+
"api",
|
|
3680
|
+
"thinking",
|
|
3681
|
+
"reasoningEffort"
|
|
3682
|
+
]) {
|
|
3683
|
+
if (!(field in patch)) continue;
|
|
3684
|
+
const value = patch[field];
|
|
3685
|
+
ops.push(value === null || value === "" ? {
|
|
3686
|
+
op: "unset",
|
|
3687
|
+
path: [...context.entry.settingsPath, field]
|
|
3688
|
+
} : {
|
|
3689
|
+
op: "set",
|
|
3690
|
+
path: [...context.entry.settingsPath, field],
|
|
3691
|
+
value
|
|
3692
|
+
});
|
|
3693
|
+
}
|
|
3694
|
+
if (patch.models !== void 0) {
|
|
3695
|
+
validateModels(patch.models);
|
|
3696
|
+
ops.push({
|
|
3697
|
+
op: "set",
|
|
3698
|
+
path: [...context.entry.settingsPath, "models"],
|
|
3699
|
+
value: patch.models
|
|
3700
|
+
});
|
|
3701
|
+
}
|
|
3702
|
+
const ref = text(context.profile?.apiKeyEnv) ?? keyRef(providerId);
|
|
3703
|
+
if (context.entry.settingsPath.length > 0 && context.profile === void 0) ops.unshift({
|
|
3704
|
+
op: "set",
|
|
3705
|
+
path: [...context.entry.settingsPath, "apiKeyEnv"],
|
|
3706
|
+
value: ref
|
|
3707
|
+
});
|
|
3708
|
+
if (ops.length > 0) unwrapDsh(await this.api.settings.mutate({
|
|
3709
|
+
rpcId: randomUUID(),
|
|
3710
|
+
payload: {
|
|
3711
|
+
ns: context.entry.settingsNs,
|
|
3712
|
+
ops,
|
|
3713
|
+
...patch.expectedRevision === void 0 ? {} : { expectedRevision: patch.expectedRevision }
|
|
3714
|
+
}
|
|
3715
|
+
}));
|
|
3716
|
+
if (patch.apiKey !== void 0) {
|
|
3717
|
+
if (patch.apiKey === null || patch.apiKey === "") unwrapDsh(await this.api.credentials.unset({
|
|
3718
|
+
rpcId: randomUUID(),
|
|
3719
|
+
payload: { ref }
|
|
3720
|
+
}));
|
|
3721
|
+
else unwrapDsh(await this.api.credentials.set({
|
|
3722
|
+
rpcId: randomUUID(),
|
|
3723
|
+
payload: {
|
|
3724
|
+
ref,
|
|
3725
|
+
value: patch.apiKey
|
|
3726
|
+
}
|
|
3727
|
+
}));
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
async createProvider(input) {
|
|
3731
|
+
const id = input.id.trim();
|
|
3732
|
+
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(id)) throw new ApiError(400, "PROVIDER_ID_INVALID", "Provider ids must use lowercase letters, digits, and single hyphens, starting with a letter.");
|
|
3733
|
+
if (input.baseURL.trim() === "") throw new ApiError(400, "PROVIDER_BASE_URL_REQUIRED", "baseURL is required.");
|
|
3734
|
+
if (input.models.length === 0) throw new ApiError(400, "PROVIDER_MODELS_REQUIRED", "At least one model is required.");
|
|
3735
|
+
validateModels(input.models);
|
|
3736
|
+
const [directoryResponse, settingsResponse] = await Promise.all([this.api.llm.providers({
|
|
3737
|
+
rpcId: randomUUID(),
|
|
3738
|
+
payload: {}
|
|
3739
|
+
}), this.api.settings.describe({
|
|
3740
|
+
rpcId: randomUUID(),
|
|
3741
|
+
payload: {}
|
|
3742
|
+
})]);
|
|
3743
|
+
const directory = unwrapDsh(directoryResponse);
|
|
3744
|
+
const settings = unwrapDsh(settingsResponse);
|
|
3745
|
+
if (!settings.writable) throw new ApiError(409, "SETTINGS_READ_ONLY", "DSH settings are read-only.");
|
|
3746
|
+
const namespace = settings.namespaces.find((item) => item.ns === "llm-pi-ai");
|
|
3747
|
+
if (namespace === void 0) throw new ApiError(409, "CUSTOM_PROVIDERS_UNAVAILABLE", "This DSH installation does not expose custom providers.");
|
|
3748
|
+
if (directory.providers.some((provider) => provider.provider === id)) throw new ApiError(409, "PROVIDER_EXISTS", "A provider with this id already exists.");
|
|
3749
|
+
if (!protocolChoices(namespace).includes(input.api)) throw new ApiError(400, "PROVIDER_PROTOCOL_INVALID", "The selected API protocol is not supported by this DSH installation.");
|
|
3750
|
+
const key = input.apiKey?.trim() ?? "";
|
|
3751
|
+
const ref = keyRef(id);
|
|
3752
|
+
const displayName = input.displayName?.trim() ?? "";
|
|
3753
|
+
const profile = {
|
|
3754
|
+
...displayName === "" ? {} : { displayName },
|
|
3755
|
+
...key === "" ? {} : { apiKeyEnv: ref },
|
|
3756
|
+
api: input.api,
|
|
3757
|
+
baseURL: input.baseURL.trim(),
|
|
3758
|
+
models: input.models
|
|
3759
|
+
};
|
|
3760
|
+
unwrapDsh(await this.api.settings.mutate({
|
|
3761
|
+
rpcId: randomUUID(),
|
|
3762
|
+
payload: {
|
|
3763
|
+
ns: "llm-pi-ai",
|
|
3764
|
+
ops: [{
|
|
3765
|
+
op: "set",
|
|
3766
|
+
path: ["providers", id],
|
|
3767
|
+
value: profile
|
|
3768
|
+
}],
|
|
3769
|
+
...input.expectedRevision === void 0 ? {} : { expectedRevision: input.expectedRevision }
|
|
3770
|
+
}
|
|
3771
|
+
}));
|
|
3772
|
+
if (key !== "") unwrapDsh(await this.api.credentials.set({
|
|
3773
|
+
rpcId: randomUUID(),
|
|
3774
|
+
payload: {
|
|
3775
|
+
ref,
|
|
3776
|
+
value: key
|
|
3777
|
+
}
|
|
3778
|
+
}));
|
|
3779
|
+
}
|
|
3780
|
+
async removeProvider(providerId, expectedRevision) {
|
|
3781
|
+
const context = await this.providerContext(providerId);
|
|
3782
|
+
if (!context.writable) throw new ApiError(409, "SETTINGS_READ_ONLY", "DSH settings are read-only.");
|
|
3783
|
+
if (context.entry.settingsPath.length === 0) throw new ApiError(409, "PROVIDER_REQUIRED", "The built-in provider can be reset but not removed.");
|
|
3784
|
+
unwrapDsh(await this.api.settings.mutate({
|
|
3785
|
+
rpcId: randomUUID(),
|
|
3786
|
+
payload: {
|
|
3787
|
+
ns: context.entry.settingsNs,
|
|
3788
|
+
ops: [{
|
|
3789
|
+
op: "unset",
|
|
3790
|
+
path: context.entry.settingsPath
|
|
3791
|
+
}],
|
|
3792
|
+
...expectedRevision === void 0 ? {} : { expectedRevision }
|
|
3793
|
+
}
|
|
3794
|
+
}));
|
|
3795
|
+
}
|
|
3796
|
+
async discover(providerId, draft) {
|
|
3797
|
+
const payload = {
|
|
3798
|
+
settingsNs: (await this.providerContext(providerId)).entry.settingsNs,
|
|
3799
|
+
provider: providerId,
|
|
3800
|
+
...draft.baseURL === void 0 || draft.baseURL === "" ? {} : { baseURL: draft.baseURL },
|
|
3801
|
+
...draft.api === void 0 || draft.api === "" ? {} : { api: draft.api },
|
|
3802
|
+
...draft.apiKey === void 0 || draft.apiKey === "" ? {} : { apiKey: draft.apiKey }
|
|
3803
|
+
};
|
|
3804
|
+
return unwrapDsh(await this.api.llm.discoverModels({
|
|
3805
|
+
rpcId: randomUUID(),
|
|
3806
|
+
payload
|
|
3807
|
+
})).models;
|
|
3808
|
+
}
|
|
3809
|
+
async providerContext(providerId) {
|
|
3810
|
+
const [directoryResponse, settingsResponse] = await Promise.all([this.api.llm.providers({
|
|
3811
|
+
rpcId: randomUUID(),
|
|
3812
|
+
payload: {}
|
|
3813
|
+
}), this.api.settings.describe({
|
|
3814
|
+
rpcId: randomUUID(),
|
|
3815
|
+
payload: {}
|
|
3816
|
+
})]);
|
|
3817
|
+
const directory = unwrapDsh(directoryResponse);
|
|
3818
|
+
const settings = unwrapDsh(settingsResponse);
|
|
3819
|
+
const entry = directory.providers.find((item) => item.provider === providerId);
|
|
3820
|
+
if (entry === void 0) throw new ApiError(404, "PROVIDER_NOT_FOUND", "The provider is not available in DSH.");
|
|
3821
|
+
const namespace = settings.namespaces.find((item) => item.ns === entry.settingsNs);
|
|
3822
|
+
return {
|
|
3823
|
+
entry,
|
|
3824
|
+
writable: settings.writable,
|
|
3825
|
+
namespace,
|
|
3826
|
+
profile: recordAt(namespace?.value, entry.settingsPath)
|
|
3827
|
+
};
|
|
3828
|
+
}
|
|
3829
|
+
};
|
|
3830
|
+
function projectConfig(profile, inheritedModels) {
|
|
3831
|
+
const baseURL = text(profile?.baseURL);
|
|
3832
|
+
const api = text(profile?.api);
|
|
3833
|
+
const displayName = text(profile?.displayName);
|
|
3834
|
+
const thinking = text(profile?.thinking);
|
|
3835
|
+
const reasoningEffort = text(profile?.reasoningEffort);
|
|
3836
|
+
const configuredModels = Array.isArray(profile?.models) ? profile.models.filter(isRecord).map((model) => {
|
|
3837
|
+
const name = text(model.name);
|
|
3838
|
+
const contextWindow = positiveInteger(model.contextWindow);
|
|
3839
|
+
const maxTokens = positiveInteger(model.maxTokens);
|
|
3840
|
+
return {
|
|
3841
|
+
id: text(model.id) ?? "",
|
|
3842
|
+
...name === void 0 ? {} : { name },
|
|
3843
|
+
...contextWindow === void 0 ? {} : { contextWindow },
|
|
3844
|
+
...maxTokens === void 0 ? {} : { maxTokens }
|
|
3845
|
+
};
|
|
3846
|
+
}).filter((model) => model.id !== "") : [];
|
|
3847
|
+
const models = configuredModels.length > 0 ? configuredModels : (inheritedModels ?? []).map((model) => ({
|
|
3848
|
+
id: model.id,
|
|
3849
|
+
...model.name === "" ? {} : { name: model.name },
|
|
3850
|
+
...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow },
|
|
3851
|
+
...model.maxTokens === void 0 ? {} : { maxTokens: model.maxTokens }
|
|
3852
|
+
}));
|
|
3853
|
+
return {
|
|
3854
|
+
...baseURL === void 0 ? {} : { baseURL },
|
|
3855
|
+
...api === void 0 ? {} : { api },
|
|
3856
|
+
...displayName === void 0 ? {} : { displayName },
|
|
3857
|
+
...thinking === void 0 ? {} : { thinking },
|
|
3858
|
+
...reasoningEffort === void 0 ? {} : { reasoningEffort },
|
|
3859
|
+
models,
|
|
3860
|
+
...configuredModels.length === 0 && models.length > 0 ? { modelsInherited: true } : {}
|
|
3861
|
+
};
|
|
3862
|
+
}
|
|
3863
|
+
function mergeRecords(base, value) {
|
|
3864
|
+
if (base === void 0) return value;
|
|
3865
|
+
if (value === void 0) return base;
|
|
3866
|
+
return {
|
|
3867
|
+
...base,
|
|
3868
|
+
...value
|
|
3869
|
+
};
|
|
3870
|
+
}
|
|
3871
|
+
function protocolChoices(namespace) {
|
|
3872
|
+
if (!isRecord(namespace?.schema) || !isRecord(namespace.schema.refs)) return [];
|
|
3873
|
+
const refs = namespace.schema.refs;
|
|
3874
|
+
const node = (id) => refs[String(id)];
|
|
3875
|
+
const root = node(namespace.schema.uid);
|
|
3876
|
+
if (!isRecord(root?.dict)) return [];
|
|
3877
|
+
const profile = node(node(root.dict.providers)?.inner);
|
|
3878
|
+
if (!isRecord(profile?.dict)) return [];
|
|
3879
|
+
const api = node(profile.dict.api);
|
|
3880
|
+
if (api?.type !== "union" || !Array.isArray(api.list)) return [];
|
|
3881
|
+
return api.list.map((item) => node(item)?.value).filter((value) => typeof value === "string");
|
|
3882
|
+
}
|
|
3883
|
+
function validateModels(models) {
|
|
3884
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3885
|
+
for (const model of models) {
|
|
3886
|
+
if (!isRecord(model) || typeof model.id !== "string" || model.id.trim() === "" || ids.has(model.id)) throw new ApiError(400, "MODEL_CONFIG_INVALID", "Model ids must be non-empty and unique.");
|
|
3887
|
+
ids.add(model.id);
|
|
3888
|
+
for (const value of [model.contextWindow, model.maxTokens]) if (value !== void 0 && (!Number.isSafeInteger(value) || value <= 0)) throw new ApiError(400, "MODEL_CONFIG_INVALID", "Model capacities must be positive integers.");
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3891
|
+
function recordAt(value, path) {
|
|
3892
|
+
let current = value;
|
|
3893
|
+
for (const segment of path) {
|
|
3894
|
+
if (!isRecord(current)) return void 0;
|
|
3895
|
+
current = current[segment];
|
|
3896
|
+
}
|
|
3897
|
+
return isRecord(current) ? current : void 0;
|
|
3898
|
+
}
|
|
3899
|
+
function isRecord(value) {
|
|
3900
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3901
|
+
}
|
|
3902
|
+
function text(value) {
|
|
3903
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
3904
|
+
}
|
|
3905
|
+
function positiveInteger(value) {
|
|
3906
|
+
return Number.isSafeInteger(value) && Number(value) > 0 ? Number(value) : void 0;
|
|
3907
|
+
}
|
|
3908
|
+
function keyRef(provider) {
|
|
3909
|
+
return provider === "deepseek-official" ? "DEEPSEEK_API_KEY" : `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
|
|
3910
|
+
}
|
|
3911
|
+
//#endregion
|
|
3912
|
+
//#region src/index.ts
|
|
3913
|
+
/**
|
|
3914
|
+
* 插件身份。
|
|
3915
|
+
*
|
|
3916
|
+
* 2.0.4 起由 `dsh-workspace` 改名为 `dsh-remote-bridge`:插件实际承担的是
|
|
3917
|
+
* 「把 DSH 内核的私有 remote 协议翻译成版本化 REST,供手机端远程访问这台机器」,
|
|
3918
|
+
* 外加一个设置中心(根目录 / 远程访问 / 设备 / 回收站 / 审计)。原先的
|
|
3919
|
+
* 「workspace」只是其中一页,名字比内容宽。
|
|
3920
|
+
*
|
|
3921
|
+
* **以下一处刻意不改**,它是已发布客户端依赖的对外契约,改了等于破坏兼容:
|
|
3922
|
+
* - REST 前缀 `/dsh-workspace-api`(`src/host/server.ts`)
|
|
3923
|
+
*
|
|
3924
|
+
* 已发布的 App 把它写死了,改名只动「装的时候叫什么、bundle 叫什么」。
|
|
3925
|
+
*
|
|
3926
|
+
* 另外两项**已随 2.2.0 删除独立页一并退役**,不再是契约:独立页路径 `/dsh-workspace`
|
|
3927
|
+
* (页面已删)、浏览器存储键 `dsh-workspace-device-token`(只被那个页面读写过,现已无人使用)。
|
|
3928
|
+
* 另有一项契约不在此处:状态目录 `dshHomePath('dsh-workspace')`(在 `cordis.patch.yml`),
|
|
3929
|
+
* 换目录等于清空用户的授权根与回收站。
|
|
3930
|
+
*/
|
|
3931
|
+
const name = "dsh-remote-bridge";
|
|
3932
|
+
/**
|
|
3933
|
+
* Host services this plugin needs, for DSH 0.2.x.
|
|
3934
|
+
*
|
|
3935
|
+
* DSH ≤ 0.1.1 published the client-shaped `apiProxy` facade from
|
|
3936
|
+
* `@deepseek-ai/dsh-host-apiproxy`. DSH 0.1.2-alpha.3 replaced that package with
|
|
3937
|
+
* `@deepseek-ai/dsh-api-gateway` (service `typertGateway`), which publishes no
|
|
3938
|
+
* `apiProxy` — the controllers instead register directly as host services.
|
|
3939
|
+
* Injecting those keeps the plugin loadable on 0.2.x, and
|
|
3940
|
+
* `./host/dsh-0.2-bridge.ts` rebuilds the old facade on top of them so the
|
|
3941
|
+
* chat/settings adapters keep their original contract.
|
|
3942
|
+
*/
|
|
3943
|
+
const inject = [
|
|
3944
|
+
"sessionController",
|
|
3945
|
+
"workspaceController",
|
|
3946
|
+
"workspaceRegistry",
|
|
3947
|
+
"settingsController",
|
|
3948
|
+
"credentialsController",
|
|
3949
|
+
"agentPresets",
|
|
3950
|
+
"llm",
|
|
3951
|
+
"webServer",
|
|
3952
|
+
"agents",
|
|
3953
|
+
"commands",
|
|
3954
|
+
"fileUploads",
|
|
3955
|
+
"attachments"
|
|
3956
|
+
];
|
|
3957
|
+
async function apply(ctx, config = {}) {
|
|
3958
|
+
const dataDir = path.resolve(config.dataDir ?? path.join(process.cwd(), ".dsh-workspace"));
|
|
3959
|
+
const port = normalizeListenerPort(config.port ?? 3090, true);
|
|
3960
|
+
const remoteHost = normalizeListenerHost(config.remoteHost ?? "0.0.0.0");
|
|
3961
|
+
const maxUploadBytes = config.maxUploadBytes ?? 20971520;
|
|
3962
|
+
if (!Number.isSafeInteger(maxUploadBytes) || maxUploadBytes < 1) throw new Error("dsh-remote-bridge: maxUploadBytes must be positive");
|
|
3963
|
+
const database = new WorkspaceDatabase(dataDir);
|
|
3964
|
+
const events = new WorkspaceEventBus();
|
|
3965
|
+
const auth = new AuthService(database);
|
|
3966
|
+
const files = new FileService(database, (event) => events.emit(event));
|
|
3967
|
+
const apiProxy = createDsh02ApiProxy(ctx);
|
|
3968
|
+
const chat = new DshChatAdapter(apiProxy, database, {
|
|
3969
|
+
agents: ctx.agents,
|
|
3970
|
+
commands: ctx.commands
|
|
3971
|
+
});
|
|
3972
|
+
const settings = new DshSettingsAdapter(apiProxy);
|
|
3973
|
+
let router;
|
|
3974
|
+
const remote = new RemoteApiServer({
|
|
3975
|
+
port,
|
|
3976
|
+
remoteHost,
|
|
3977
|
+
database,
|
|
3978
|
+
auth,
|
|
3979
|
+
chat,
|
|
3980
|
+
events,
|
|
3981
|
+
handle: (req, res) => router.handle(req, res)
|
|
3982
|
+
});
|
|
3983
|
+
router = new ApiRouter({
|
|
3984
|
+
database,
|
|
3985
|
+
auth,
|
|
3986
|
+
files,
|
|
3987
|
+
chat,
|
|
3988
|
+
settings,
|
|
3989
|
+
remote,
|
|
3990
|
+
maxUploadBytes
|
|
3991
|
+
});
|
|
3992
|
+
try {
|
|
3993
|
+
await remote.start();
|
|
3994
|
+
} catch (error) {
|
|
3995
|
+
database.close();
|
|
3996
|
+
throw error;
|
|
3997
|
+
}
|
|
3998
|
+
const disposeEmbedded = attachEmbeddedRoutes(ctx.webServer, router, remote);
|
|
3999
|
+
ctx.effect(() => async () => {
|
|
4000
|
+
disposeEmbedded();
|
|
4001
|
+
await remote.stop();
|
|
4002
|
+
database.close();
|
|
4003
|
+
}, "dsh-remote-bridge lifecycle");
|
|
4004
|
+
const status = remote.status();
|
|
4005
|
+
ctx.logger.info(`dsh-remote-bridge v${PLUGIN_VERSION}: ${status.host}:${status.port} (${status.remoteEnabled ? "remote enabled" : "loopback only"})`);
|
|
4006
|
+
}
|
|
4007
|
+
//#endregion
|
|
4008
|
+
export { apply, apply as default, inject, name };
|