@chatroomcp/chatroom 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +50 -0
- package/assets/chatroom.svg +15 -0
- package/chatroom.config.example.json +24 -0
- package/dist/app/application.d.ts +18 -0
- package/dist/app/application.js +63 -0
- package/dist/app/event-bus.d.ts +23 -0
- package/dist/app/event-bus.js +12 -0
- package/dist/app/external-access-registry.d.ts +18 -0
- package/dist/app/external-access-registry.js +48 -0
- package/dist/app/lifecycle.d.ts +12 -0
- package/dist/app/lifecycle.js +41 -0
- package/dist/auth/auth-service.d.ts +88 -0
- package/dist/auth/auth-service.js +222 -0
- package/dist/auth/ingress-policy.d.ts +21 -0
- package/dist/auth/ingress-policy.js +57 -0
- package/dist/auth/passkey-service.d.ts +39 -0
- package/dist/auth/passkey-service.js +145 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +10 -0
- package/dist/cli/run-cli.d.ts +1 -0
- package/dist/cli/run-cli.js +42 -0
- package/dist/config/load-config.d.ts +7 -0
- package/dist/config/load-config.js +182 -0
- package/dist/config/platform-paths.d.ts +6 -0
- package/dist/config/platform-paths.js +34 -0
- package/dist/config/types.d.ts +25 -0
- package/dist/config/types.js +1 -0
- package/dist/core/auth/passkey-repository.d.ts +18 -0
- package/dist/core/auth/passkey-repository.js +1 -0
- package/dist/core/auth/repository.d.ts +59 -0
- package/dist/core/auth/repository.js +7 -0
- package/dist/core/auth/session-repository.d.ts +6 -0
- package/dist/core/auth/session-repository.js +1 -0
- package/dist/core/errors/chatroom-error.d.ts +23 -0
- package/dist/core/errors/chatroom-error.js +46 -0
- package/dist/core/operations/bounded-value.d.ts +6 -0
- package/dist/core/operations/bounded-value.js +41 -0
- package/dist/core/operations/redactor.d.ts +4 -0
- package/dist/core/operations/redactor.js +65 -0
- package/dist/core/operations/repository.d.ts +19 -0
- package/dist/core/operations/repository.js +1 -0
- package/dist/core/operations/types.d.ts +31 -0
- package/dist/core/operations/types.js +9 -0
- package/dist/core/runtime/child-environment.d.ts +1 -0
- package/dist/core/runtime/child-environment.js +39 -0
- package/dist/core/runtime/command-runner.d.ts +15 -0
- package/dist/core/runtime/command-runner.js +39 -0
- package/dist/core/runtime/identity.d.ts +2 -0
- package/dist/core/runtime/identity.js +9 -0
- package/dist/infrastructure/database/app-database.d.ts +6 -0
- package/dist/infrastructure/database/app-database.js +104 -0
- package/dist/infrastructure/database/oauth-repository.d.ts +20 -0
- package/dist/infrastructure/database/oauth-repository.js +140 -0
- package/dist/infrastructure/database/operation-repository.d.ts +16 -0
- package/dist/infrastructure/database/operation-repository.js +129 -0
- package/dist/infrastructure/database/passkey-repository.d.ts +11 -0
- package/dist/infrastructure/database/passkey-repository.js +57 -0
- package/dist/infrastructure/database/web-session-repository.d.ts +10 -0
- package/dist/infrastructure/database/web-session-repository.js +26 -0
- package/dist/infrastructure/http/http-server.d.ts +26 -0
- package/dist/infrastructure/http/http-server.js +166 -0
- package/dist/mcp/server/create-mcp-server.d.ts +3 -0
- package/dist/mcp/server/create-mcp-server.js +16 -0
- package/dist/mcp/server/tool-support.d.ts +105 -0
- package/dist/mcp/server/tool-support.js +124 -0
- package/dist/operations/operation-log.d.ts +23 -0
- package/dist/operations/operation-log.js +103 -0
- package/dist/plugins/cloud/api-client.d.ts +56 -0
- package/dist/plugins/cloud/api-client.js +122 -0
- package/dist/plugins/cloud/controller.d.ts +48 -0
- package/dist/plugins/cloud/controller.js +282 -0
- package/dist/plugins/cloud/device-proof.d.ts +9 -0
- package/dist/plugins/cloud/device-proof.js +17 -0
- package/dist/plugins/cloud/plugin.d.ts +4 -0
- package/dist/plugins/cloud/plugin.js +17 -0
- package/dist/plugins/cloud/state-store.d.ts +7 -0
- package/dist/plugins/cloud/state-store.js +74 -0
- package/dist/plugins/cloud/tunnel-client.d.ts +36 -0
- package/dist/plugins/cloud/tunnel-client.js +293 -0
- package/dist/plugins/cloud/types.d.ts +70 -0
- package/dist/plugins/cloud/types.js +32 -0
- package/dist/plugins/plugin-manager.d.ts +11 -0
- package/dist/plugins/plugin-manager.js +24 -0
- package/dist/plugins/process/backend.d.ts +19 -0
- package/dist/plugins/process/backend.js +1 -0
- package/dist/plugins/process/head-tail-buffer.d.ts +18 -0
- package/dist/plugins/process/head-tail-buffer.js +49 -0
- package/dist/plugins/process/infrastructure/pipe-process-backend.d.ts +8 -0
- package/dist/plugins/process/infrastructure/pipe-process-backend.js +61 -0
- package/dist/plugins/process/infrastructure/pty-process-backend.d.ts +8 -0
- package/dist/plugins/process/infrastructure/pty-process-backend.js +50 -0
- package/dist/plugins/process/mcp.d.ts +4 -0
- package/dist/plugins/process/mcp.js +69 -0
- package/dist/plugins/process/plugin.d.ts +4 -0
- package/dist/plugins/process/plugin.js +28 -0
- package/dist/plugins/process/process-supervisor.d.ts +36 -0
- package/dist/plugins/process/process-supervisor.js +232 -0
- package/dist/plugins/process/types.d.ts +34 -0
- package/dist/plugins/process/types.js +1 -0
- package/dist/plugins/types.d.ts +32 -0
- package/dist/plugins/types.js +17 -0
- package/dist/plugins/web/api-types.d.ts +10 -0
- package/dist/plugins/web/api-types.js +1 -0
- package/dist/plugins/web/http/api-router.d.ts +13 -0
- package/dist/plugins/web/http/api-router.js +146 -0
- package/dist/plugins/web/http/cloud-api-router.d.ts +4 -0
- package/dist/plugins/web/http/cloud-api-router.js +41 -0
- package/dist/plugins/web/http/process-api-router.d.ts +3 -0
- package/dist/plugins/web/http/process-api-router.js +16 -0
- package/dist/plugins/web/http/workspace-api-router.d.ts +3 -0
- package/dist/plugins/web/http/workspace-api-router.js +82 -0
- package/dist/plugins/web/plugin.d.ts +8 -0
- package/dist/plugins/web/plugin.js +17 -0
- package/dist/plugins/web/runtime.d.ts +36 -0
- package/dist/plugins/web/runtime.js +66 -0
- package/dist/plugins/workspace/domain/filesystem.d.ts +25 -0
- package/dist/plugins/workspace/domain/filesystem.js +1 -0
- package/dist/plugins/workspace/domain/git.d.ts +7 -0
- package/dist/plugins/workspace/domain/git.js +1 -0
- package/dist/plugins/workspace/domain/repository.d.ts +16 -0
- package/dist/plugins/workspace/domain/repository.js +1 -0
- package/dist/plugins/workspace/domain/review.d.ts +28 -0
- package/dist/plugins/workspace/domain/review.js +1 -0
- package/dist/plugins/workspace/domain/workspace.d.ts +23 -0
- package/dist/plugins/workspace/domain/workspace.js +1 -0
- package/dist/plugins/workspace/git/git-command-runner.d.ts +6 -0
- package/dist/plugins/workspace/git/git-command-runner.js +15 -0
- package/dist/plugins/workspace/git/git-service.d.ts +14 -0
- package/dist/plugins/workspace/git/git-service.js +107 -0
- package/dist/plugins/workspace/git/git-snapshot-service.d.ts +33 -0
- package/dist/plugins/workspace/git/git-snapshot-service.js +352 -0
- package/dist/plugins/workspace/infrastructure/workspace-fs.d.ts +38 -0
- package/dist/plugins/workspace/infrastructure/workspace-fs.js +340 -0
- package/dist/plugins/workspace/infrastructure/workspace-repository.d.ts +12 -0
- package/dist/plugins/workspace/infrastructure/workspace-repository.js +51 -0
- package/dist/plugins/workspace/mcp.d.ts +4 -0
- package/dist/plugins/workspace/mcp.js +168 -0
- package/dist/plugins/workspace/plugin.d.ts +10 -0
- package/dist/plugins/workspace/plugin.js +37 -0
- package/dist/plugins/workspace/workspace-helpers.d.ts +9 -0
- package/dist/plugins/workspace/workspace-helpers.js +67 -0
- package/dist/plugins/workspace/workspace-service.d.ts +46 -0
- package/dist/plugins/workspace/workspace-service.js +339 -0
- package/dist/plugins/workspace/worktree-review-service.d.ts +16 -0
- package/dist/plugins/workspace/worktree-review-service.js +51 -0
- package/dist/presentation/http/http-utils.d.ts +7 -0
- package/dist/presentation/http/http-utils.js +47 -0
- package/dist/presentation/http/oauth-router.d.ts +5 -0
- package/dist/presentation/http/oauth-router.js +163 -0
- package/dist/web/assets/chatroom-B5p-5n3y.svg +15 -0
- package/dist/web/assets/index-BF_mWl1D.js +26 -0
- package/dist/web/assets/index-JPZut9tc.css +1 -0
- package/dist/web/assets/materialdesignicons-webfont-B7mPwVP_.ttf +0 -0
- package/dist/web/assets/materialdesignicons-webfont-CSr8KVlo.eot +0 -0
- package/dist/web/assets/materialdesignicons-webfont-Dp5v-WZN.woff2 +0 -0
- package/dist/web/assets/materialdesignicons-webfont-PXm3-2wK.woff +0 -0
- package/dist/web/index.html +19 -0
- package/package.json +77 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-scoped filesystem implementation and the primary path-containment boundary for ChatRoom file tools.
|
|
3
|
+
* It rejects absolute/traversal paths and prevents reads or writes that escape through symbolic links.
|
|
4
|
+
*/
|
|
5
|
+
import { lstat, mkdir, open, readdir, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { ChatRoomError } from "../../../core/errors/chatroom-error.js";
|
|
9
|
+
export class WorkspaceFs {
|
|
10
|
+
root;
|
|
11
|
+
realRoot;
|
|
12
|
+
constructor(root, realRoot) {
|
|
13
|
+
this.root = root;
|
|
14
|
+
this.realRoot = realRoot;
|
|
15
|
+
}
|
|
16
|
+
static async create(root) {
|
|
17
|
+
const resolved = path.resolve(root);
|
|
18
|
+
const canonical = await realpath(resolved).catch(() => {
|
|
19
|
+
throw new ChatRoomError("NOT_FOUND", `Workspace root does not exist: ${root}`);
|
|
20
|
+
});
|
|
21
|
+
const info = await stat(canonical);
|
|
22
|
+
if (!info.isDirectory())
|
|
23
|
+
throw new ChatRoomError("INVALID_INPUT", `Workspace root is not a directory: ${root}`);
|
|
24
|
+
return new WorkspaceFs(resolved, canonical);
|
|
25
|
+
}
|
|
26
|
+
async read(relativePath, options = {}) {
|
|
27
|
+
const result = await this.readBytes(relativePath, options);
|
|
28
|
+
return {
|
|
29
|
+
content: result.data.toString("utf8"),
|
|
30
|
+
bytes: result.bytes,
|
|
31
|
+
truncated: result.truncated,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async readBytes(relativePath, options = {}) {
|
|
35
|
+
const target = await this.resolveReadable(relativePath);
|
|
36
|
+
const info = await stat(target);
|
|
37
|
+
if (!info.isFile())
|
|
38
|
+
throw new ChatRoomError("INVALID_INPUT", `Not a file: ${relativePath}`);
|
|
39
|
+
const maxBytes = options.maxBytes ?? 1024 * 1024;
|
|
40
|
+
const handle = await open(target, "r");
|
|
41
|
+
try {
|
|
42
|
+
const size = Math.min(info.size, maxBytes);
|
|
43
|
+
const buffer = Buffer.alloc(size);
|
|
44
|
+
const result = await handle.read(buffer, 0, size, 0);
|
|
45
|
+
return {
|
|
46
|
+
data: buffer.subarray(0, result.bytesRead),
|
|
47
|
+
bytes: info.size,
|
|
48
|
+
truncated: info.size > maxBytes,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await handle.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async write(relativePath, content) {
|
|
56
|
+
const target = await this.resolveWritable(relativePath, true);
|
|
57
|
+
let mode = 0o644;
|
|
58
|
+
try {
|
|
59
|
+
mode = (await lstat(target)).mode & 0o777;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error.code !== "ENOENT")
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
// Write beside the destination and rename atomically so readers never observe partially written content.
|
|
66
|
+
const temp = `${target}.chatroom-${randomUUID()}.tmp`;
|
|
67
|
+
await writeFile(temp, content, { flag: "wx", mode });
|
|
68
|
+
try {
|
|
69
|
+
await rename(temp, target);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
path: this.normalizeRelative(relativePath),
|
|
76
|
+
bytes: Buffer.byteLength(content),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async list(relativePath = ".", options = {}) {
|
|
80
|
+
const start = await this.resolveReadable(relativePath);
|
|
81
|
+
const maxEntries = Math.min(options.maxEntries ?? 1000, 10000);
|
|
82
|
+
const output = [];
|
|
83
|
+
const walk = async (absolute) => {
|
|
84
|
+
for (const entry of await readdir(absolute, { withFileTypes: true })) {
|
|
85
|
+
if (output.length >= maxEntries)
|
|
86
|
+
return;
|
|
87
|
+
const absoluteEntry = path.join(absolute, entry.name);
|
|
88
|
+
const relative = path
|
|
89
|
+
.relative(this.realRoot, absoluteEntry)
|
|
90
|
+
.split(path.sep)
|
|
91
|
+
.join("/");
|
|
92
|
+
const info = await lstat(absoluteEntry);
|
|
93
|
+
const type = info.isSymbolicLink()
|
|
94
|
+
? "symlink"
|
|
95
|
+
: info.isDirectory()
|
|
96
|
+
? "directory"
|
|
97
|
+
: "file";
|
|
98
|
+
output.push({
|
|
99
|
+
path: relative,
|
|
100
|
+
type,
|
|
101
|
+
size: info.size,
|
|
102
|
+
modifiedAt: info.mtime.toISOString(),
|
|
103
|
+
});
|
|
104
|
+
// Never recurse through symlinks; their targets may point outside the workspace.
|
|
105
|
+
if (options.recursive && entry.isDirectory() && !entry.isSymbolicLink())
|
|
106
|
+
await walk(absoluteEntry);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const startInfo = await stat(start);
|
|
110
|
+
if (!startInfo.isDirectory())
|
|
111
|
+
throw new ChatRoomError("INVALID_INPUT", `Not a directory: ${relativePath}`);
|
|
112
|
+
await walk(start);
|
|
113
|
+
return output;
|
|
114
|
+
}
|
|
115
|
+
async search(query, options = {}) {
|
|
116
|
+
if (!query)
|
|
117
|
+
throw new ChatRoomError("INVALID_INPUT", "Search query must not be empty");
|
|
118
|
+
const maxResults = Math.min(options.maxResults ?? 200, 2000);
|
|
119
|
+
const files = await this.list(options.path ?? ".", {
|
|
120
|
+
recursive: true,
|
|
121
|
+
maxEntries: 10000,
|
|
122
|
+
});
|
|
123
|
+
const needle = query.toLocaleLowerCase();
|
|
124
|
+
const results = [];
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
if (results.length >= maxResults ||
|
|
127
|
+
file.type !== "file" ||
|
|
128
|
+
file.size > 2 * 1024 * 1024)
|
|
129
|
+
continue;
|
|
130
|
+
const data = await this.read(file.path, { maxBytes: 2 * 1024 * 1024 });
|
|
131
|
+
if (data.content.includes("\0"))
|
|
132
|
+
continue;
|
|
133
|
+
const lines = data.content.split(/\r?\n/);
|
|
134
|
+
for (let lineIndex = 0; lineIndex < lines.length && results.length < maxResults; lineIndex++) {
|
|
135
|
+
const line = lines[lineIndex] ?? "";
|
|
136
|
+
const haystack = line.toLocaleLowerCase();
|
|
137
|
+
const column = haystack.indexOf(needle);
|
|
138
|
+
if (column !== -1)
|
|
139
|
+
results.push({
|
|
140
|
+
path: file.path,
|
|
141
|
+
line: lineIndex + 1,
|
|
142
|
+
column: column + 1,
|
|
143
|
+
preview: line.slice(0, 500),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return results;
|
|
148
|
+
}
|
|
149
|
+
async patch(replacements) {
|
|
150
|
+
if (!replacements.length)
|
|
151
|
+
throw new ChatRoomError("INVALID_INPUT", "Patch must contain at least one replacement");
|
|
152
|
+
const grouped = new Map();
|
|
153
|
+
for (const replacement of replacements) {
|
|
154
|
+
if (!replacement.oldText)
|
|
155
|
+
throw new ChatRoomError("INVALID_INPUT", `oldText must not be empty: ${replacement.path}`);
|
|
156
|
+
const key = this.normalizeRelative(replacement.path);
|
|
157
|
+
grouped.set(key, [...(grouped.get(key) ?? []), replacement]);
|
|
158
|
+
}
|
|
159
|
+
// Phase 1 prepares every file completely before any destination is changed.
|
|
160
|
+
const prepared = [];
|
|
161
|
+
let replacementCount = 0;
|
|
162
|
+
let bytesBefore = 0;
|
|
163
|
+
let bytesAfter = 0;
|
|
164
|
+
for (const [relative, edits] of grouped) {
|
|
165
|
+
const target = await this.resolveWritable(relative, false);
|
|
166
|
+
const mode = (await lstat(target)).mode & 0o777;
|
|
167
|
+
const before = (await this.read(relative, { maxBytes: 64 * 1024 * 1024 }))
|
|
168
|
+
.content;
|
|
169
|
+
let after = before;
|
|
170
|
+
for (const edit of edits) {
|
|
171
|
+
const occurrences = countOccurrences(after, edit.oldText);
|
|
172
|
+
if (occurrences === 0)
|
|
173
|
+
throw new ChatRoomError("CONFLICT", `oldText not found in ${relative}`);
|
|
174
|
+
if (edit.occurrence === "all") {
|
|
175
|
+
after = after.split(edit.oldText).join(edit.newText);
|
|
176
|
+
replacementCount += occurrences;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
const occurrence = edit.occurrence ?? 1;
|
|
180
|
+
if (occurrence < 1 || occurrence > occurrences)
|
|
181
|
+
throw new ChatRoomError("CONFLICT", `Occurrence ${occurrence} not found in ${relative}`, { occurrences });
|
|
182
|
+
after = replaceOccurrence(after, edit.oldText, edit.newText, occurrence);
|
|
183
|
+
replacementCount += 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
bytesBefore += Buffer.byteLength(before);
|
|
187
|
+
bytesAfter += Buffer.byteLength(after);
|
|
188
|
+
const id = randomUUID();
|
|
189
|
+
prepared.push({
|
|
190
|
+
relative,
|
|
191
|
+
target,
|
|
192
|
+
after,
|
|
193
|
+
temp: `${target}.chatroom-${id}.tmp`,
|
|
194
|
+
backup: `${target}.chatroom-${id}.bak`,
|
|
195
|
+
mode,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
for (const item of prepared)
|
|
200
|
+
await writeFile(item.temp, item.after, { flag: "wx", mode: item.mode });
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
for (const item of prepared)
|
|
204
|
+
await rm(item.temp, { force: true }).catch(() => undefined);
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
// Phase 2 commits with backups so already-swapped files can be restored if a later rename fails.
|
|
208
|
+
const committed = [];
|
|
209
|
+
try {
|
|
210
|
+
for (const item of prepared) {
|
|
211
|
+
await rename(item.target, item.backup);
|
|
212
|
+
try {
|
|
213
|
+
await rename(item.temp, item.target);
|
|
214
|
+
committed.push(item);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
await rename(item.backup, item.target).catch(() => undefined);
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
for (const item of committed.reverse()) {
|
|
224
|
+
await rm(item.target, { force: true }).catch(() => undefined);
|
|
225
|
+
await rename(item.backup, item.target).catch(() => undefined);
|
|
226
|
+
}
|
|
227
|
+
throw new ChatRoomError("INTERNAL", "Patch commit failed and was rolled back", undefined, {
|
|
228
|
+
cause: error,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
for (const item of prepared) {
|
|
233
|
+
await rm(item.temp, { force: true }).catch(() => undefined);
|
|
234
|
+
await rm(item.backup, { force: true }).catch(() => undefined);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
files: prepared.map((item) => item.relative),
|
|
239
|
+
replacements: replacementCount,
|
|
240
|
+
bytesBefore,
|
|
241
|
+
bytesAfter,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
normalizeRelative(input) {
|
|
245
|
+
// Check both host-native and Windows path syntax so a path cannot become dangerous when moved across platforms.
|
|
246
|
+
if (typeof input !== "string" || input.includes("\0"))
|
|
247
|
+
throw new ChatRoomError("INVALID_INPUT", "Path must be a valid string");
|
|
248
|
+
if (path.isAbsolute(input) ||
|
|
249
|
+
path.win32.isAbsolute(input) ||
|
|
250
|
+
/^\\\\/.test(input))
|
|
251
|
+
throw new ChatRoomError("FORBIDDEN", "Absolute paths are not allowed");
|
|
252
|
+
const parts = input
|
|
253
|
+
.split(/[\\/]+/)
|
|
254
|
+
.filter((part) => part !== "" && part !== ".");
|
|
255
|
+
if (parts.some((part) => part === ".."))
|
|
256
|
+
throw new ChatRoomError("FORBIDDEN", "Path traversal is not allowed");
|
|
257
|
+
return parts.join("/") || ".";
|
|
258
|
+
}
|
|
259
|
+
lexical(relativePath) {
|
|
260
|
+
const normalized = this.normalizeRelative(relativePath);
|
|
261
|
+
const absolute = path.resolve(this.realRoot, ...normalized.split("/"));
|
|
262
|
+
if (!inside(this.realRoot, absolute))
|
|
263
|
+
throw new ChatRoomError("FORBIDDEN", "Path escapes workspace");
|
|
264
|
+
return absolute;
|
|
265
|
+
}
|
|
266
|
+
async resolveReadable(relativePath) {
|
|
267
|
+
const lexical = this.lexical(relativePath);
|
|
268
|
+
const canonical = await realpath(lexical).catch((error) => {
|
|
269
|
+
if (error.code === "ENOENT")
|
|
270
|
+
throw new ChatRoomError("NOT_FOUND", `Path does not exist: ${relativePath}`);
|
|
271
|
+
throw error;
|
|
272
|
+
});
|
|
273
|
+
// realpath resolves symlinks before containment is checked against the canonical workspace root.
|
|
274
|
+
if (!inside(this.realRoot, canonical))
|
|
275
|
+
throw new ChatRoomError("FORBIDDEN", "Symlink escapes workspace");
|
|
276
|
+
return canonical;
|
|
277
|
+
}
|
|
278
|
+
async resolveWritable(relativePath, createParents) {
|
|
279
|
+
const target = this.lexical(relativePath);
|
|
280
|
+
const relative = path.relative(this.realRoot, target);
|
|
281
|
+
const parts = relative.split(path.sep).filter(Boolean);
|
|
282
|
+
let current = this.realRoot;
|
|
283
|
+
// Walk each existing parent explicitly so writes cannot traverse a symlinked directory.
|
|
284
|
+
for (let index = 0; index < parts.length - 1; index++) {
|
|
285
|
+
current = path.join(current, parts[index]);
|
|
286
|
+
try {
|
|
287
|
+
const info = await lstat(current);
|
|
288
|
+
if (info.isSymbolicLink())
|
|
289
|
+
throw new ChatRoomError("FORBIDDEN", `Writes through symlinked directories are not allowed: ${relativePath}`);
|
|
290
|
+
if (!info.isDirectory())
|
|
291
|
+
throw new ChatRoomError("INVALID_INPUT", `Parent is not a directory: ${parts[index]}`);
|
|
292
|
+
const canonical = await realpath(current);
|
|
293
|
+
if (!inside(this.realRoot, canonical))
|
|
294
|
+
throw new ChatRoomError("FORBIDDEN", "Write path escapes workspace");
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
if (error instanceof ChatRoomError)
|
|
298
|
+
throw error;
|
|
299
|
+
if (error.code !== "ENOENT")
|
|
300
|
+
throw error;
|
|
301
|
+
if (!createParents)
|
|
302
|
+
throw new ChatRoomError("NOT_FOUND", `Parent directory does not exist: ${relativePath}`);
|
|
303
|
+
await mkdir(current, { recursive: false, mode: 0o700 });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const targetInfo = await lstat(target);
|
|
308
|
+
if (targetInfo.isSymbolicLink())
|
|
309
|
+
throw new ChatRoomError("FORBIDDEN", `Writes through symlinks are not allowed: ${relativePath}`);
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (error instanceof ChatRoomError)
|
|
313
|
+
throw error;
|
|
314
|
+
if (error.code !== "ENOENT")
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
return target;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function inside(root, candidate) {
|
|
321
|
+
const rel = path.relative(root, candidate);
|
|
322
|
+
return (rel === "" ||
|
|
323
|
+
(!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel)));
|
|
324
|
+
}
|
|
325
|
+
function countOccurrences(text, needle) {
|
|
326
|
+
let count = 0, index = 0;
|
|
327
|
+
while ((index = text.indexOf(needle, index)) !== -1) {
|
|
328
|
+
count++;
|
|
329
|
+
index += needle.length;
|
|
330
|
+
}
|
|
331
|
+
return count;
|
|
332
|
+
}
|
|
333
|
+
function replaceOccurrence(text, needle, replacement, occurrence) {
|
|
334
|
+
let index = -1, from = 0;
|
|
335
|
+
for (let i = 0; i < occurrence; i++) {
|
|
336
|
+
index = text.indexOf(needle, from);
|
|
337
|
+
from = index + needle.length;
|
|
338
|
+
}
|
|
339
|
+
return `${text.slice(0, index)}${replacement}${text.slice(index + needle.length)}`;
|
|
340
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { WorkspaceRecord, WorkspaceStateRepository } from "../domain/repository.js";
|
|
2
|
+
import type { WorkspaceMode } from "../domain/workspace.js";
|
|
3
|
+
import type { AppDatabase } from "../../../infrastructure/database/app-database.js";
|
|
4
|
+
export declare class WorkspaceRepository implements WorkspaceStateRepository {
|
|
5
|
+
private readonly database;
|
|
6
|
+
constructor(database: AppDatabase);
|
|
7
|
+
upsert(workspace: WorkspaceRecord): void;
|
|
8
|
+
get(workspaceId: string): WorkspaceRecord | null;
|
|
9
|
+
findByRoot(root: string, mode: WorkspaceMode): WorkspaceRecord | null;
|
|
10
|
+
list(): WorkspaceRecord[];
|
|
11
|
+
remove(workspaceId: string): void;
|
|
12
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export class WorkspaceRepository {
|
|
2
|
+
database;
|
|
3
|
+
constructor(database) {
|
|
4
|
+
this.database = database;
|
|
5
|
+
}
|
|
6
|
+
upsert(workspace) {
|
|
7
|
+
this.database.raw
|
|
8
|
+
.prepare(`
|
|
9
|
+
INSERT INTO workspaces(id,root,source_root,mode,created_at,last_used_at)
|
|
10
|
+
VALUES(?,?,?,?,?,?)
|
|
11
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
12
|
+
root=excluded.root,
|
|
13
|
+
source_root=excluded.source_root,
|
|
14
|
+
mode=excluded.mode,
|
|
15
|
+
last_used_at=excluded.last_used_at
|
|
16
|
+
`)
|
|
17
|
+
.run(workspace.id, workspace.root, workspace.sourceRoot, workspace.mode, workspace.createdAt, workspace.lastUsedAt);
|
|
18
|
+
}
|
|
19
|
+
get(workspaceId) {
|
|
20
|
+
const row = this.database.raw
|
|
21
|
+
.prepare("SELECT * FROM workspaces WHERE id=?")
|
|
22
|
+
.get(workspaceId);
|
|
23
|
+
return row ? fromRow(row) : null;
|
|
24
|
+
}
|
|
25
|
+
findByRoot(root, mode) {
|
|
26
|
+
const row = this.database.raw
|
|
27
|
+
.prepare("SELECT * FROM workspaces WHERE root=? AND mode=?")
|
|
28
|
+
.get(root, mode);
|
|
29
|
+
return row ? fromRow(row) : null;
|
|
30
|
+
}
|
|
31
|
+
list() {
|
|
32
|
+
return this.database.raw
|
|
33
|
+
.prepare("SELECT * FROM workspaces ORDER BY last_used_at DESC")
|
|
34
|
+
.all().map(fromRow);
|
|
35
|
+
}
|
|
36
|
+
remove(workspaceId) {
|
|
37
|
+
this.database.raw
|
|
38
|
+
.prepare("DELETE FROM workspaces WHERE id=?")
|
|
39
|
+
.run(workspaceId);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function fromRow(row) {
|
|
43
|
+
return {
|
|
44
|
+
id: row.id,
|
|
45
|
+
root: row.root,
|
|
46
|
+
sourceRoot: row.source_root,
|
|
47
|
+
mode: row.mode,
|
|
48
|
+
createdAt: row.created_at,
|
|
49
|
+
lastUsedAt: row.last_used_at,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import type { OperationLog } from "../../operations/operation-log.js";
|
|
3
|
+
import type { WorkspaceService } from "./workspace-service.js";
|
|
4
|
+
export declare function registerWorkspaceTools(server: McpServer, workspaces: WorkspaceService, operations: OperationLog): void;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { changeSetSchema, closedRead, destructiveLocalMutation, fileInfoSchema, fileReadOutputSchema, fileWriteOutputSchema, localMutation, localWrite, mcpTool, searchMatchSchema, workspaceOutputSchema, } from "../../mcp/server/tool-support.js";
|
|
3
|
+
export function registerWorkspaceTools(server, workspaces, operations) {
|
|
4
|
+
server.registerTool("open_workspace", {
|
|
5
|
+
title: "Open workspace",
|
|
6
|
+
description: "Open or reuse an approved checkout, or create a new isolated managed Git worktree from the source checkout HEAD.",
|
|
7
|
+
inputSchema: z.object({
|
|
8
|
+
path: z.string(),
|
|
9
|
+
mode: z.enum(["checkout", "worktree"]).optional(),
|
|
10
|
+
}),
|
|
11
|
+
outputSchema: workspaceOutputSchema,
|
|
12
|
+
annotations: localMutation,
|
|
13
|
+
}, mcpTool((input) => operations.run({ pluginId: "workspace", source: "mcp", action: "open", input }, () => workspaces.open({
|
|
14
|
+
path: input.path,
|
|
15
|
+
...(input.mode ? { mode: input.mode } : {}),
|
|
16
|
+
}))));
|
|
17
|
+
server.registerTool("remove_workspace", {
|
|
18
|
+
title: "Remove workspace",
|
|
19
|
+
description: "Unregister a checkout workspace or remove a managed worktree. Dirty worktrees require force=true.",
|
|
20
|
+
inputSchema: z.object({
|
|
21
|
+
workspaceId: z.string(),
|
|
22
|
+
force: z.boolean().optional(),
|
|
23
|
+
}),
|
|
24
|
+
outputSchema: z.object({
|
|
25
|
+
removed: z.boolean(),
|
|
26
|
+
workspaceId: z.string(),
|
|
27
|
+
mode: z.enum(["checkout", "worktree"]),
|
|
28
|
+
}),
|
|
29
|
+
annotations: destructiveLocalMutation,
|
|
30
|
+
}, mcpTool(async (input) => {
|
|
31
|
+
const removed = await operations.run({
|
|
32
|
+
pluginId: "workspace",
|
|
33
|
+
source: "mcp",
|
|
34
|
+
action: "remove",
|
|
35
|
+
workspaceId: input.workspaceId,
|
|
36
|
+
input,
|
|
37
|
+
}, () => workspaces.remove(input.workspaceId, input.force ?? false));
|
|
38
|
+
return { removed: true, workspaceId: removed.id, mode: removed.mode };
|
|
39
|
+
}));
|
|
40
|
+
server.registerTool("fs_read", {
|
|
41
|
+
title: "Read file",
|
|
42
|
+
description: "Read a workspace-relative file through the ChatRoom filesystem boundary.",
|
|
43
|
+
inputSchema: z.object({
|
|
44
|
+
workspaceId: z.string(),
|
|
45
|
+
path: z.string(),
|
|
46
|
+
maxBytes: z
|
|
47
|
+
.number()
|
|
48
|
+
.int()
|
|
49
|
+
.positive()
|
|
50
|
+
.max(16 * 1024 * 1024)
|
|
51
|
+
.optional(),
|
|
52
|
+
}),
|
|
53
|
+
outputSchema: fileReadOutputSchema,
|
|
54
|
+
annotations: closedRead,
|
|
55
|
+
}, mcpTool(async (input) => {
|
|
56
|
+
const fs = await workspaces.fs(input.workspaceId);
|
|
57
|
+
return operations.run({
|
|
58
|
+
pluginId: "workspace",
|
|
59
|
+
source: "mcp",
|
|
60
|
+
action: "fs.read",
|
|
61
|
+
workspaceId: input.workspaceId,
|
|
62
|
+
input,
|
|
63
|
+
}, () => fs.read(input.path, input.maxBytes === undefined ? {} : { maxBytes: input.maxBytes }));
|
|
64
|
+
}));
|
|
65
|
+
server.registerTool("fs_write", {
|
|
66
|
+
title: "Write file",
|
|
67
|
+
description: "Atomically write a workspace-relative file.",
|
|
68
|
+
inputSchema: z.object({
|
|
69
|
+
workspaceId: z.string(),
|
|
70
|
+
path: z.string(),
|
|
71
|
+
content: z.string(),
|
|
72
|
+
}),
|
|
73
|
+
outputSchema: fileWriteOutputSchema,
|
|
74
|
+
annotations: localWrite,
|
|
75
|
+
}, mcpTool(async (input) => {
|
|
76
|
+
const fs = await workspaces.fs(input.workspaceId);
|
|
77
|
+
return operations.run({
|
|
78
|
+
pluginId: "workspace",
|
|
79
|
+
source: "mcp",
|
|
80
|
+
action: "fs.write",
|
|
81
|
+
workspaceId: input.workspaceId,
|
|
82
|
+
input,
|
|
83
|
+
}, () => fs.write(input.path, input.content));
|
|
84
|
+
}));
|
|
85
|
+
server.registerTool("fs_list", {
|
|
86
|
+
title: "List files",
|
|
87
|
+
description: "List files under a workspace-relative directory.",
|
|
88
|
+
inputSchema: z.object({
|
|
89
|
+
workspaceId: z.string(),
|
|
90
|
+
path: z.string().default("."),
|
|
91
|
+
recursive: z.boolean().default(false),
|
|
92
|
+
}),
|
|
93
|
+
outputSchema: z.object({ files: z.array(fileInfoSchema) }),
|
|
94
|
+
annotations: closedRead,
|
|
95
|
+
}, mcpTool(async (input) => {
|
|
96
|
+
const fs = await workspaces.fs(input.workspaceId);
|
|
97
|
+
return {
|
|
98
|
+
files: await operations.run({
|
|
99
|
+
pluginId: "workspace",
|
|
100
|
+
source: "mcp",
|
|
101
|
+
action: "fs.list",
|
|
102
|
+
workspaceId: input.workspaceId,
|
|
103
|
+
input,
|
|
104
|
+
}, () => fs.list(input.path, { recursive: input.recursive })),
|
|
105
|
+
};
|
|
106
|
+
}));
|
|
107
|
+
server.registerTool("fs_search", {
|
|
108
|
+
title: "Search files",
|
|
109
|
+
description: "Search text under a workspace.",
|
|
110
|
+
inputSchema: z.object({
|
|
111
|
+
workspaceId: z.string(),
|
|
112
|
+
query: z.string().min(1),
|
|
113
|
+
path: z.string().default("."),
|
|
114
|
+
maxResults: z.number().int().positive().max(2000).default(200),
|
|
115
|
+
}),
|
|
116
|
+
outputSchema: z.object({ matches: z.array(searchMatchSchema) }),
|
|
117
|
+
annotations: closedRead,
|
|
118
|
+
}, mcpTool(async (input) => {
|
|
119
|
+
const fs = await workspaces.fs(input.workspaceId);
|
|
120
|
+
return {
|
|
121
|
+
matches: await operations.run({
|
|
122
|
+
pluginId: "workspace",
|
|
123
|
+
source: "mcp",
|
|
124
|
+
action: "fs.search",
|
|
125
|
+
workspaceId: input.workspaceId,
|
|
126
|
+
input,
|
|
127
|
+
}, () => fs.search(input.query, {
|
|
128
|
+
path: input.path,
|
|
129
|
+
maxResults: input.maxResults,
|
|
130
|
+
})),
|
|
131
|
+
};
|
|
132
|
+
}));
|
|
133
|
+
server.registerTool("fs_patch", {
|
|
134
|
+
title: "Transactional patch",
|
|
135
|
+
description: "Validate all replacements before committing a multi-file text patch.",
|
|
136
|
+
inputSchema: z.object({
|
|
137
|
+
workspaceId: z.string(),
|
|
138
|
+
replacements: z
|
|
139
|
+
.array(z.object({
|
|
140
|
+
path: z.string(),
|
|
141
|
+
oldText: z.string().min(1),
|
|
142
|
+
newText: z.string(),
|
|
143
|
+
occurrence: z
|
|
144
|
+
.union([z.number().int().positive(), z.literal("all")])
|
|
145
|
+
.optional(),
|
|
146
|
+
}))
|
|
147
|
+
.min(1),
|
|
148
|
+
}),
|
|
149
|
+
outputSchema: changeSetSchema,
|
|
150
|
+
annotations: destructiveLocalMutation,
|
|
151
|
+
}, mcpTool(async (input) => {
|
|
152
|
+
const fs = await workspaces.fs(input.workspaceId);
|
|
153
|
+
return operations.run({
|
|
154
|
+
pluginId: "workspace",
|
|
155
|
+
source: "mcp",
|
|
156
|
+
action: "fs.patch",
|
|
157
|
+
workspaceId: input.workspaceId,
|
|
158
|
+
input,
|
|
159
|
+
}, () => fs.patch(input.replacements.map((item) => ({
|
|
160
|
+
path: item.path,
|
|
161
|
+
oldText: item.oldText,
|
|
162
|
+
newText: item.newText,
|
|
163
|
+
...(item.occurrence === undefined
|
|
164
|
+
? {}
|
|
165
|
+
: { occurrence: item.occurrence }),
|
|
166
|
+
}))));
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { InternalPlugin } from "../types.js";
|
|
2
|
+
import { GitService } from "./git/git-service.js";
|
|
3
|
+
import { WorkspaceService as WorkspaceRuntime } from "./workspace-service.js";
|
|
4
|
+
interface WorkspacePluginService {
|
|
5
|
+
workspaces: WorkspaceRuntime;
|
|
6
|
+
git: GitService;
|
|
7
|
+
}
|
|
8
|
+
export declare const WorkspaceService: import("../types.js").ServiceToken<WorkspacePluginService>;
|
|
9
|
+
export declare function createWorkspacePlugin(): InternalPlugin;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { CommandRunner } from "../../core/runtime/command-runner.js";
|
|
2
|
+
import { createServiceToken } from "../types.js";
|
|
3
|
+
import { GitCommandRunner } from "./git/git-command-runner.js";
|
|
4
|
+
import { GitService } from "./git/git-service.js";
|
|
5
|
+
import { GitSnapshotService } from "./git/git-snapshot-service.js";
|
|
6
|
+
import { WorkspaceRepository } from "./infrastructure/workspace-repository.js";
|
|
7
|
+
import { registerWorkspaceTools } from "./mcp.js";
|
|
8
|
+
import { WorkspaceService as WorkspaceRuntime } from "./workspace-service.js";
|
|
9
|
+
import { WorktreeReviewService } from "./worktree-review-service.js";
|
|
10
|
+
export const WorkspaceService = createServiceToken("workspace");
|
|
11
|
+
export function createWorkspacePlugin() {
|
|
12
|
+
let value = null;
|
|
13
|
+
let operations = null;
|
|
14
|
+
return {
|
|
15
|
+
id: "workspace",
|
|
16
|
+
async activate(context) {
|
|
17
|
+
const gitRunner = new GitCommandRunner(new CommandRunner());
|
|
18
|
+
const git = new GitService(gitRunner);
|
|
19
|
+
const snapshots = new GitSnapshotService(gitRunner, git);
|
|
20
|
+
const review = new WorktreeReviewService(git, snapshots);
|
|
21
|
+
const workspaces = await WorkspaceRuntime.create(new WorkspaceRepository(context.database), git, review, context.config.allowedRoots, context.config.dataDir);
|
|
22
|
+
await workspaces.discoverGitWorkspaces();
|
|
23
|
+
value = { workspaces, git };
|
|
24
|
+
operations = context.operations;
|
|
25
|
+
context.services.provide(WorkspaceService, value);
|
|
26
|
+
},
|
|
27
|
+
registerMcp(server) {
|
|
28
|
+
if (!value || !operations)
|
|
29
|
+
throw new Error("Workspace plugin is not active");
|
|
30
|
+
registerWorkspaceTools(server, value.workspaces, operations);
|
|
31
|
+
},
|
|
32
|
+
deactivate() {
|
|
33
|
+
value = null;
|
|
34
|
+
operations = null;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { WorkspaceFs } from "./infrastructure/workspace-fs.js";
|
|
2
|
+
import type { WorkspaceRecord } from "./domain/repository.js";
|
|
3
|
+
import type { Workspace } from "./domain/workspace.js";
|
|
4
|
+
export declare function discoverInstructions(fs: WorkspaceFs): Promise<string[]>;
|
|
5
|
+
export declare function discoverSkills(fs: WorkspaceFs): Promise<string[]>;
|
|
6
|
+
export declare function expandHome(input: string): string;
|
|
7
|
+
export declare function inside(root: string, candidate: string): boolean;
|
|
8
|
+
export declare function exists(target: string): Promise<boolean>;
|
|
9
|
+
export declare function toRecord(workspace: Workspace): WorkspaceRecord;
|