@zhanguym/chatroom 1.1.0-beta.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/external-access-registry.d.ts +1 -0
- package/dist/app/external-access-registry.js +3 -0
- package/dist/infrastructure/logging/system-log.js +2 -1
- package/dist/native/macos/ChatRoomComputerHelper.app/Contents/MacOS/chatroom-computer-helper +0 -0
- package/dist/native/windows/chatroom-computer-helper.exe +0 -0
- package/dist/plugins/computer/computer-native-backend.js +9 -1
- package/dist/plugins/computer/computer-native-host.js +21 -5
- package/dist/plugins/computer/types.d.ts +1 -0
- package/dist/plugins/web/http/workspace-api-router.js +119 -2
- package/dist/plugins/workspace/mcp.d.ts +1 -1
- package/dist/plugins/workspace/mcp.js +109 -1
- package/dist/plugins/workspace/plugin.js +4 -1
- package/dist/plugins/workspace/workspace-fs.d.ts +10 -0
- package/dist/plugins/workspace/workspace-fs.js +39 -0
- package/dist/plugins/workspace/workspace-service.d.ts +2 -0
- package/dist/plugins/workspace/workspace-service.js +18 -0
- package/dist/web/assets/{AgentView-DYJeMJLX.css → AgentView-BGLIBoxF.css} +1 -1
- package/dist/web/assets/AgentView-Dd6zCbr-.js +4 -0
- package/dist/web/assets/CloudView-DquY1HDZ.js +1 -0
- package/dist/web/assets/CodeViewer.vue_vue_type_script_setup_true_lang-ekOImmBP.js +3 -0
- package/dist/web/assets/{ComputerView-BM0u3wSF.css → ComputerView-DWzfSYJ7.css} +1 -1
- package/dist/web/assets/ComputerView-RD2gMxJZ.js +1 -0
- package/dist/web/assets/{VTextarea-DG1zE1as.css → MarkdownContent-B_5zTG8c.css} +1 -1
- package/dist/web/assets/MarkdownContent-CkjR5fs7.js +16 -0
- package/dist/web/assets/McpToolsView-Dg2-iaDP.js +1 -0
- package/dist/web/assets/McpToolsView-DjxPbTHj.css +1 -0
- package/dist/web/assets/OperationsView-C8yCX7WZ.js +4 -0
- package/dist/web/assets/ProcessesView-C834LC-f.js +4 -0
- package/dist/web/assets/StateChip.vue_vue_type_script_setup_true_lang-By2gilBw.js +1 -0
- package/dist/web/assets/SystemLogsView-B6KcZv1J.js +1 -0
- package/dist/web/assets/VSelect-BtAJX-XJ.js +1 -0
- package/dist/web/assets/VSpacer-M8bpkhTH.js +1 -0
- package/dist/web/assets/VSwitch-C0VTnwLJ.js +1 -0
- package/dist/web/assets/VTabs-g61mykvY.js +1 -0
- package/dist/web/assets/VTextarea-CRYQYRqg.js +1 -0
- package/dist/web/assets/VTextarea-xiMOHEu-.css +1 -0
- package/dist/web/assets/WorkspaceDownloadPreview-BrESF-82.js +1 -0
- package/dist/web/assets/WorkspaceDownloadPreview-DnxhH5Q7.css +1 -0
- package/dist/web/assets/WorkspaceMediaPreview-CwtP9xWT.css +1 -0
- package/dist/web/assets/WorkspaceMediaPreview-HYajqgZ3.js +4 -0
- package/dist/web/assets/WorkspacePdfPreview-BtZkUHzV.css +1 -0
- package/dist/web/assets/WorkspacePdfPreview-CO-ZtDHG.js +55 -0
- package/dist/web/assets/WorkspacesView-BJCnzBLj.js +2 -0
- package/dist/web/assets/WorkspacesView-ClA6xcjn.css +1 -0
- package/dist/web/assets/index-CZ-4tJlF.css +1 -0
- package/dist/web/assets/index-u-aADQP-.js +19 -0
- package/dist/web/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
- package/dist/web/assets/{useMasterDetailFocus-D0j-KcXv.js → useMasterDetailFocus-D1mQ8Qjk.js} +1 -1
- package/dist/web/assets/{useOperationLabels-DR8r6Vrl.js → useOperationLabels-BjPIVnJW.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/web/assets/AgentView-DKuspiki.js +0 -4
- package/dist/web/assets/CloudView-PbREbJgz.js +0 -1
- package/dist/web/assets/CodeViewer.vue_vue_type_script_setup_true_lang-CiFNEpWo.js +0 -3
- package/dist/web/assets/ComputerView-BRb2sE0Z.js +0 -1
- package/dist/web/assets/McpToolsView-B8NSFdf4.js +0 -1
- package/dist/web/assets/McpToolsView-C22ZbSuV.css +0 -1
- package/dist/web/assets/OperationsView-Cavi-6SZ.js +0 -4
- package/dist/web/assets/ProcessesView-UO48Nh2q.js +0 -4
- package/dist/web/assets/StateChip.vue_vue_type_script_setup_true_lang-Bvc9wRyA.js +0 -1
- package/dist/web/assets/SystemLogsView-BXmOrmu5.js +0 -1
- package/dist/web/assets/VSelect-BPRXx8KM.js +0 -1
- package/dist/web/assets/VSpacer-Cl-QiwaM.js +0 -1
- package/dist/web/assets/VSwitch-C0XNj1lR.js +0 -1
- package/dist/web/assets/VTabs-CHj39Ket.js +0 -1
- package/dist/web/assets/VTextarea-BIcUWOU6.js +0 -16
- package/dist/web/assets/WorkspacesView-DPCt6kYy.css +0 -1
- package/dist/web/assets/WorkspacesView-rY6RXeiR.js +0 -2
- package/dist/web/assets/index-BPpl3-sP.css +0 -1
- package/dist/web/assets/index-Dt-pWiW1.js +0 -19
|
@@ -10,6 +10,7 @@ export declare class ExternalAccessRegistry {
|
|
|
10
10
|
constructor(config: ChatRoomConfig["auth"]);
|
|
11
11
|
setCloud(state: ExternalAccessState): void;
|
|
12
12
|
clearCloud(): void;
|
|
13
|
+
publicWebBaseUrl(): string | null;
|
|
13
14
|
baseUrlForHost(kind: ExternalAccessKind, hostname: string): string | null;
|
|
14
15
|
matches(kind: ExternalAccessKind, hostname: string): boolean;
|
|
15
16
|
hasHost(hostname: string): boolean;
|
|
@@ -13,6 +13,9 @@ export class ExternalAccessRegistry {
|
|
|
13
13
|
clearCloud() {
|
|
14
14
|
this.cloud = { mcpBaseUrl: null, webBaseUrl: null };
|
|
15
15
|
}
|
|
16
|
+
publicWebBaseUrl() {
|
|
17
|
+
return this.cloud.webBaseUrl ?? this.selfHosted.webBaseUrl;
|
|
18
|
+
}
|
|
16
19
|
baseUrlForHost(kind, hostname) {
|
|
17
20
|
for (const url of this.urls(kind))
|
|
18
21
|
if (new URL(url).hostname === hostname)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
3
|
import { SecretRedactor } from "#core/operations/redactor";
|
|
3
4
|
export class SystemLog {
|
|
4
5
|
store;
|
|
@@ -35,7 +36,7 @@ export class SystemLog {
|
|
|
35
36
|
}
|
|
36
37
|
write(level, module, event, message, data) {
|
|
37
38
|
const redacted = this.redactor.redact({
|
|
38
|
-
message,
|
|
39
|
+
message: stripVTControlCharacters(message),
|
|
39
40
|
...(data ? { data: normalizeValue(data) } : {}),
|
|
40
41
|
});
|
|
41
42
|
const record = {
|
package/dist/native/macos/ChatRoomComputerHelper.app/Contents/MacOS/chatroom-computer-helper
CHANGED
|
Binary file
|
|
Binary file
|
|
@@ -5,9 +5,17 @@ export class NativeComputerBackend {
|
|
|
5
5
|
host = new ComputerNativeHost();
|
|
6
6
|
restartWhenGranted = new Set();
|
|
7
7
|
async status() {
|
|
8
|
-
const value = await this.readStatus().catch(() => ({
|
|
8
|
+
const value = await this.readStatus().catch((error) => ({
|
|
9
9
|
platform: this.host.platform,
|
|
10
10
|
helper: "unavailable",
|
|
11
|
+
...(error instanceof ChatRoomError &&
|
|
12
|
+
error.code === "UNSUPPORTED" &&
|
|
13
|
+
error.details &&
|
|
14
|
+
typeof error.details === "object" &&
|
|
15
|
+
"reason" in error.details &&
|
|
16
|
+
error.details.reason === "no_x11_session"
|
|
17
|
+
? { unavailableReason: "no_x11_session" }
|
|
18
|
+
: {}),
|
|
11
19
|
permissions: {
|
|
12
20
|
accessibility: "unknown",
|
|
13
21
|
screenRecording: "unknown",
|
|
@@ -62,8 +62,17 @@ export class ComputerNativeHost {
|
|
|
62
62
|
const child = this.child;
|
|
63
63
|
if (!child || child.killed || child.stdin.destroyed)
|
|
64
64
|
this.rejectRequest(id, transportError);
|
|
65
|
-
else
|
|
66
|
-
|
|
65
|
+
else {
|
|
66
|
+
try {
|
|
67
|
+
child.stdin.write(line, (error) => {
|
|
68
|
+
if (error)
|
|
69
|
+
this.failTransport(child, error);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
this.failTransport(child, error instanceof Error ? error : new Error(String(error)));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
67
76
|
}
|
|
68
77
|
return promise;
|
|
69
78
|
}
|
|
@@ -104,8 +113,12 @@ export class ComputerNativeHost {
|
|
|
104
113
|
return this.startMacHelper(generation);
|
|
105
114
|
case "windows":
|
|
106
115
|
return this.startPipeHelper(windowsHelperPath(), "Windows", process.env, generation);
|
|
107
|
-
case "linux":
|
|
108
|
-
|
|
116
|
+
case "linux": {
|
|
117
|
+
const env = linuxDesktopEnvironment();
|
|
118
|
+
if (!env.DISPLAY)
|
|
119
|
+
return Promise.reject(new ChatRoomError("UNSUPPORTED", "Linux Computer Use requires an active X11 graphical session", { reason: "no_x11_session" }));
|
|
120
|
+
return this.startPipeHelper(linuxHelperPath(), "Linux X11", env, generation);
|
|
121
|
+
}
|
|
109
122
|
case "unsupported":
|
|
110
123
|
return Promise.reject(new ChatRoomError("UNSUPPORTED", "Computer Use is supported only on macOS, Windows, and Linux X11"));
|
|
111
124
|
}
|
|
@@ -182,6 +195,7 @@ export class ComputerNativeHost {
|
|
|
182
195
|
env,
|
|
183
196
|
});
|
|
184
197
|
this.child = child;
|
|
198
|
+
child.stdin.on("error", (error) => this.failTransport(child, error));
|
|
185
199
|
this.lines = createInterface({ input: child.stdout });
|
|
186
200
|
this.lines.on("line", (line) => this.handleLine(line));
|
|
187
201
|
child.stderr.on("data", (chunk) => console.error("[computer-helper]", String(chunk).trimEnd()));
|
|
@@ -204,8 +218,10 @@ export class ComputerNativeHost {
|
|
|
204
218
|
cancelStarting?.(error);
|
|
205
219
|
if (isCurrentSocket)
|
|
206
220
|
this.socket = null;
|
|
207
|
-
if (isCurrentChild)
|
|
221
|
+
if (isCurrentChild) {
|
|
222
|
+
this.child?.kill();
|
|
208
223
|
this.child = null;
|
|
224
|
+
}
|
|
209
225
|
this.lines?.close();
|
|
210
226
|
this.lines = null;
|
|
211
227
|
this.cleanupSocketPath();
|
|
@@ -23,6 +23,7 @@ export interface ComputerDisplay {
|
|
|
23
23
|
export interface ComputerStatus {
|
|
24
24
|
platform: ComputerPlatform;
|
|
25
25
|
helper: ComputerHelperState;
|
|
26
|
+
unavailableReason?: "no_x11_session";
|
|
26
27
|
permissions: ComputerPermissions;
|
|
27
28
|
displays: ComputerDisplay[];
|
|
28
29
|
settings: ComputerSettings;
|
|
@@ -47,7 +47,7 @@ export function createWorkspaceApiRouter(application) {
|
|
|
47
47
|
res.json(await application.workspaces.projectFilesResult(requireString(req.query.root, "root")));
|
|
48
48
|
}));
|
|
49
49
|
router.get("/workspace/file", asyncRoute(async (req, res) => {
|
|
50
|
-
const fs = await
|
|
50
|
+
const fs = await requestWorkspaceFs(req, application);
|
|
51
51
|
res.json(await fs.read(requireString(req.query.path, "path"), {
|
|
52
52
|
maxBytes: 2 * 1024 * 1024,
|
|
53
53
|
}));
|
|
@@ -103,12 +103,25 @@ export function createWorkspaceApiRouter(application) {
|
|
|
103
103
|
: null,
|
|
104
104
|
});
|
|
105
105
|
}));
|
|
106
|
+
router.get("/workspace/file/download", asyncRoute(async (req, res) => {
|
|
107
|
+
const filePath = requireString(req.query.path, "path");
|
|
108
|
+
const fs = await requestWorkspaceFs(req, application);
|
|
109
|
+
await streamWorkspaceFile(req, res, fs, filePath, "application/octet-stream", true);
|
|
110
|
+
}));
|
|
111
|
+
router.get("/workspace/file/preview", asyncRoute(async (req, res) => {
|
|
112
|
+
const filePath = requireString(req.query.path, "path");
|
|
113
|
+
const mime = previewMime(filePath);
|
|
114
|
+
if (!mime)
|
|
115
|
+
throw new ChatRoomError("INVALID_INPUT", "Unsupported file preview type");
|
|
116
|
+
const fs = await requestWorkspaceFs(req, application);
|
|
117
|
+
await streamWorkspaceFile(req, res, fs, filePath, mime, false);
|
|
118
|
+
}));
|
|
106
119
|
router.get("/workspace/file/image", asyncRoute(async (req, res) => {
|
|
107
120
|
const filePath = requireString(req.query.path, "path");
|
|
108
121
|
const mime = imageMime(filePath);
|
|
109
122
|
if (!mime)
|
|
110
123
|
throw new ChatRoomError("INVALID_INPUT", "File type is not supported for image preview");
|
|
111
|
-
const fs = await
|
|
124
|
+
const fs = await requestWorkspaceFs(req, application);
|
|
112
125
|
const file = await fs.readBytes(filePath, {
|
|
113
126
|
maxBytes: MAX_IMAGE_PREVIEW_BYTES,
|
|
114
127
|
});
|
|
@@ -121,6 +134,8 @@ export function createWorkspaceApiRouter(application) {
|
|
|
121
134
|
'"';
|
|
122
135
|
const requestedVersion = typeof req.query.v === "string" ? req.query.v : null;
|
|
123
136
|
res.setHeader("Content-Type", mime);
|
|
137
|
+
if (mime === "image/svg+xml")
|
|
138
|
+
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'");
|
|
124
139
|
res.setHeader("ETag", etag);
|
|
125
140
|
res.setHeader("Last-Modified", file.modifiedAt);
|
|
126
141
|
res.setHeader("Cache-Control", requestedVersion === file.modifiedAt
|
|
@@ -136,6 +151,11 @@ export function createWorkspaceApiRouter(application) {
|
|
|
136
151
|
}));
|
|
137
152
|
return router;
|
|
138
153
|
}
|
|
154
|
+
function requestWorkspaceFs(req, application) {
|
|
155
|
+
if (req.query.workspace !== undefined)
|
|
156
|
+
return application.workspaces.fsByAlias(requireString(req.query.workspace, "workspace"));
|
|
157
|
+
return application.workspaces.fs(requireString(req.query.root, "root"));
|
|
158
|
+
}
|
|
139
159
|
function imageMime(filePath) {
|
|
140
160
|
switch (filePath.split(".").pop()?.toLowerCase()) {
|
|
141
161
|
case "png":
|
|
@@ -153,6 +173,8 @@ function imageMime(filePath) {
|
|
|
153
173
|
return "image/bmp";
|
|
154
174
|
case "ico":
|
|
155
175
|
return "image/x-icon";
|
|
176
|
+
case "svg":
|
|
177
|
+
return "image/svg+xml";
|
|
156
178
|
default:
|
|
157
179
|
return null;
|
|
158
180
|
}
|
|
@@ -162,3 +184,98 @@ function attachmentFileName(value) {
|
|
|
162
184
|
const safe = name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
163
185
|
return (safe || "attachment").slice(0, 160);
|
|
164
186
|
}
|
|
187
|
+
function previewMime(filePath) {
|
|
188
|
+
switch (filePath.split(".").pop()?.toLowerCase()) {
|
|
189
|
+
case "pdf":
|
|
190
|
+
return "application/pdf";
|
|
191
|
+
case "mp4":
|
|
192
|
+
case "m4v":
|
|
193
|
+
return "video/mp4";
|
|
194
|
+
case "webm":
|
|
195
|
+
return "video/webm";
|
|
196
|
+
case "ogv":
|
|
197
|
+
return "video/ogg";
|
|
198
|
+
case "mov":
|
|
199
|
+
return "video/quicktime";
|
|
200
|
+
case "mp3":
|
|
201
|
+
return "audio/mpeg";
|
|
202
|
+
case "m4a":
|
|
203
|
+
return "audio/mp4";
|
|
204
|
+
case "aac":
|
|
205
|
+
return "audio/aac";
|
|
206
|
+
case "wav":
|
|
207
|
+
return "audio/wav";
|
|
208
|
+
case "flac":
|
|
209
|
+
return "audio/flac";
|
|
210
|
+
case "oga":
|
|
211
|
+
case "opus":
|
|
212
|
+
case "ogg":
|
|
213
|
+
return "audio/ogg";
|
|
214
|
+
default:
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function parseByteRange(header, size) {
|
|
219
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
220
|
+
if (!match || size === 0 || (!match[1] && !match[2]))
|
|
221
|
+
return null;
|
|
222
|
+
let start;
|
|
223
|
+
let end;
|
|
224
|
+
if (!match[1]) {
|
|
225
|
+
const suffix = Number(match[2]);
|
|
226
|
+
if (!Number.isSafeInteger(suffix) || suffix <= 0)
|
|
227
|
+
return null;
|
|
228
|
+
start = Math.max(0, size - suffix);
|
|
229
|
+
end = size - 1;
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
start = Number(match[1]);
|
|
233
|
+
end = match[2] ? Number(match[2]) : size - 1;
|
|
234
|
+
if (!Number.isSafeInteger(start) ||
|
|
235
|
+
!Number.isSafeInteger(end) ||
|
|
236
|
+
start >= size ||
|
|
237
|
+
end < start)
|
|
238
|
+
return null;
|
|
239
|
+
end = Math.min(end, size - 1);
|
|
240
|
+
}
|
|
241
|
+
return { start, end };
|
|
242
|
+
}
|
|
243
|
+
async function streamWorkspaceFile(req, res, fs, relativePath, mime, download) {
|
|
244
|
+
const { handle, bytes } = await fs.openReadable(relativePath);
|
|
245
|
+
const isRange = typeof req.headers.range === "string";
|
|
246
|
+
const range = isRange ? parseByteRange(req.headers.range, bytes) : null;
|
|
247
|
+
res.setHeader("Content-Type", mime);
|
|
248
|
+
res.setHeader("Cache-Control", "private, no-store, no-transform");
|
|
249
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
250
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
251
|
+
if (download) {
|
|
252
|
+
const name = relativePath.replaceAll("\\", "/").split("/").pop() || "download";
|
|
253
|
+
const fallback = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
|
|
254
|
+
res.setHeader("Content-Disposition", `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(name)}`);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
res.setHeader("Content-Disposition", "inline");
|
|
258
|
+
}
|
|
259
|
+
if (isRange && !range) {
|
|
260
|
+
res.setHeader("Content-Range", `bytes */${bytes}`);
|
|
261
|
+
await handle.close();
|
|
262
|
+
res.status(416).end();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const start = range?.start ?? 0;
|
|
266
|
+
const end = range?.end ?? bytes - 1;
|
|
267
|
+
if (range) {
|
|
268
|
+
res.status(206);
|
|
269
|
+
res.setHeader("Content-Range", `bytes ${start}-${end}/${bytes}`);
|
|
270
|
+
}
|
|
271
|
+
res.setHeader("Content-Length", String(Math.max(0, end - start + 1)));
|
|
272
|
+
if (req.method === "HEAD" || bytes === 0) {
|
|
273
|
+
await handle.close();
|
|
274
|
+
res.end();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const stream = handle.createReadStream({ start, end, autoClose: true });
|
|
278
|
+
res.on("close", () => stream.destroy());
|
|
279
|
+
stream.on("error", (error) => res.destroy(error));
|
|
280
|
+
stream.pipe(res);
|
|
281
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { PluginMcpRegistrar } from "#mcp/server/plugin-mcp-registrar";
|
|
2
2
|
import type { WorkspaceService } from "./workspace-service.js";
|
|
3
|
-
export declare function registerWorkspaceTools(mcp: PluginMcpRegistrar, workspaces: WorkspaceService): void;
|
|
3
|
+
export declare function registerWorkspaceTools(mcp: PluginMcpRegistrar, workspaces: WorkspaceService, webBaseUrl: () => string | null): void;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { ChatRoomError } from "#core/errors/chatroom-error";
|
|
1
3
|
import { z } from "zod";
|
|
2
4
|
import { closedRead } from "#mcp/server/tool-support";
|
|
3
5
|
const workspaceEntrySchema = z.object({
|
|
@@ -20,7 +22,7 @@ const workspaceInfoSchema = z.object({
|
|
|
20
22
|
path: z.string(),
|
|
21
23
|
})),
|
|
22
24
|
});
|
|
23
|
-
export function registerWorkspaceTools(mcp, workspaces) {
|
|
25
|
+
export function registerWorkspaceTools(mcp, workspaces, webBaseUrl) {
|
|
24
26
|
mcp.registerTool("workspace_list", {
|
|
25
27
|
title: "List workspaces",
|
|
26
28
|
description: "List available projects with their workspace roots, names, and project summaries from the workspace prompt metadata. Use this first to identify the relevant project before calling workspace_info for its full prompt, instructions, and skills.",
|
|
@@ -37,4 +39,110 @@ export function registerWorkspaceTools(mcp, workspaces) {
|
|
|
37
39
|
annotations: closedRead,
|
|
38
40
|
action: "info",
|
|
39
41
|
}, (input) => workspaces.info(input.root));
|
|
42
|
+
mcp.registerTool("workspace_file_get", {
|
|
43
|
+
title: "Get workspace file",
|
|
44
|
+
description: "Read a file from a workspace in bounded chunks, or return a browser download link protected by the normal ChatRoom Web login. " +
|
|
45
|
+
"Use mode=content with encoding=utf8 for text and encoding=base64 for binary files; advance offset with nextOffset for large files. " +
|
|
46
|
+
"Use mode=link to give the user a download link when the Web service has a configured public address. " +
|
|
47
|
+
"Links do not contain credentials and the user must log in to ChatRoom Web to download.",
|
|
48
|
+
inputSchema: z.object({
|
|
49
|
+
root: z.string(),
|
|
50
|
+
path: z.string(),
|
|
51
|
+
mode: z.enum(["content", "link"]).default("content"),
|
|
52
|
+
encoding: z.enum(["utf8", "base64"]).default("utf8"),
|
|
53
|
+
offset: z.number().int().nonnegative().default(0),
|
|
54
|
+
maxBytes: z.number().int().min(1).max(262144).default(65536),
|
|
55
|
+
}),
|
|
56
|
+
outputSchema: z.object({
|
|
57
|
+
mode: z.enum(["content", "link"]),
|
|
58
|
+
path: z.string(),
|
|
59
|
+
name: z.string(),
|
|
60
|
+
bytes: z.number(),
|
|
61
|
+
modifiedAt: z.string(),
|
|
62
|
+
encoding: z.enum(["utf8", "base64"]).optional(),
|
|
63
|
+
content: z.string().optional(),
|
|
64
|
+
offset: z.number().optional(),
|
|
65
|
+
nextOffset: z.number().nullable().optional(),
|
|
66
|
+
url: z.string().optional(),
|
|
67
|
+
}),
|
|
68
|
+
annotations: closedRead,
|
|
69
|
+
action: "file.get",
|
|
70
|
+
audit: {
|
|
71
|
+
input: (input) => ({
|
|
72
|
+
root: input.root,
|
|
73
|
+
path: input.path,
|
|
74
|
+
mode: input.mode,
|
|
75
|
+
offset: input.mode === "content" ? input.offset : undefined,
|
|
76
|
+
}),
|
|
77
|
+
output: (output) => {
|
|
78
|
+
const result = output;
|
|
79
|
+
return {
|
|
80
|
+
mode: result.mode,
|
|
81
|
+
path: result.path,
|
|
82
|
+
bytes: result.bytes,
|
|
83
|
+
nextOffset: result.nextOffset,
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
}, async (input) => {
|
|
88
|
+
const fs = await workspaces.fs(input.root);
|
|
89
|
+
const name = path.posix.basename(input.path.replaceAll("\\", "/"));
|
|
90
|
+
if (input.mode === "link") {
|
|
91
|
+
const base = webBaseUrl();
|
|
92
|
+
if (!base)
|
|
93
|
+
throw new ChatRoomError("INVALID_INPUT", "No public Web URL is configured; download link is unavailable");
|
|
94
|
+
const info = await fs.fileInfo(input.path);
|
|
95
|
+
const url = new URL("/download", base);
|
|
96
|
+
url.searchParams.set("workspace", path.basename(fs.root));
|
|
97
|
+
url.searchParams.set("path", input.path);
|
|
98
|
+
return {
|
|
99
|
+
mode: "link",
|
|
100
|
+
path: input.path,
|
|
101
|
+
name,
|
|
102
|
+
...info,
|
|
103
|
+
url: url.toString(),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const chunk = await fs.readChunk(input.path, input.offset, input.maxBytes);
|
|
107
|
+
let content;
|
|
108
|
+
let consumed = chunk.data.byteLength;
|
|
109
|
+
if (input.encoding === "utf8") {
|
|
110
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
111
|
+
let decoded = null;
|
|
112
|
+
for (let trim = 0; trim < 4 && trim < chunk.data.byteLength; trim += 1) {
|
|
113
|
+
if (trim > 0 && input.offset + chunk.data.byteLength >= chunk.bytes)
|
|
114
|
+
break;
|
|
115
|
+
try {
|
|
116
|
+
consumed = chunk.data.byteLength - trim;
|
|
117
|
+
decoded = decoder.decode(chunk.data.subarray(0, consumed));
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Only incomplete trailing UTF-8 sequences can be repaired.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (decoded === null) {
|
|
125
|
+
if (chunk.data.byteLength === 0)
|
|
126
|
+
decoded = "";
|
|
127
|
+
else
|
|
128
|
+
throw new ChatRoomError("INVALID_INPUT", "Invalid UTF-8 data; use base64");
|
|
129
|
+
}
|
|
130
|
+
content = decoded;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
content = chunk.data.toString("base64");
|
|
134
|
+
}
|
|
135
|
+
const next = input.offset + consumed;
|
|
136
|
+
return {
|
|
137
|
+
mode: "content",
|
|
138
|
+
path: input.path,
|
|
139
|
+
name,
|
|
140
|
+
bytes: chunk.bytes,
|
|
141
|
+
modifiedAt: chunk.modifiedAt,
|
|
142
|
+
encoding: input.encoding,
|
|
143
|
+
content,
|
|
144
|
+
offset: input.offset,
|
|
145
|
+
nextOffset: next < chunk.bytes ? next : null,
|
|
146
|
+
};
|
|
147
|
+
});
|
|
40
148
|
}
|
|
@@ -6,9 +6,11 @@ import { WorkspaceService } from "./workspace-service.js";
|
|
|
6
6
|
export const WorkspaceServiceToken = createServiceToken("workspace");
|
|
7
7
|
export function createWorkspacePlugin() {
|
|
8
8
|
let service = null;
|
|
9
|
+
let webBaseUrl = () => null;
|
|
9
10
|
return {
|
|
10
11
|
id: "workspace",
|
|
11
12
|
async activate(context) {
|
|
13
|
+
webBaseUrl = () => context.externalAccess.publicWebBaseUrl();
|
|
12
14
|
service = await WorkspaceService.create(context.config.allowedRoots, context.services.require(GitAccessToken));
|
|
13
15
|
context.services.provide(WorkspaceServiceToken, service);
|
|
14
16
|
context.services.provide(WorkspaceAccessToken, service);
|
|
@@ -16,10 +18,11 @@ export function createWorkspacePlugin() {
|
|
|
16
18
|
registerMcp(mcp) {
|
|
17
19
|
if (!service)
|
|
18
20
|
throw new Error("Workspace plugin is not active");
|
|
19
|
-
registerWorkspaceTools(mcp, service);
|
|
21
|
+
registerWorkspaceTools(mcp, service, webBaseUrl);
|
|
20
22
|
},
|
|
21
23
|
deactivate() {
|
|
22
24
|
service = null;
|
|
25
|
+
webBaseUrl = () => null;
|
|
23
26
|
},
|
|
24
27
|
};
|
|
25
28
|
}
|
|
@@ -23,6 +23,16 @@ export declare class WorkspaceFs {
|
|
|
23
23
|
truncated: boolean;
|
|
24
24
|
modifiedAt: string;
|
|
25
25
|
}>;
|
|
26
|
+
openReadable(relativePath: string): Promise<{
|
|
27
|
+
handle: import("fs/promises").FileHandle;
|
|
28
|
+
bytes: number;
|
|
29
|
+
modifiedAt: string;
|
|
30
|
+
}>;
|
|
31
|
+
readChunk(relativePath: string, offset: number, maxBytes: number): Promise<{
|
|
32
|
+
data: Buffer;
|
|
33
|
+
bytes: number;
|
|
34
|
+
modifiedAt: string;
|
|
35
|
+
}>;
|
|
26
36
|
write(relativePath: string, content: string): Promise<WorkspaceFile>;
|
|
27
37
|
writeBytes(relativePath: string, content: Uint8Array): Promise<WorkspaceFile>;
|
|
28
38
|
list(relativePath?: string, options?: {
|
|
@@ -68,6 +68,45 @@ export class WorkspaceFs {
|
|
|
68
68
|
}
|
|
69
69
|
throw new ChatRoomError("CONFLICT", `File changed while being read: ${relativePath}`);
|
|
70
70
|
}
|
|
71
|
+
async openReadable(relativePath) {
|
|
72
|
+
const target = await resolveReadableWithin(this.root, relativePath);
|
|
73
|
+
const handle = await open(target, "r");
|
|
74
|
+
try {
|
|
75
|
+
const info = await handle.stat();
|
|
76
|
+
if (!info.isFile())
|
|
77
|
+
throw new ChatRoomError("INVALID_INPUT", `Not a file: ${relativePath}`);
|
|
78
|
+
return { handle, bytes: info.size, modifiedAt: info.mtime.toISOString() };
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
await handle.close();
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async readChunk(relativePath, offset, maxBytes) {
|
|
86
|
+
const { handle, bytes, modifiedAt } = await this.openReadable(relativePath);
|
|
87
|
+
try {
|
|
88
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > bytes)
|
|
89
|
+
throw new ChatRoomError("INVALID_INPUT", "Invalid file offset");
|
|
90
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 262144)
|
|
91
|
+
throw new ChatRoomError("INVALID_INPUT", "Invalid file chunk size");
|
|
92
|
+
const length = Math.min(maxBytes, bytes - offset);
|
|
93
|
+
const buffer = Buffer.alloc(length);
|
|
94
|
+
let position = 0;
|
|
95
|
+
while (position < length) {
|
|
96
|
+
const { bytesRead } = await handle.read(buffer, position, length - position, offset + position);
|
|
97
|
+
if (bytesRead === 0)
|
|
98
|
+
break;
|
|
99
|
+
position += bytesRead;
|
|
100
|
+
}
|
|
101
|
+
const after = await handle.stat();
|
|
102
|
+
if (after.size !== bytes || after.mtime.toISOString() !== modifiedAt)
|
|
103
|
+
throw new ChatRoomError("CONFLICT", "File changed while reading");
|
|
104
|
+
return { data: buffer.subarray(0, position), bytes, modifiedAt };
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await handle.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
71
110
|
async write(relativePath, content) {
|
|
72
111
|
if (typeof content !== "string")
|
|
73
112
|
throw new ChatRoomError("INVALID_INPUT", "File content must be a string");
|
|
@@ -9,6 +9,8 @@ export declare class WorkspaceService implements WorkspaceAccess {
|
|
|
9
9
|
roots(): string[];
|
|
10
10
|
list(): Promise<WorkspaceEntry[]>;
|
|
11
11
|
resolve(input: string): Promise<string>;
|
|
12
|
+
resolveAlias(alias: string): Promise<string>;
|
|
13
|
+
fsByAlias(alias: string): Promise<WorkspaceFs>;
|
|
12
14
|
createProject(parentInput: string, nameInput: string): Promise<WorkspaceEntry>;
|
|
13
15
|
projectFiles(input: string, maxFiles?: number): Promise<string[]>;
|
|
14
16
|
projectFilesResult(input: string, maxFiles?: number): Promise<{
|
|
@@ -51,6 +51,24 @@ export class WorkspaceService {
|
|
|
51
51
|
async resolve(input) {
|
|
52
52
|
return this.rootsPolicy.resolveWorkspace(input);
|
|
53
53
|
}
|
|
54
|
+
async resolveAlias(alias) {
|
|
55
|
+
const name = validateProjectName(alias);
|
|
56
|
+
const matches = [];
|
|
57
|
+
for (const parent of this.rootsPolicy.roots()) {
|
|
58
|
+
const candidate = path.join(parent, name);
|
|
59
|
+
const canonical = await realpath(candidate).catch(() => null);
|
|
60
|
+
if (canonical === candidate)
|
|
61
|
+
matches.push(candidate);
|
|
62
|
+
}
|
|
63
|
+
if (matches.length === 0)
|
|
64
|
+
throw new ChatRoomError("NOT_FOUND", "Workspace not found");
|
|
65
|
+
if (matches.length > 1)
|
|
66
|
+
throw new ChatRoomError("CONFLICT", "Workspace alias is ambiguous");
|
|
67
|
+
return this.resolve(matches[0]);
|
|
68
|
+
}
|
|
69
|
+
async fsByAlias(alias) {
|
|
70
|
+
return WorkspaceFs.create(await this.resolveAlias(alias));
|
|
71
|
+
}
|
|
54
72
|
async createProject(parentInput, nameInput) {
|
|
55
73
|
const parent = await this.rootsPolicy.resolveAllowedRoot(parentInput);
|
|
56
74
|
const name = validateProjectName(nameInput);
|