diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d85635a..2741d602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ product overview. ## Unreleased +- Fix the agent directory returning an empty list on a connected Slack deployment: register + the live Slack client for discovery and follow disconnect/reconnect. Preserve native Slack + automation delivery, including image uploads, menus and reminder permalinks. + - Update Claude Code to 2.1.306 and offer Sonnet 5.5 in the model picker, with its native 1M context window and verified external-usage cache rates. Existing saved models remain valid. diff --git a/FEATURES.md b/FEATURES.md index c8289cd5..2c9d10ab 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -12,6 +12,8 @@ - Built-in reconciliation refreshes channel metadata at startup and nightly at 03:00 in the host local timezone without an engine turn or user schedule. Metadata caches narrow keyword searches; returned recommendations still verify live metadata/membership and fresh gateway access. + The daemon registers its existing Slack lifecycle manager in the live connector registry; + disconnects fail closed and reconnects resolve the current client without retaining old readers. - Visibility protects the reply's audience as well as its requester. DMs, group DMs, hidden, archived and Lean agents are excluded. Private destinations cannot appear in public/shared sources; other results require the whole current source roster to belong to the destination. diff --git a/TEST-PLAN.md b/TEST-PLAN.md index e187e6ea..568a8e8a 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -2,6 +2,14 @@ ## Channel agent directory (2026-10-21) +- [x] Production boot integration: `test/agent-directory-slack-boot.test.js` uses the real Slack + manager and `createPlatformTransports({ slack })` with a scratch store and simulated provider. + Search uses its default verifier (no directory/connector override), returns the current channel + and eligible specialist after connect, fails closed after disconnect despite cached metadata, + and uses the replacement client after reconnect. Native automation retains the raw Slack + WebClient for menus, images, prefixes and permalink APIs. Live acceptance must exercise the + actual daemon boot registration, not just an injected membership provider. + - [x] Regression: `test/agent-directory.test.js`, `test/agent-directory-refresh.test.js` and `test/agent-directory-tools.test.js` exercise live shared grant/template/dependency resolution, source and destination authorization, personal dependency suppression, audience proof, diff --git a/scripts/security-coverage.mjs b/scripts/security-coverage.mjs index 60e5a89e..11aab5eb 100644 --- a/scripts/security-coverage.mjs +++ b/scripts/security-coverage.mjs @@ -13,7 +13,7 @@ const areas = [ { name: "agent-directory", floor: [98, 83, 95], include: ["src/gateway/agent-directory.js", "src/gateway/agent-directory-refresh.js", "src/slack/agent-directory.js", "src/mcp/tools/agent-directory.js"], - tests: ["test/agent-directory.test.js", "test/agent-directory-refresh.test.js", "test/agent-directory-tools.test.js"], + tests: ["test/agent-directory.test.js", "test/agent-directory-refresh.test.js", "test/agent-directory-tools.test.js", "test/agent-directory-slack-boot.test.js"], }, { name: "plugin-packages", floor: [95, 85, 90], diff --git a/src/platforms/boot.js b/src/platforms/boot.js index f2e826be..0b3d0261 100644 --- a/src/platforms/boot.js +++ b/src/platforms/boot.js @@ -59,7 +59,18 @@ async function startTeamsTransport(config, log) { return startTeams({ ...config, capabilities, getActivationReactions: () => getMentionReactions('msteams'), getActivationVersion: getTeamsMentionReactionsUpdatedAt, onMessage: ingest, onInvoke: controls.onInvoke, onStop: controls.stop, log, deps: { auth, api, connector } }); } -export function createPlatformTransports({ log = console } = {}) { +export function createPlatformTransports({ slack, log = console } = {}) { + // Slack retains its existing lifecycle manager, but also supplies the live connector seam + // used by read-only agent discovery. Resolve the CURRENT client on every access so a + // disconnect or credential rotation can never retain an old metadata/membership reader. + if (slack) registerTransport("slack", { + snapshot: () => slack.snapshot(), + getConnector: () => { + const client = slack.snapshot().connected ? slack.getClient() : null; + const adapter = requirePlatform("slack"); + return client ? adapter.createConnector(client, { capabilities: adapter.capabilities }) : null; + }, + }); const googlechat = createTransportManager({ platform: "googlechat", log, start: (config, lifecycle) => startGoogleChatTransport(config, log, lifecycle) }); const msteams = createTransportManager({ platform: "msteams", log, start: (config) => startTeamsTransport(config, log) }); registerTransport("googlechat", googlechat); diff --git a/src/platforms/notify.js b/src/platforms/notify.js index ceeea766..a1f36048 100644 --- a/src/platforms/notify.js +++ b/src/platforms/notify.js @@ -79,8 +79,11 @@ export async function postDirectMessage(target, { userId, text, blocks = null } // Chat/Teams result, and a Slack client must never receive a namespaced foreign conversation id. export function automationTarget(slackManager, conversationId) { const platform = platformOfConversation(conversationId); + // Keep native Slack delivery (menus, images, trusted prefixes and permalink APIs) on its + // WebClient path even when Slack also registers a live connector for directory reads. + const client = slackManager?.snapshot?.().connected ? slackManager.getClient?.() : null; + if (asConnector(client)?.platform === platform) return client; const live = liveConnector(platform); if (live && live.ready?.() !== false) return live; - const client = slackManager?.snapshot?.().connected ? slackManager.getClient?.() : null; - return asConnector(client)?.platform === platform ? client : null; + return null; } diff --git a/src/server.js b/src/server.js index 4768f634..7befc113 100644 --- a/src/server.js +++ b/src/server.js @@ -77,7 +77,7 @@ process.env.MCP_TOOL_TIMEOUT ||= "300000"; const slack = createSlackManager(); // Google Chat + Teams. Created at module scope alongside Slack so the managers exist (and are // registered as the platforms' live-connector source) before anything can ask for a connector. -const transports = createPlatformTransports(); +const transports = createPlatformTransports({ slack }); // A long-running gateway must survive a bad integration (e.g. an invalid Slack token producing // an async error). Log and keep running rather than exiting on these. diff --git a/test/agent-directory-slack-boot.test.js b/test/agent-directory-slack-boot.test.js new file mode 100644 index 00000000..ab50ba87 --- /dev/null +++ b/test/agent-directory-slack-boot.test.js @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ensureTestEnv } from "./helpers.js"; + +ensureTestEnv(); +const { createSlackManager } = await import("../src/slack/manager.js"); +const { createPlatformTransports } = await import("../src/platforms/boot.js"); +const { liveConnector, resetTransports } = await import("../src/platforms/live.js"); +const { automationTarget } = await import("../src/platforms/notify.js"); +const { reconcileAgentDirectory, readAgentDirectoryMetadata } = await import("../src/gateway/agent-directory-refresh.js"); +const { searchAgentDirectory } = await import("../src/gateway/agent-directory.js"); +const { setUser, upsertChannelEntry, defaultChannelMeta, saveChannelMeta } = await import("../src/config/store.js"); +const sourceId = "C_BOOT_SOURCE"; +const specialistId = "C_BOOT_SPECIALIST"; +const authorId = "U_BOOT_READER"; + +function webClient(purpose) { + const calls = []; + return { calls, + chat: { postMessage: async () => ({ channel: sourceId, ts: "1.1" }), getPermalink: async () => ({ permalink: "https://slack.example/thread" }) }, + conversations: { + info: async ({ channel }) => { + calls.push(channel); + return { ok: true, channel: { id: channel, name: channel === sourceId ? "boot-source" : "boot-specialist", is_channel: true, is_private: true, purpose: { value: purpose } } }; + }, + members: async () => ({ ok: true, members: [authorId, "U_BOOT_BOT"], response_metadata: {} }), + }, + }; +} + +test("daemon transport boot exposes Slack discovery and follows disconnect/reconnect without changing native automation delivery", async () => { + resetTransports(); + await setUser(authorId, { approved: true }); + for (const [channelId, name] of [[sourceId, "boot-source"], [specialistId, "boot-specialist"]]) { + const entry = await upsertChannelEntry(channelId, { name, type: "group", isDM: false }); + await saveChannelMeta(entry.slug, defaultChannelMeta({ channelId, name, type: "group", isDM: false })); + } + const first = webClient("Presentation specialist"); + const second = webClient("Updated presentation specialist"); + let next = first; + const manager = createSlackManager({ start: async () => ({ app: { client: next, stop: async () => {} }, teamId: "T_BOOT", botUserId: "U_BOOT_BOT" }) }); + // This is the same boot entry the daemon calls, not an injected directory verifier/provider. + createPlatformTransports({ slack: manager }); + const lookup = () => searchAgentDirectory({ createdBy: authorId, channelId: sourceId, limit: 50 }); + try { + assert.equal(liveConnector("slack"), null); + assert.equal((await lookup()).returned, 0); + await manager.connect({}); + assert.equal(liveConnector("slack").client, first); + assert.equal(automationTarget(manager, sourceId), first, "native Slack delivery keeps menus, images and getPermalink"); + const initial = await lookup(); + assert.deepEqual(initial.agents.map((entry) => entry.channelId).sort(), [sourceId, specialistId].sort()); + assert.equal(initial.agents.find((entry) => entry.channelId === specialistId).description, "Presentation specialist"); + assert.match(initial.agents[0].url, /^https:\/\/slack\.com\/app_redirect/); + const refresh = await reconcileAgentDirectory(); + assert.equal(refresh.updated, 2); + assert.equal(readAgentDirectoryMetadata()[specialistId].purpose, "Presentation specialist"); + await manager.disconnect(); + assert.equal(liveConnector("slack"), null); + assert.equal(automationTarget(manager, sourceId), null); + assert.equal((await lookup()).returned, 0, "cache never substitutes for disconnected live proof"); + const oldReads = first.calls.length; + next = second; + await manager.connect({}); + assert.equal(automationTarget(manager, sourceId), second); + const updated = await lookup(); + assert.equal(updated.agents.find((entry) => entry.channelId === specialistId).description, "Updated presentation specialist"); + assert.equal(first.calls.length, oldReads, "old credentials/client are not retained by the registry"); + } finally { + await manager.disconnect(); + resetTransports(); + } +});