@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.
- package/CHANGELOG.md +66 -0
- package/README.md +38 -6
- package/dist/api/generated/operations.js +156 -2
- package/dist/api/generated/schemas.js +337 -109
- package/dist/api/generated/strict.js +371 -109
- package/dist/channel/attachment-blocks.js +57 -0
- package/dist/channel/attachments.js +469 -0
- package/dist/channel/handoffs.js +11 -22
- package/dist/channel/item-events.js +10 -15
- package/dist/channel/loop.js +87 -2
- package/dist/channel/mask.js +61 -1
- package/dist/channel/threads.js +21 -0
- package/dist/channel/upload.js +374 -0
- package/dist/cli/commands.js +3 -0
- package/dist/errors.js +1 -0
- package/dist/files/location.js +1855 -0
- package/dist/files/paths.js +69 -0
- package/dist/files/poster.js +281 -0
- package/dist/files/scan.js +108 -0
- package/dist/files/transfer.js +163 -0
- package/dist/files/types.js +351 -0
- package/dist/log.js +7 -0
- package/dist/server.js +9 -0
- package/dist/tools/ask.js +237 -223
- package/dist/tools/attachment.js +175 -0
- package/dist/tools/reply.js +216 -62
- package/dist/transports/stdio.js +9 -3
- package/package.json +1 -1
package/dist/channel/loop.js
CHANGED
|
@@ -49,6 +49,8 @@ import { childDoneContent, handoffResolvedContent, Handoffs, targetName } from '
|
|
|
49
49
|
import { assignedContent, handedContent, mentionContent, plainLinks, repliedContent, unassignedContent, } from './item-events.js';
|
|
50
50
|
import { ackSessionEvents, getSession, listSessionEvents, listSessionThreads, SESSION_ENDED_CODES, sessionHeartbeat, setSessionCapabilities, } from './api.js';
|
|
51
51
|
import { apiApprovalCloser } from './approvals.js';
|
|
52
|
+
import { attachmentsBlock, forwardedFilesLead } from './attachment-blocks.js';
|
|
53
|
+
import { AttachmentStore, filesServed } from './attachments.js';
|
|
52
54
|
import { ToolRing } from './tool-ring.js';
|
|
53
55
|
import { CardWaiters, eventMessageId } from './waiters.js';
|
|
54
56
|
/** The notification Claude Code reads as a channel event. */
|
|
@@ -142,6 +144,8 @@ export class ChannelState {
|
|
|
142
144
|
ring = new ToolRing();
|
|
143
145
|
/** The session's open tool approvals (channel mode, the folder's channel). */
|
|
144
146
|
approvals;
|
|
147
|
+
/** The session's attachments folder (channel mode, once the session is read). */
|
|
148
|
+
attachments;
|
|
145
149
|
/** The handoffs this session proposed or was handed, as seen in this process. */
|
|
146
150
|
handoffs = new Handoffs();
|
|
147
151
|
/**
|
|
@@ -206,6 +210,22 @@ function forwardedBlock(text, origin) {
|
|
|
206
210
|
}),
|
|
207
211
|
].join('\n');
|
|
208
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* The attachments block of the event's own message: the owner's files, or
|
|
215
|
+
* a forwarded message's (the original author's, said in a line before it).
|
|
216
|
+
* Call inside the content's withBlockId.
|
|
217
|
+
*/
|
|
218
|
+
function filesBlock(messageId, outcomes, forwarded, owner) {
|
|
219
|
+
const block = attachmentsBlock(messageId, outcomes, forwarded === undefined
|
|
220
|
+
? { author: owner, author_kind: 'user', from_owner: true }
|
|
221
|
+
: {
|
|
222
|
+
author: forwarded.author,
|
|
223
|
+
author_kind: forwarded.app ? 'app' : 'user',
|
|
224
|
+
from_owner: false,
|
|
225
|
+
forwarded: true,
|
|
226
|
+
}, owner);
|
|
227
|
+
return forwarded === undefined ? block : [forwardedFilesLead(forwarded.author), block].join('\n');
|
|
228
|
+
}
|
|
209
229
|
/** The longest pause after repeated failures. */
|
|
210
230
|
export const MAX_BACKOFF_MS = 60_000;
|
|
211
231
|
/** The pause while the session is suspended or sessions are off. */
|
|
@@ -324,6 +344,7 @@ export class ChannelLoop {
|
|
|
324
344
|
.catch(() => undefined);
|
|
325
345
|
}
|
|
326
346
|
state.waiters.endAll();
|
|
347
|
+
state.attachments?.stopCleanup();
|
|
327
348
|
if (this.ended || this.yielded || state.session === undefined)
|
|
328
349
|
return;
|
|
329
350
|
await this.options.state.typing?.stopAll().catch(() => undefined);
|
|
@@ -365,6 +386,7 @@ export class ChannelLoop {
|
|
|
365
386
|
return;
|
|
366
387
|
if (!this.stillChannel())
|
|
367
388
|
return;
|
|
389
|
+
this.openAttachments(info);
|
|
368
390
|
await sessionHeartbeat(this.options.api, 'online', { signal: this.signal }).catch((err) => {
|
|
369
391
|
if (err instanceof ApiError) {
|
|
370
392
|
this.options.logger.info('channel heartbeat failed', { code: err.code });
|
|
@@ -417,6 +439,38 @@ export class ChannelLoop {
|
|
|
417
439
|
await this.sleep(wait, this.signal);
|
|
418
440
|
}
|
|
419
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* The session's attachments folder, in the folder it is connected for,
|
|
444
|
+
* cleaned up now and every hour. Not on a server that doesn't serve files.
|
|
445
|
+
*/
|
|
446
|
+
openAttachments(info) {
|
|
447
|
+
const { api, logger, state } = this.options;
|
|
448
|
+
if (state.attachments !== undefined || filesServed(api) === false)
|
|
449
|
+
return;
|
|
450
|
+
try {
|
|
451
|
+
state.attachments = new AttachmentStore({
|
|
452
|
+
api,
|
|
453
|
+
logger: logger.child({ component: 'attachments' }),
|
|
454
|
+
folder: state.folder ?? process.cwd(),
|
|
455
|
+
sessionId: info.session.id,
|
|
456
|
+
budgetLeft: info.files_budget?.down_bytes_left,
|
|
457
|
+
});
|
|
458
|
+
state.attachments.startCleanup();
|
|
459
|
+
}
|
|
460
|
+
catch (err) {
|
|
461
|
+
logger.warn('attachments unavailable', { error: err });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* The automatic download of an event's own message's files, before the
|
|
466
|
+
* event is handed on. Never throws, and takes at most a minute.
|
|
467
|
+
*/
|
|
468
|
+
async download(messageId, files) {
|
|
469
|
+
const store = this.options.state.attachments;
|
|
470
|
+
if (store === undefined)
|
|
471
|
+
return files.map((file) => ({ file, reason: 'not_served' }));
|
|
472
|
+
return store.autoDownload(messageId, files, { signal: this.signal });
|
|
473
|
+
}
|
|
420
474
|
/** GET /v1/session, retried while it fails for a reason that may pass. */
|
|
421
475
|
async loadSession() {
|
|
422
476
|
while (!this.isStopped()) {
|
|
@@ -528,13 +582,27 @@ export class ChannelLoop {
|
|
|
528
582
|
this.noteLocale(user);
|
|
529
583
|
const threadId = message.thread_id ?? message.id;
|
|
530
584
|
const forwarded = forwardOrigin(message);
|
|
585
|
+
const files = message.files ?? [];
|
|
586
|
+
const outcomes = files.length > 0 ? await this.download(message.id, files) : [];
|
|
587
|
+
if (this.isStopped())
|
|
588
|
+
return;
|
|
589
|
+
const owner = sanitizeLabel(info.owner.name, 64);
|
|
590
|
+
const content = outcomes.length === 0
|
|
591
|
+
? forwarded
|
|
592
|
+
? forwardedContent(message.text, forwarded)
|
|
593
|
+
: message.text
|
|
594
|
+
: withBlockId(() => [
|
|
595
|
+
forwarded ? forwardedBlock(message.text, forwarded) : message.text,
|
|
596
|
+
filesBlock(message.id, outcomes, forwarded, owner),
|
|
597
|
+
].join('\n\n'));
|
|
531
598
|
await this.send({
|
|
532
|
-
content
|
|
599
|
+
content,
|
|
533
600
|
meta: {
|
|
534
601
|
thread_id: threadId,
|
|
535
602
|
message_id: message.id,
|
|
536
603
|
session: sanitizeLabel(event.session.label, 40),
|
|
537
604
|
...(forwarded ? { forwarded: 'true' } : {}),
|
|
605
|
+
...(files.length > 0 ? { attachments: String(files.length) } : {}),
|
|
538
606
|
},
|
|
539
607
|
});
|
|
540
608
|
const conversationId = event.data.channel?.id;
|
|
@@ -665,7 +733,18 @@ export class ChannelLoop {
|
|
|
665
733
|
this.seen.add(`m:${message.id}`);
|
|
666
734
|
this.noteLocale(user);
|
|
667
735
|
const forwarded = forwardOrigin(message);
|
|
668
|
-
const
|
|
736
|
+
const files = message.files ?? [];
|
|
737
|
+
const outcomes = files.length > 0 ? await this.download(message.id, files) : [];
|
|
738
|
+
if (this.isStopped())
|
|
739
|
+
return;
|
|
740
|
+
const text = () => {
|
|
741
|
+
const said = forwarded
|
|
742
|
+
? forwardedBlock(plainLinks(message.text), forwarded)
|
|
743
|
+
: plainLinks(message.text);
|
|
744
|
+
return outcomes.length === 0
|
|
745
|
+
? said
|
|
746
|
+
: [said, filesBlock(message.id, outcomes, forwarded, owner)].join('\n');
|
|
747
|
+
};
|
|
669
748
|
const base = { thread_id: threadId, message_id: message.id, item: key, project, session };
|
|
670
749
|
if (event.type === 'app.mentioned') {
|
|
671
750
|
content = mentionContent(owner, key, text, item, context);
|
|
@@ -679,6 +758,8 @@ export class ChannelLoop {
|
|
|
679
758
|
}
|
|
680
759
|
if (forwarded)
|
|
681
760
|
meta.forwarded = 'true';
|
|
761
|
+
if (files.length > 0)
|
|
762
|
+
meta.attachments = String(files.length);
|
|
682
763
|
shown.push(message.id);
|
|
683
764
|
}
|
|
684
765
|
await this.send({ content, meta });
|
|
@@ -805,6 +886,8 @@ export class ChannelLoop {
|
|
|
805
886
|
from_owner: message.from_owner,
|
|
806
887
|
via_agent: message.via_agent,
|
|
807
888
|
text: message.text,
|
|
889
|
+
message_id: message.id,
|
|
890
|
+
files: message.files,
|
|
808
891
|
},
|
|
809
892
|
});
|
|
810
893
|
const threadId = d.item.id;
|
|
@@ -994,6 +1077,8 @@ export class ChannelLoop {
|
|
|
994
1077
|
this.stopper.abort();
|
|
995
1078
|
if (reason === 'token_revoked' || reason === 'token_expired' || reason === 'token_invalid') {
|
|
996
1079
|
this.options.state.ended = reason;
|
|
1080
|
+
// The session is over: its downloaded files go with it.
|
|
1081
|
+
await this.options.state.attachments?.removeSession().catch(() => undefined);
|
|
997
1082
|
}
|
|
998
1083
|
// Every question waiting for an answer is told the session ended.
|
|
999
1084
|
this.options.state.waiters.endAll();
|
package/dist/channel/mask.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* the soft hyphen, bidi controls, zero-width and invisible joiners and
|
|
22
22
|
* separators, variation selectors, the byte order mark and tag characters.
|
|
23
23
|
*/
|
|
24
|
-
const STRIP = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F\u00AD\u034F\u061C\u180E\u200B-\u200F\u202A-\u202E\u2060\u2066-\u2069\uFE00-\uFE0F\uFEFF\u{E0000}-\u{E007F}]/gu;
|
|
24
|
+
export const STRIP = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F\u00AD\u034F\u061C\u180E\u200B-\u200F\u202A-\u202E\u2060\u2066-\u2069\uFE00-\uFE0F\uFEFF\u{E0000}-\u{E007F}]/gu;
|
|
25
25
|
/* eslint-enable no-control-regex, no-misleading-character-class */
|
|
26
26
|
/** What replaces a secret. */
|
|
27
27
|
export const REDACTED = '[REDACTED]';
|
|
@@ -42,6 +42,20 @@ export function cleanApprovalKey(text) {
|
|
|
42
42
|
.replace(/[\n\t]/g, ' ')
|
|
43
43
|
.trim();
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* What each rule finds, in the rules' order (R1 to R8): the names the agent
|
|
47
|
+
* API's credential_found error uses in details.kinds.
|
|
48
|
+
*/
|
|
49
|
+
export const CREDENTIAL_KINDS = [
|
|
50
|
+
'private_key',
|
|
51
|
+
'authorization_header',
|
|
52
|
+
'bearer_token',
|
|
53
|
+
'url_password',
|
|
54
|
+
'credential_option',
|
|
55
|
+
'credential_assignment',
|
|
56
|
+
'api_key',
|
|
57
|
+
'jwt',
|
|
58
|
+
];
|
|
45
59
|
/**
|
|
46
60
|
* The masking rules, in the order they apply. Each replaces only the secret.
|
|
47
61
|
*
|
|
@@ -64,43 +78,51 @@ export function cleanApprovalKey(text) {
|
|
|
64
78
|
const RULES = [
|
|
65
79
|
// R1: a PEM or PGP private key block whose body is all armor, to its END line or the end.
|
|
66
80
|
{
|
|
81
|
+
kind: 'private_key',
|
|
67
82
|
pattern: /-----BEGIN [A-Z0-9 ]{0,64}PRIVATE KEY(?: BLOCK)?-----\n(?:(?:[A-Za-z0-9+/=]*|[A-Za-z0-9-]{1,64}: [A-Za-z0-9._~+= -]*)\n)*?(?:-----END [A-Z0-9 ]{0,64}PRIVATE KEY(?: BLOCK)?-----|(?:[A-Za-z0-9+/=]*|[A-Za-z0-9-]{1,64}: [A-Za-z0-9._~+= -]*)$)/g,
|
|
68
83
|
replacement: REDACTED,
|
|
69
84
|
},
|
|
70
85
|
// R2: an Authorization header's credentials (its quotes may be JSON-escaped).
|
|
71
86
|
{
|
|
87
|
+
kind: 'authorization_header',
|
|
72
88
|
pattern: /(authorization(?:\\?["'])?[ \t\n\v\f\r]{0,16}[:=][ \t\n\v\f\r]{0,16}(?:\\?["'])?(?:bearer|basic|token|bot)[ \t\n\v\f\r]{1,16})[A-Za-z0-9._~+=-]+(?![^ \t\n\v\f\r"'\\&;,])/gi,
|
|
73
89
|
replacement: `$1${REDACTED}`,
|
|
74
90
|
},
|
|
75
91
|
// R3: a bearer token anywhere.
|
|
76
92
|
{
|
|
93
|
+
kind: 'bearer_token',
|
|
77
94
|
pattern: /(?<![A-Za-z0-9_])(bearer[ \t\n\v\f\r]{1,16})[A-Za-z0-9._~+=-]{16,}(?![^ \t\n\v\f\r"'\\&;,])/gi,
|
|
78
95
|
replacement: `$1${REDACTED}`,
|
|
79
96
|
},
|
|
80
97
|
// R4: the password in a URL's user info (the host after it stays).
|
|
81
98
|
{
|
|
99
|
+
kind: 'url_password',
|
|
82
100
|
pattern: /(?<![a-z0-9+.-])([a-z][a-z0-9+.-]{0,31}:\/\/[^/ \t\n\v\f\r:@]{1,64}:)[A-Za-z0-9._~+=-]+@/gi,
|
|
83
101
|
replacement: `$1${REDACTED}@`,
|
|
84
102
|
},
|
|
85
103
|
// R5: a password, secret, token or API key option's value (--password x, --token=x),
|
|
86
104
|
// plain or in quotes (which stay).
|
|
87
105
|
{
|
|
106
|
+
kind: 'credential_option',
|
|
88
107
|
pattern: /(?<![^ \t\n\v\f\r])(--?[A-Za-z0-9-]{0,64}(?:password|passwd|secret|token|api-?key)(?:[ \t\n\v\f\r]{1,16}|=)(?:"(?=[A-Za-z0-9._~+=-]+")|'(?=[A-Za-z0-9._~+=-]+')|(?=[A-Za-z0-9._~+=-]+(?![^ \t\n\v\f\r]))))[A-Za-z0-9._~+=-]+/gi,
|
|
89
108
|
replacement: `$1${REDACTED}`,
|
|
90
109
|
},
|
|
91
110
|
// R6: an assignment or JSON member whose name says it holds a secret: the value
|
|
92
111
|
// plain, in quotes, or in JSON-escaped quotes inside a shell string (the quotes stay).
|
|
93
112
|
{
|
|
113
|
+
kind: 'credential_assignment',
|
|
94
114
|
pattern: /(?<![A-Za-z0-9_-])([A-Za-z0-9_-]{0,64}(?:password|passwd|secret|token|api[_-]?key|apikey|private[_-]?key|access[_-]?key)(?:\\?["'])?[ \t\n\v\f\r]{0,16}[:=][ \t\n\v\f\r]{0,16}(?:\\"(?=[A-Za-z0-9._~+=-]+\\")|"(?=[A-Za-z0-9._~+=-]+")|'(?=[A-Za-z0-9._~+=-]+')|(?=[A-Za-z0-9._~+=-]+(?![^ \t\n\v\f\r"'\\&;,]))))[A-Za-z0-9._~+=-]+/gi,
|
|
95
115
|
replacement: `$1${REDACTED}`,
|
|
96
116
|
},
|
|
97
117
|
// R7: well-known API key and token prefixes.
|
|
98
118
|
{
|
|
119
|
+
kind: 'api_key',
|
|
99
120
|
pattern: /(?<![A-Za-z0-9_])(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|xapp-[A-Za-z0-9-]{10,}|(?:AKIA|ASIA)[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{30,}|[sr]k_(?:live|test)_[A-Za-z0-9]{10,}|whsec_[A-Za-z0-9]{10,}|buildit_(?:pat|ags|app)_[A-Za-z0-9]{10,}|buildit_whsec_[0-9a-f]{10,})/g,
|
|
100
121
|
replacement: REDACTED,
|
|
101
122
|
},
|
|
102
123
|
// R8: a JSON Web Token.
|
|
103
124
|
{
|
|
125
|
+
kind: 'jwt',
|
|
104
126
|
pattern: /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]*)?/g,
|
|
105
127
|
replacement: REDACTED,
|
|
106
128
|
},
|
|
@@ -109,6 +131,44 @@ const RULES = [
|
|
|
109
131
|
const SECRET_KEY = /^[A-Za-z0-9_-]{0,64}(?:password|passwd|secret|token|api[_-]?key|apikey|private[_-]?key|access[_-]?key|authorization|credentials?)$/i;
|
|
110
132
|
/** The only characters a masked span may hold. */
|
|
111
133
|
const TOKEN_CHARSET = /^[A-Za-z0-9._~+=-]+$/;
|
|
134
|
+
/**
|
|
135
|
+
* Finds, never masks: the lines (1-based, after CR LF and CR become line
|
|
136
|
+
* feeds, counted from `firstLine`) where a rule matches, and which rules.
|
|
137
|
+
* The text is cleaned first (cleanApprovalText), so an invisible character
|
|
138
|
+
* can't split a key. The same rules the agent API's backstop applies.
|
|
139
|
+
*/
|
|
140
|
+
export function findCredentials(text, firstLine = 1) {
|
|
141
|
+
const clean = cleanApprovalText(text);
|
|
142
|
+
const starts = [0];
|
|
143
|
+
for (let i = clean.indexOf('\n'); i >= 0; i = clean.indexOf('\n', i + 1))
|
|
144
|
+
starts.push(i + 1);
|
|
145
|
+
const lineOf = (offset) => {
|
|
146
|
+
let lo = 0;
|
|
147
|
+
let hi = starts.length - 1;
|
|
148
|
+
while (lo < hi) {
|
|
149
|
+
const mid = (lo + hi + 1) >> 1;
|
|
150
|
+
if ((starts[mid] ?? 0) <= offset)
|
|
151
|
+
lo = mid;
|
|
152
|
+
else
|
|
153
|
+
hi = mid - 1;
|
|
154
|
+
}
|
|
155
|
+
return lo + firstLine;
|
|
156
|
+
};
|
|
157
|
+
const lines = new Set();
|
|
158
|
+
const kinds = [];
|
|
159
|
+
for (const rule of RULES) {
|
|
160
|
+
let hit = false;
|
|
161
|
+
for (const m of clean.matchAll(rule.pattern)) {
|
|
162
|
+
hit = true;
|
|
163
|
+
lines.add(lineOf(m.index));
|
|
164
|
+
}
|
|
165
|
+
if (hit)
|
|
166
|
+
kinds.push(rule.kind);
|
|
167
|
+
}
|
|
168
|
+
if (kinds.length === 0)
|
|
169
|
+
return undefined;
|
|
170
|
+
return { lines: [...lines].sort((a, b) => a - b), kinds };
|
|
171
|
+
}
|
|
112
172
|
/** Replaces every credential-shaped part of a text with `[REDACTED]` (rules R1 to R8). */
|
|
113
173
|
export function maskCredentials(text) {
|
|
114
174
|
let out = text;
|
package/dist/channel/threads.js
CHANGED
|
@@ -28,6 +28,27 @@ export async function itemThreadId(ref, ctx) {
|
|
|
28
28
|
ctx.channel?.rememberItem(item.key, item.id);
|
|
29
29
|
return item.id;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Whether a thread is a work item's (its id is the item's): one of the
|
|
33
|
+
* items the session works in, an item the events or get_item named, or the
|
|
34
|
+
* latest message's item. Anything else (none: a new message) is the direct
|
|
35
|
+
* message's.
|
|
36
|
+
*/
|
|
37
|
+
export function isItemThread(state, threadId) {
|
|
38
|
+
if (threadId === undefined)
|
|
39
|
+
return false;
|
|
40
|
+
const id = threadId.toLowerCase();
|
|
41
|
+
const same = (other) => other?.toLowerCase() === id;
|
|
42
|
+
if (same(state.last?.item?.id))
|
|
43
|
+
return true;
|
|
44
|
+
for (const active of state.activeItems.keys())
|
|
45
|
+
if (same(active))
|
|
46
|
+
return true;
|
|
47
|
+
for (const known of state.items.values())
|
|
48
|
+
if (same(known))
|
|
49
|
+
return true;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
31
52
|
/** The refusal when several threads are active and none was named. */
|
|
32
53
|
export function threadRequiredMessage(state, tool, how) {
|
|
33
54
|
const listed = state
|