@chrisromp/copilot-bridge 0.7.0 → 0.8.1

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.
Files changed (47) hide show
  1. package/config.sample.json +9 -1
  2. package/dist/channels/slack/adapter.d.ts +62 -0
  3. package/dist/channels/slack/adapter.d.ts.map +1 -0
  4. package/dist/channels/slack/adapter.js +382 -0
  5. package/dist/channels/slack/adapter.js.map +1 -0
  6. package/dist/channels/slack/mrkdwn.d.ts +22 -0
  7. package/dist/channels/slack/mrkdwn.d.ts.map +1 -0
  8. package/dist/channels/slack/mrkdwn.js +120 -0
  9. package/dist/channels/slack/mrkdwn.js.map +1 -0
  10. package/dist/config.d.ts +5 -1
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +63 -7
  13. package/dist/config.js.map +1 -1
  14. package/dist/core/access-control.d.ts +32 -0
  15. package/dist/core/access-control.d.ts.map +1 -0
  16. package/dist/core/access-control.js +59 -0
  17. package/dist/core/access-control.js.map +1 -0
  18. package/dist/core/command-handler.d.ts +2 -0
  19. package/dist/core/command-handler.d.ts.map +1 -1
  20. package/dist/core/command-handler.js +75 -1
  21. package/dist/core/command-handler.js.map +1 -1
  22. package/dist/core/inter-agent.d.ts +9 -2
  23. package/dist/core/inter-agent.d.ts.map +1 -1
  24. package/dist/core/inter-agent.js +87 -22
  25. package/dist/core/inter-agent.js.map +1 -1
  26. package/dist/core/model-fallback.js +1 -1
  27. package/dist/core/model-fallback.js.map +1 -1
  28. package/dist/core/session-manager.d.ts +3 -0
  29. package/dist/core/session-manager.d.ts.map +1 -1
  30. package/dist/core/session-manager.js +51 -13
  31. package/dist/core/session-manager.js.map +1 -1
  32. package/dist/index.js +207 -30
  33. package/dist/index.js.map +1 -1
  34. package/dist/types.d.ts +10 -1
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +2 -1
  37. package/scripts/check.ts +54 -1
  38. package/scripts/com.copilot-bridge.plist +5 -2
  39. package/scripts/init.ts +322 -117
  40. package/scripts/install-service.ts +32 -1
  41. package/scripts/lib/config-gen.ts +74 -10
  42. package/scripts/lib/prerequisites.ts +17 -5
  43. package/scripts/lib/prompts.ts +4 -0
  44. package/scripts/lib/service.ts +27 -3
  45. package/scripts/lib/slack.ts +190 -0
  46. package/templates/admin/AGENTS.md +5 -5
  47. package/templates/agents/AGENTS.md +1 -1
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { loadConfig, getConfig, isConfiguredChannel, registerDynamicChannel, markChannelAsDM, getChannelConfig, getPlatformBots, getChannelBotName, isBotAdmin, getHardcodedRules, getConfigRules, reloadConfig, ConfigWatcher } from './config.js';
1
+ import { loadConfig, getConfig, isConfiguredChannel, registerDynamicChannel, markChannelAsDM, getChannelConfig, getPlatformBots, getPlatformAccess, getChannelBotName, isBotAdmin, getHardcodedRules, getConfigRules, reloadConfig, ConfigWatcher } from './config.js';
2
2
  import { CopilotBridge } from './core/bridge.js';
3
3
  import { SessionManager, BRIDGE_CUSTOM_TOOLS } from './core/session-manager.js';
4
4
  import { handleCommand, parseCommand } from './core/command-handler.js';
@@ -12,7 +12,8 @@ import { initScheduler, stopAll as stopScheduler, listJobs, removeJob, pauseJob,
12
12
  import { markBusy, markIdle, markIdleImmediate, isBusy, waitForChannelIdle, cancelIdleDebounce } from './core/channel-idle.js';
13
13
  import { LoopDetector, MAX_IDENTICAL_CALLS } from './core/loop-detector.js';
14
14
  import { getTaskHistory } from './state/store.js';
15
- import { createLogger } from './logger.js';
15
+ import { checkUserAccess } from './core/access-control.js';
16
+ import { createLogger, setLogLevel } from './logger.js';
16
17
  import fs from 'node:fs';
17
18
  import path from 'node:path';
18
19
  const log = createLogger('bridge');
@@ -116,16 +117,135 @@ function getAdapterForChannel(channelId) {
116
117
  return null;
117
118
  return { adapter, streaming };
118
119
  }
120
+ const SLACK_UID_PATTERN = /^U[A-Z0-9]{6,}$/;
121
+ /**
122
+ * Resolve non-UID entries in Slack bot access configs.
123
+ * Handles added manually as usernames are looked up via Slack API (with pagination) and replaced with UIDs.
124
+ */
125
+ async function resolveSlackAccessUsers(config) {
126
+ const slackPlatform = config.platforms.slack;
127
+ if (!slackPlatform?.bots)
128
+ return;
129
+ // Collect all access configs that need resolution: platform-level + per-bot
130
+ const accessTargets = [];
131
+ const firstBotToken = Object.values(slackPlatform.bots)[0]?.token;
132
+ if (slackPlatform.access?.users?.length && firstBotToken) {
133
+ accessTargets.push({ label: 'platform "slack"', access: slackPlatform.access, tokenSource: firstBotToken });
134
+ }
135
+ for (const [botName, bot] of Object.entries(slackPlatform.bots)) {
136
+ if (bot.access?.users?.length) {
137
+ accessTargets.push({ label: `bot "${botName}"`, access: bot.access, tokenSource: bot.token });
138
+ }
139
+ }
140
+ if (accessTargets.length === 0)
141
+ return;
142
+ // Deduplicate API calls — group by token
143
+ const membersByToken = new Map();
144
+ for (const target of accessTargets) {
145
+ if (membersByToken.has(target.tokenSource))
146
+ continue;
147
+ const unresolved = target.access.users.filter(u => !SLACK_UID_PATTERN.test(u));
148
+ if (unresolved.length === 0)
149
+ continue;
150
+ const allMembers = [];
151
+ try {
152
+ let cursor;
153
+ do {
154
+ const params = new URLSearchParams({ limit: '200' });
155
+ if (cursor)
156
+ params.set('cursor', cursor);
157
+ const resp = await fetch(`https://slack.com/api/users.list?${params}`, {
158
+ headers: { 'Authorization': `Bearer ${target.tokenSource}` },
159
+ });
160
+ if (!resp.ok) {
161
+ log.warn(` Slack users.list failed: HTTP ${resp.status}`);
162
+ break;
163
+ }
164
+ const data = await resp.json();
165
+ if (!data.ok) {
166
+ log.warn(` Slack users.list failed: ${data.error}`);
167
+ break;
168
+ }
169
+ for (const m of data.members ?? [])
170
+ allMembers.push(m);
171
+ cursor = data.response_metadata?.next_cursor || undefined;
172
+ } while (cursor);
173
+ }
174
+ catch (err) {
175
+ log.warn(` Failed to fetch Slack users: ${err.message}`);
176
+ }
177
+ membersByToken.set(target.tokenSource, allMembers);
178
+ }
179
+ // Resolve each access config
180
+ for (const target of accessTargets) {
181
+ const unresolved = target.access.users.filter(u => !SLACK_UID_PATTERN.test(u));
182
+ if (unresolved.length === 0)
183
+ continue;
184
+ log.info(`Resolving ${unresolved.length} Slack handle(s) for ${target.label} access list...`);
185
+ const allMembers = membersByToken.get(target.tokenSource) ?? [];
186
+ // Build lookup map for O(1) resolution
187
+ const nameMap = new Map();
188
+ const displayMap = new Map();
189
+ for (const m of allMembers) {
190
+ if (m.deleted || m.is_bot)
191
+ continue;
192
+ const name = (m.name ?? '').toLowerCase();
193
+ if (name)
194
+ nameMap.set(name, m.id);
195
+ const displayName = m.profile?.display_name_normalized?.toLowerCase() ?? '';
196
+ if (displayName)
197
+ displayMap.set(displayName, m.id);
198
+ const realName = m.profile?.real_name_normalized?.toLowerCase() ?? '';
199
+ if (realName)
200
+ displayMap.set(realName, m.id);
201
+ }
202
+ const resolved = [];
203
+ for (const handle of unresolved) {
204
+ const normalized = handle.replace(/^@/, '').toLowerCase();
205
+ const byName = nameMap.get(normalized);
206
+ if (byName) {
207
+ log.info(` Resolved "${handle}" → ${byName} (by handle)`);
208
+ resolved.push(byName);
209
+ }
210
+ else {
211
+ const byDisplay = displayMap.get(normalized);
212
+ if (byDisplay) {
213
+ log.warn(` Resolved "${handle}" → ${byDisplay} (by display/real name — consider using the exact Slack handle for reliability)`);
214
+ resolved.push(byDisplay);
215
+ }
216
+ else {
217
+ log.warn(` Could not resolve Slack handle "${handle}" — keeping as-is`);
218
+ resolved.push(handle);
219
+ }
220
+ }
221
+ }
222
+ const uidEntries = target.access.users.filter(u => SLACK_UID_PATTERN.test(u));
223
+ target.access.users = [...uidEntries, ...resolved];
224
+ }
225
+ }
119
226
  async function main() {
120
227
  log.info('copilot-bridge starting...');
121
228
  // Load configuration
122
229
  const config = loadConfig();
230
+ setLogLevel(config.logLevel ?? 'info');
123
231
  log.info(`Loaded ${config.channels.length} channel mapping(s)`);
124
232
  // Start config file watcher for hot-reload
125
233
  const configWatcher = new ConfigWatcher();
126
234
  configWatcher.onReload((result) => {
127
235
  if (!result.success)
128
236
  return;
237
+ // Re-apply logLevel in case config changed it
238
+ setLogLevel(getConfig().logLevel ?? 'info');
239
+ // Re-resolve Slack access handles after reload (config was re-read from disk).
240
+ // Fires asynchronously — messages during resolution use the old resolved values.
241
+ void (async () => {
242
+ try {
243
+ await resolveSlackAccessUsers(getConfig());
244
+ }
245
+ catch (err) {
246
+ log.warn(`Slack access resolution after reload failed: ${err.message}`);
247
+ }
248
+ })();
129
249
  if (result.restartNeeded.length > 0) {
130
250
  // Notify admin channels about restart-needed changes
131
251
  for (const [key, adapter] of botAdapters) {
@@ -174,20 +294,44 @@ async function main() {
174
294
  };
175
295
  // Initialize channel adapters — one per bot identity
176
296
  for (const [platformName, platformConfig] of Object.entries(config.platforms)) {
177
- const factory = adapterFactories[platformName];
178
- if (!factory) {
179
- log.warn(`No adapter for platform "${platformName}" — skipping`);
180
- continue;
181
- }
182
297
  const bots = getPlatformBots(platformName);
183
298
  for (const [botName, botInfo] of bots) {
184
299
  const key = `${platformName}:${botName}`;
185
- const adapter = factory(platformName, platformConfig.url, botInfo.token);
300
+ let adapter;
301
+ if (platformName === 'slack') {
302
+ // Slack needs appToken for Socket Mode — construct directly
303
+ if (!botInfo.appToken) {
304
+ log.error(`Slack bot "${botName}" missing appToken — skipping`);
305
+ continue;
306
+ }
307
+ try {
308
+ const { SlackAdapter } = await import('./channels/slack/adapter.js');
309
+ adapter = new SlackAdapter({
310
+ platformName,
311
+ botToken: botInfo.token,
312
+ appToken: botInfo.appToken,
313
+ });
314
+ }
315
+ catch (err) {
316
+ log.error(`Failed to load Slack adapter: ${err.message}`);
317
+ continue;
318
+ }
319
+ }
320
+ else {
321
+ const factory = adapterFactories[platformName];
322
+ if (!factory) {
323
+ log.warn(`No adapter for platform "${platformName}" — skipping`);
324
+ break; // skip all bots for this platform
325
+ }
326
+ adapter = factory(platformName, platformConfig.url ?? '', botInfo.token);
327
+ }
186
328
  botAdapters.set(key, adapter);
187
329
  botStreamers.set(key, new StreamingHandler(adapter));
188
330
  log.info(`Registered bot "${botName}" for ${platformName}`);
189
331
  }
190
332
  }
333
+ // Resolve non-UID Slack access entries at startup
334
+ await resolveSlackAccessUsers(config);
191
335
  // Wire up session events → streaming output (serialized per channel)
192
336
  sessionManager.onSessionEvent((sessionId, channelId, event) => {
193
337
  const prev = eventLocks.get(channelId) ?? Promise.resolve();
@@ -242,7 +386,7 @@ async function main() {
242
386
  .catch(err => log.error(`Unhandled error in message handler:`, err)));
243
387
  channelLocks.set(msg.channelId, next);
244
388
  });
245
- adapter.onReaction((reaction) => handleReaction(reaction, sessionManager));
389
+ adapter.onReaction((reaction) => handleReaction(reaction, sessionManager, platformName, botName));
246
390
  await adapter.connect();
247
391
  log.info(`${key} connected`);
248
392
  // Discover existing DM channels and auto-register any that aren't configured
@@ -370,6 +514,12 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
370
514
  if (key.startsWith(`${platformName}:`) && msg.userId === a.getBotUserId())
371
515
  return;
372
516
  }
517
+ // Check user-level access control
518
+ const botInfo = getPlatformBots(platformName).get(botName);
519
+ if (!checkUserAccess(msg.userId, msg.username, botInfo?.access, getPlatformAccess(platformName))) {
520
+ log.debug(`User ${msg.username} (${msg.userId}) denied mid-turn access to bot "${botName}"`);
521
+ return;
522
+ }
373
523
  if (!isConfiguredChannel(msg.channelId))
374
524
  return;
375
525
  const assignedBot = getChannelBotName(msg.channelId);
@@ -381,8 +531,10 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
381
531
  const { adapter } = resolved;
382
532
  const channelConfig = getChannelConfig(msg.channelId);
383
533
  // Respect trigger mode — don't steer on unmentioned messages in mention-only channels
384
- if (channelConfig.triggerMode === 'mention' && !msg.mentionsBot && !msg.isDM)
534
+ if (channelConfig.triggerMode === 'mention' && !msg.mentionsBot && !msg.isDM) {
535
+ log.debug(`Ignoring mid-turn message (trigger=mention, no mention) in ${msg.channelId.slice(0, 8)}...`);
385
536
  return;
537
+ }
386
538
  const text = stripBotMention(msg.text, channelConfig.bot);
387
539
  if (!text && !msg.attachments?.length)
388
540
  return;
@@ -457,7 +609,7 @@ async function handleMidTurnMessage(msg, sessionManager, platformName, botName)
457
609
  // Commands with complex action handlers (skills, schedule, rules) defer to serialized path.
458
610
  const SAFE_MID_TURN = new Set([
459
611
  'context', 'status', 'help', 'verbose', 'autopilot', 'yolo',
460
- 'mcp', 'model', 'models', 'reasoning',
612
+ 'mcp', 'model', 'models', 'reasoning', 'agents',
461
613
  'streamer-mode', 'on-air',
462
614
  ]);
463
615
  if (SAFE_MID_TURN.has(parsed.command)) {
@@ -523,6 +675,12 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
523
675
  if (key.startsWith(`${platformName}:`) && msg.userId === a.getBotUserId())
524
676
  return;
525
677
  }
678
+ // Check user-level access control (reads live config — hot-reloadable)
679
+ const botInfo = getPlatformBots(platformName).get(botName);
680
+ if (!checkUserAccess(msg.userId, msg.username, botInfo?.access, getPlatformAccess(platformName))) {
681
+ log.debug(`User ${msg.username} (${msg.userId}) denied access to bot "${botName}"`);
682
+ return; // silent drop
683
+ }
526
684
  // Auto-register DM channels for known bots
527
685
  if (!isConfiguredChannel(msg.channelId) && msg.isDM) {
528
686
  const workspacePath = getWorkspacePath(botName);
@@ -558,8 +716,10 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
558
716
  const channelConfig = getChannelConfig(msg.channelId);
559
717
  // Check trigger mode
560
718
  const triggerMode = channelConfig.triggerMode;
561
- if (triggerMode === 'mention' && !msg.mentionsBot && !msg.isDM)
719
+ if (triggerMode === 'mention' && !msg.mentionsBot && !msg.isDM) {
720
+ log.debug(`Ignoring message (trigger=mention, no mention) in ${msg.channelId.slice(0, 8)}...`);
562
721
  return;
722
+ }
563
723
  // Strip bot mention from message text
564
724
  let text = stripBotMention(msg.text, channelConfig.bot);
565
725
  if (!text && !msg.attachments?.length)
@@ -635,22 +795,26 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
635
795
  if (!result.success) {
636
796
  response = `❌ Config reload failed: ${result.error}\nExisting config is unchanged.`;
637
797
  }
638
- else if (result.changes.length === 0 && result.restartNeeded.length === 0) {
639
- response = '✅ Config reloaded — no changes detected.';
640
- }
641
798
  else {
642
- const parts = ['✅ Config reloaded.'];
643
- if (result.changes.length > 0) {
644
- parts.push('**Applied:**');
645
- for (const c of result.changes)
646
- parts.push(` ✓ ${c}`);
799
+ // Re-apply logLevel after manual reload
800
+ setLogLevel(getConfig().logLevel ?? 'info');
801
+ if (result.changes.length === 0 && result.restartNeeded.length === 0) {
802
+ response = '✅ Config reloaded — no changes detected.';
647
803
  }
648
- if (result.restartNeeded.length > 0) {
649
- parts.push('**Restart needed:**');
650
- for (const r of result.restartNeeded)
651
- parts.push(` ⚠️ ${r}`);
804
+ else {
805
+ const parts = [' Config reloaded.'];
806
+ if (result.changes.length > 0) {
807
+ parts.push('**Applied:**');
808
+ for (const c of result.changes)
809
+ parts.push(` ✓ ${c}`);
810
+ }
811
+ if (result.restartNeeded.length > 0) {
812
+ parts.push('**Restart needed:**');
813
+ for (const r of result.restartNeeded)
814
+ parts.push(` ⚠️ ${r}`);
815
+ }
816
+ response = parts.join('\n');
652
817
  }
653
- response = parts.join('\n');
654
818
  }
655
819
  await adapter.sendMessage(msg.channelId, response, { threadRootId: threadRoot });
656
820
  break;
@@ -910,14 +1074,16 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
910
1074
  lines.push('**Skills**');
911
1075
  for (const s of skills) {
912
1076
  const desc = s.description ? ` — ${s.description}` : '';
913
- lines.push(`• \`${s.name}\`${desc} _(${s.source})_`);
1077
+ const flag = s.pending ? ' ⏳ _reload to activate_' : '';
1078
+ lines.push(`• \`${s.name}\`${desc} _(${s.source})_${flag}`);
914
1079
  }
915
1080
  lines.push('');
916
1081
  }
917
1082
  if (mcpInfo.length > 0) {
918
1083
  lines.push('**MCP Servers**');
919
1084
  for (const s of mcpInfo) {
920
- lines.push(`• \`${s.name}\` _(${s.source})_`);
1085
+ const flag = s.pending ? ' ⏳ _reload to activate_' : '';
1086
+ lines.push(`• \`${s.name}\` _(${s.source})_${flag}`);
921
1087
  }
922
1088
  lines.push('');
923
1089
  }
@@ -1020,11 +1186,20 @@ async function handleInboundMessage(msg, sessionManager, platformName, botName)
1020
1186
  }
1021
1187
  }
1022
1188
  // --- Reaction Handling ---
1023
- async function handleReaction(reaction, sessionManager) {
1189
+ async function handleReaction(reaction, sessionManager, platformName, botName) {
1024
1190
  if (!isConfiguredChannel(reaction.channelId))
1025
1191
  return;
1026
1192
  if (reaction.action !== 'added')
1027
1193
  return;
1194
+ // Check user-level access control.
1195
+ // Reactions only carry userId (no username), so this matches against userId only.
1196
+ // For Slack (UIDs stored in config), this is exact. For Mattermost (usernames in config),
1197
+ // this is best-effort — admin bots should use both username and user ID in allowlists.
1198
+ const botInfo = getPlatformBots(platformName).get(botName);
1199
+ if (!checkUserAccess(reaction.userId, reaction.userId, botInfo?.access, getPlatformAccess(platformName))) {
1200
+ log.debug(`User ${reaction.userId} denied reaction access to bot "${botName}"`);
1201
+ return;
1202
+ }
1028
1203
  const resolved = getAdapterForChannel(reaction.channelId);
1029
1204
  if (!resolved)
1030
1205
  return;
@@ -1079,6 +1254,7 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1079
1254
  // Handle custom bridge events (permissions, user input)
1080
1255
  if (event.type === 'bridge.permission_request') {
1081
1256
  const streamKey = activeStreams.get(channelId);
1257
+ const threadRootId = streamKey ? streaming.getStreamThreadRootId(streamKey) : undefined;
1082
1258
  if (streamKey) {
1083
1259
  await streaming.finalizeStream(streamKey);
1084
1260
  activeStreams.delete(channelId);
@@ -1086,11 +1262,12 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1086
1262
  await finalizeActivityFeed(channelId, adapter);
1087
1263
  const { toolName, serverName, input, commands } = event.data;
1088
1264
  const formatted = formatPermissionRequest(toolName, input, commands, serverName);
1089
- await adapter.sendMessage(channelId, formatted);
1265
+ await adapter.sendMessage(channelId, formatted, { threadRootId });
1090
1266
  return;
1091
1267
  }
1092
1268
  if (event.type === 'bridge.user_input_request') {
1093
1269
  const streamKey = activeStreams.get(channelId);
1270
+ const threadRootId = streamKey ? streaming.getStreamThreadRootId(streamKey) : undefined;
1094
1271
  if (streamKey) {
1095
1272
  await streaming.finalizeStream(streamKey);
1096
1273
  activeStreams.delete(channelId);
@@ -1098,7 +1275,7 @@ async function handleSessionEvent(sessionId, channelId, event, sessionManager) {
1098
1275
  await finalizeActivityFeed(channelId, adapter);
1099
1276
  const { question, choices } = event.data;
1100
1277
  const formatted = formatUserInputRequest(question, choices);
1101
- await adapter.sendMessage(channelId, formatted);
1278
+ await adapter.sendMessage(channelId, formatted, { threadRootId });
1102
1279
  return;
1103
1280
  }
1104
1281
  // Format and route SDK events