@kammcs/buildit-mcp 0.8.0 → 0.10.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.
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Sharing local files in a reply: the checks, then the upload, then one post.
3
+ *
4
+ * 1. **Checks, before any network call**, for every file: it resolves (its
5
+ * real path, symlinks followed) to a regular file inside the session's
6
+ * folder or the OS temp folder, and not in a secret folder; its name is
7
+ * not a secret store's; it is not a program (by its extension, its type
8
+ * or its first bytes); it is within the size cap for its type; and a
9
+ * text file passes the whole-file credential scan. One refusal refuses
10
+ * the reply: nothing is uploaded or posted, and the error says why.
11
+ * 2. **Location** is removed from photos and videos into a temporary copy
12
+ * (the original is never touched), as the app does for a person's. A
13
+ * video gets its length and a poster frame when ffprobe and ffmpeg are
14
+ * on the PATH (`src/files/poster.ts`); without them, neither, as before.
15
+ * 3. **Each file** is uploaded: create_session_upload (with a video's
16
+ * duration_ms and thumbnail when known), a streamed PUT with exactly the
17
+ * signed headers (no Authorization), the poster's PUT to thumbnail_put,
18
+ * complete_session_upload (nothing uploaded yet, upload_missing: the PUTs
19
+ * once more, then complete again). A refusal there stops the reply too,
20
+ * so nothing is posted.
21
+ * 4. **One post**, post_session_message with the text and the uploads.
22
+ *
23
+ * Upload ids are UUIDv5s of the session, the reply's idempotency key, the
24
+ * file's place and its real path, so a retry of the same reply neither
25
+ * uploads nor posts twice.
26
+ */
27
+ import { lstat, mkdtemp, open, readFile, realpath, rm, writeFile } from 'node:fs/promises';
28
+ import { tmpdir } from 'node:os';
29
+ import { basename, join, resolve } from 'node:path';
30
+ import { ApiError } from '../api/client.js';
31
+ import { callOperation } from '../api/operations.js';
32
+ import { ToolInputError } from '../errors.js';
33
+ import { copyWithPatches, isHeifContainer, isMovie, LocationUnreadableError, stripLocation, videoLocationPatches, } from '../files/location.js';
34
+ import { locationProblem } from '../files/paths.js';
35
+ import { videoFacts } from '../files/poster.js';
36
+ import { describeFinding, scanFileForCredentials } from '../files/scan.js';
37
+ import { TransferError, uploadFile } from '../files/transfer.js';
38
+ import { cleanFileName, contentTypeFor, formatMiB, isBlockedFile, isProgramHeader, isSecretName, maxBytesFor, readsAsText, sniffImage, } from '../files/types.js';
39
+ import { uuidV5 } from '../tools/ask.js';
40
+ /** The most files a reply takes (a message's set). */
41
+ export const REPLY_MAX_FILES = 10;
42
+ /** The UUIDv5 namespace of a session's upload ids. */
43
+ const UPLOAD_NAMESPACE = '6f1c2b8e-3d4a-5e9f-8b7c-1a2d3e4f5a6b';
44
+ /** How many first bytes the checks read. */
45
+ const HEAD_BYTES = 8 * 1024;
46
+ /** Reads a file's first bytes. */
47
+ async function head(path, n = HEAD_BYTES) {
48
+ const handle = await open(path, 'r');
49
+ try {
50
+ const buf = Buffer.alloc(n);
51
+ const { bytesRead } = await handle.read(buf, 0, n, 0);
52
+ return buf.subarray(0, bytesRead);
53
+ }
54
+ finally {
55
+ await handle.close();
56
+ }
57
+ }
58
+ /** The refusal of a whole reply: every file's problem, and that nothing was sent. */
59
+ function refused(problems) {
60
+ return new ToolInputError(`Not shared: ${problems.join(' ')} Nothing was uploaded or posted; fix that and send the reply again (or leave that file out).`);
61
+ }
62
+ /** Whether a file is a photo or a video whose location is looked for. */
63
+ function mediaOf(contentType, first) {
64
+ if (sniffImage(first) !== undefined || isHeifContainer(first))
65
+ return 'photo';
66
+ if (contentType.startsWith('image/') &&
67
+ (contentType === 'image/tiff' ||
68
+ contentType.endsWith('heic') ||
69
+ contentType.endsWith('heif') ||
70
+ contentType === 'image/avif')) {
71
+ return 'photo';
72
+ }
73
+ if (isMovie(first))
74
+ return 'video';
75
+ return undefined;
76
+ }
77
+ /**
78
+ * Step 1: the checks of every file, before any network call. Throws one
79
+ * ToolInputError that names each refused file and why.
80
+ */
81
+ export async function checkFiles(given, folder, signal) {
82
+ const problems = [];
83
+ const checked = [];
84
+ const seen = new Set();
85
+ for (const raw of given) {
86
+ const shown = JSON.stringify(raw.length > 200 ? `${raw.slice(0, 200)}…` : raw);
87
+ const fail = (why) => {
88
+ problems.push(`${shown}: ${why}.`);
89
+ };
90
+ let path;
91
+ try {
92
+ path = await realpath(resolve(folder, raw));
93
+ }
94
+ catch {
95
+ fail('no such file');
96
+ continue;
97
+ }
98
+ const info = await lstat(path).catch(() => undefined);
99
+ if (info === undefined || !info.isFile()) {
100
+ fail('not a regular file');
101
+ continue;
102
+ }
103
+ if (seen.has(path)) {
104
+ fail('the same file is given twice');
105
+ continue;
106
+ }
107
+ seen.add(path);
108
+ const where = await locationProblem(path, { folder });
109
+ if (where === 'secret_folder') {
110
+ fail("it's in a folder that holds credentials (such as .ssh or .aws), which are never shared");
111
+ continue;
112
+ }
113
+ if (where === 'outside_roots') {
114
+ fail("it is outside this session's folder and the temp folder; copy it into the project (or the temp folder) first if it should be shared");
115
+ continue;
116
+ }
117
+ const name = cleanFileName(basename(path));
118
+ if (isSecretName(basename(path)) || isSecretName(basename(raw))) {
119
+ fail("its name is a secret store's (.env files, keys, certificates, password databases), which are never shared");
120
+ continue;
121
+ }
122
+ const contentType = contentTypeFor(name);
123
+ if (isBlockedFile(name, contentType)) {
124
+ fail('programs and installers are never shared; share the source or a document about it instead');
125
+ continue;
126
+ }
127
+ const first = await head(path);
128
+ if (isProgramHeader(first)) {
129
+ fail('it is a program (its first bytes say so), and programs are never shared');
130
+ continue;
131
+ }
132
+ const max = maxBytesFor(contentType);
133
+ if (info.size > max) {
134
+ fail(`it is ${formatMiB(info.size)}, over the ${formatMiB(max)} limit for this type; compress or split it`);
135
+ continue;
136
+ }
137
+ if (readsAsText(contentType, first)) {
138
+ const finding = await scanFileForCredentials(path, signal);
139
+ if (finding !== undefined) {
140
+ fail(`${describeFinding(finding)}. Remove it and try again`);
141
+ continue;
142
+ }
143
+ }
144
+ checked.push({
145
+ given: raw,
146
+ path,
147
+ name,
148
+ size: info.size,
149
+ contentType,
150
+ media: mediaOf(contentType, first),
151
+ });
152
+ }
153
+ if (problems.length > 0)
154
+ throw refused(problems);
155
+ return checked;
156
+ }
157
+ /**
158
+ * Step 2: location removed from photos and videos into copies in a new
159
+ * temporary folder (returned, to be removed after). A photo or video whose
160
+ * metadata can't be read is refused, never sent with its location. Then a
161
+ * video's length and poster (from the copy without location), when ffprobe
162
+ * and ffmpeg are on the PATH; never a refusal.
163
+ */
164
+ export async function prepareFiles(files, options = {}) {
165
+ let temp;
166
+ const tempDir = async () => {
167
+ temp ??= await mkdtemp(join(tmpdir(), 'buildit-mcp-share-'));
168
+ return temp;
169
+ };
170
+ const out = [];
171
+ const problems = [];
172
+ try {
173
+ for (const [i, f] of files.entries()) {
174
+ try {
175
+ if (f.media === 'photo') {
176
+ const bytes = new Uint8Array(await readFile(f.path));
177
+ const clean = stripLocation(bytes);
178
+ if (clean !== bytes) {
179
+ const copy = join(await tempDir(), `${i}-${f.name}`);
180
+ await writeFile(copy, clean, { flag: 'wx', mode: 0o600 });
181
+ out.push({ ...f, source: copy, sourceSize: clean.length, stripped: true });
182
+ continue;
183
+ }
184
+ }
185
+ else if (f.media === 'video') {
186
+ const patches = await videoLocationPatches(f.path);
187
+ if (patches.length > 0) {
188
+ const copy = join(await tempDir(), `${i}-${f.name}`);
189
+ await copyWithPatches(f.path, copy, patches);
190
+ out.push({ ...f, source: copy, sourceSize: f.size, stripped: true });
191
+ continue;
192
+ }
193
+ }
194
+ out.push({ ...f, source: f.path, sourceSize: f.size, stripped: false });
195
+ }
196
+ catch (err) {
197
+ if (err instanceof LocationUnreadableError) {
198
+ problems.push(`${JSON.stringify(f.given)}: its metadata can't be read, so its location can't be removed; export it again (or as another format) and share that.`);
199
+ }
200
+ else {
201
+ throw err;
202
+ }
203
+ }
204
+ }
205
+ }
206
+ catch (err) {
207
+ if (temp !== undefined)
208
+ await rm(temp, { recursive: true, force: true });
209
+ throw err;
210
+ }
211
+ if (problems.length > 0) {
212
+ if (temp !== undefined)
213
+ await rm(temp, { recursive: true, force: true });
214
+ throw refused(problems);
215
+ }
216
+ try {
217
+ for (const [i, f] of out.entries()) {
218
+ if (!f.contentType.startsWith('video/'))
219
+ continue;
220
+ const facts = await videoFacts(f.source, tempDir, i, options);
221
+ f.durationMs = facts.durationMs;
222
+ f.poster = facts.poster;
223
+ }
224
+ }
225
+ catch (err) {
226
+ if (temp !== undefined)
227
+ await rm(temp, { recursive: true, force: true });
228
+ throw err;
229
+ }
230
+ return { files: out, temp };
231
+ }
232
+ /** What a refusal at upload means, for the file it names. */
233
+ function uploadRefusal(err, f, owner) {
234
+ const d = (err.details ?? {});
235
+ const name = JSON.stringify(f.given);
236
+ switch (err.code) {
237
+ case 'scope_missing':
238
+ return new ToolInputError(`Not shared: this session can't share files. Ask ${owner} to add Files permissions in Settings → Agents, then send the reply again. Nothing was posted.`);
239
+ case 'session_token_required':
240
+ return new ToolInputError('Not shared: files can be shared only from a connected session (buildit-mcp connect), not with a personal access token. Nothing was posted.');
241
+ case 'files_off':
242
+ return new ToolInputError('Not shared: files are turned off on this buildIt.Social server. Describe the file in text instead. Nothing was posted.');
243
+ case 'credential_found': {
244
+ const lines = Array.isArray(d.lines) ? d.lines : [];
245
+ const kinds = Array.isArray(d.kinds) ? d.kinds : [];
246
+ return refused([
247
+ `${name}: buildIt.Social found that ${describeFinding({ lines, kinds })}. Remove it and try again.`,
248
+ ]);
249
+ }
250
+ case 'location_found':
251
+ return refused([`${name}: buildIt.Social found location metadata left in the photo.`]);
252
+ case 'name_not_allowed':
253
+ return refused([`${name}: its name is a secret store's, which is never shared.`]);
254
+ case 'blocked_type':
255
+ return refused([`${name}: programs and installers are never shared.`]);
256
+ case 'too_large':
257
+ return refused([`${name}: it is over the size limit for its type.`]);
258
+ case 'upload_mismatch':
259
+ return refused([`${name}: the file changed while it was uploading; send the reply again.`]);
260
+ case 'budget_exceeded':
261
+ return new ToolInputError(`Not shared: this session's upload budget for today is used up${typeof d.resets_at === 'string' ? ` (it resets at ${d.resets_at})` : ''}. Nothing was posted.`);
262
+ case 'too_many_pending':
263
+ return new ToolInputError('Not shared: this session has too many uploads waiting to be posted; they expire within the hour. Try again later. Nothing was posted.');
264
+ default:
265
+ return undefined;
266
+ }
267
+ }
268
+ /** The upload id of a reply's nth file. */
269
+ export function uploadId(sessionId, key, index, path) {
270
+ return uuidV5(`${sessionId}\n${key}\n${index}\n${path}`, UPLOAD_NAMESPACE);
271
+ }
272
+ /** How long a PUT may take (its URL works 10 minutes). */
273
+ const PUT_TIMEOUT_MS = 10 * 60 * 1000;
274
+ /** Step 3: one file's upload, to ready. Returns its id. */
275
+ async function uploadOne(f, index, total, o) {
276
+ const id = uploadId(o.sessionId, o.key, index, f.path);
277
+ const body = {
278
+ id,
279
+ ...(o.threadId === undefined ? {} : { thread_id: o.threadId }),
280
+ file_name: f.name,
281
+ content_type: f.contentType,
282
+ size_bytes: f.sourceSize,
283
+ ...(f.durationMs === undefined ? {} : { duration_ms: f.durationMs }),
284
+ ...(f.poster === undefined
285
+ ? {}
286
+ : {
287
+ thumbnail: {
288
+ content_type: f.poster.contentType,
289
+ size_bytes: f.poster.size,
290
+ width: f.poster.width,
291
+ height: f.poster.height,
292
+ },
293
+ }),
294
+ };
295
+ const init = () => callOperation(o.api, 'create_session_upload', { body }, o.apiOptions);
296
+ const put = async (upload) => {
297
+ if (upload.put === undefined)
298
+ throw new ToolInputError('The API gave no upload URL; send the reply again.');
299
+ await o.progress?.(`Uploading ${f.name} (${index + 1} of ${total})`);
300
+ let last = Date.now();
301
+ await uploadFile(upload.put.url, upload.put.headers, f.source, f.sourceSize, {
302
+ signal: AbortSignal.any([
303
+ ...(o.signal ? [o.signal] : []),
304
+ AbortSignal.timeout(PUT_TIMEOUT_MS),
305
+ ]),
306
+ onBytes: (sent) => {
307
+ if (Date.now() - last < 10_000)
308
+ return;
309
+ last = Date.now();
310
+ void o.progress?.(`Uploading ${f.name}: ${Math.floor((sent / Math.max(1, f.sourceSize)) * 100)}%`);
311
+ },
312
+ });
313
+ // The poster, to the URL signed for it (none when the server didn't take one).
314
+ if (f.poster !== undefined && upload.thumbnail_put !== undefined) {
315
+ await uploadFile(upload.thumbnail_put.url, upload.thumbnail_put.headers, f.poster.path, f.poster.size, {
316
+ signal: AbortSignal.any([
317
+ ...(o.signal ? [o.signal] : []),
318
+ AbortSignal.timeout(PUT_TIMEOUT_MS),
319
+ ]),
320
+ });
321
+ }
322
+ };
323
+ const complete = () => callOperation(o.api, 'complete_session_upload', { path: { id }, body: {} }, o.apiOptions);
324
+ const first = await init();
325
+ if (first.upload.status !== 'uploading')
326
+ return id;
327
+ await put(first.upload);
328
+ try {
329
+ await complete();
330
+ }
331
+ catch (err) {
332
+ if (!(err instanceof ApiError) || err.code !== 'upload_missing')
333
+ throw err;
334
+ // The bytes didn't arrive: a fresh URL (the same id), the PUT again, then complete again.
335
+ const again = await init();
336
+ if (again.upload.status === 'uploading') {
337
+ await put(again.upload);
338
+ await complete();
339
+ }
340
+ }
341
+ return id;
342
+ }
343
+ /**
344
+ * Steps 3 and 4: uploads every prepared file, then posts them with the
345
+ * text in one post_session_message. Any refusal stops before the post.
346
+ */
347
+ export async function shareFiles(files, post, o) {
348
+ const ids = [];
349
+ for (const [i, f] of files.entries()) {
350
+ try {
351
+ ids.push(await uploadOne(f, i, files.length, o));
352
+ }
353
+ catch (err) {
354
+ if (err instanceof ApiError) {
355
+ const plain = uploadRefusal(err, f, o.owner);
356
+ if (plain !== undefined)
357
+ throw plain;
358
+ }
359
+ if (err instanceof TransferError) {
360
+ throw new ToolInputError(`Not shared: uploading ${JSON.stringify(f.given)} failed (${err.message}). Nothing was posted; send the reply again with the same idempotency_key.`);
361
+ }
362
+ throw err;
363
+ }
364
+ }
365
+ const response = await callOperation(o.api, 'post_session_message', {
366
+ body: {
367
+ id: o.key,
368
+ ...post,
369
+ ...(o.threadId === undefined ? {} : { thread_id: o.threadId }),
370
+ files: ids,
371
+ },
372
+ }, o.apiOptions);
373
+ return { response, ids };
374
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { hostname } from 'node:os';
11
11
  import { dirname, join } from 'node:path';
12
+ import { removeFolderAttachments } from '../channel/attachments.js';
12
13
  import { ApiError, ApiClient } from '../api/client.js';
13
14
  import { detectCapabilities } from '../capabilities.js';
14
15
  import { connectPoll, connectStart, getSession, POLL_CODES, revokeSession, SESSION_ENDED_CODES, } from '../channel/api.js';
@@ -382,6 +383,8 @@ export async function runDisconnect(given, io) {
382
383
  // Best effort: the session ends in buildIt.Social too, before its token is forgotten here.
383
384
  const failed = await cancel(apiFor(options, io, token));
384
385
  await forgetSession(io.store, options.folder, options.apiUrl);
386
+ // The session's downloaded attachments go with it.
387
+ await removeFolderAttachments(options.folder);
385
388
  io.out(failed === undefined
386
389
  ? 'Disconnected: the session is cancelled in buildIt.Social, and this folder no longer has a saved token.\n'
387
390
  : `Disconnected: this folder no longer has a saved token. The session could not be cancelled in buildIt.Social (${failed}): revoke it there (Settings → Agents).\n`);
package/dist/errors.js CHANGED
@@ -96,6 +96,7 @@ const RATE_BUCKETS = {
96
96
  session_typing_per_minute: 'typing notices per minute for this session',
97
97
  session_approvals_per_minute: 'tool approval calls per minute for this session',
98
98
  session_handoffs_per_minute: 'handoff calls per minute for this session',
99
+ session_files_per_minute: 'file downloads and uploads per minute for this session',
99
100
  connect_starts_per_hour: 'connections started per hour from this address',
100
101
  connect_pending_grants: 'connections waiting for approval on this server',
101
102
  connect_pending_per_address: 'connections waiting for approval from this network',