@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.
@@ -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: forwarded ? forwardedContent(message.text, forwarded) : message.text,
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 text = () => forwarded ? forwardedBlock(plainLinks(message.text), forwarded) : plainLinks(message.text);
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();
@@ -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;
@@ -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