Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ product overview.

## Unreleased

- Find specialist channel agents with a live directory of purposes, examples and shared skills.
Managers can describe/hide their channel's agent. Metadata refreshes at startup and nightly;
recommendations respect the requester and reply audience and never transfer work automatically.

- Show connected Composio apps and accounts on admin user pages and channel Connections pages,
with account statuses, shared connection source, refresh controls and explicit provider limitations.

Expand Down
18 changes: 18 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# ChannelGate — Features

## Channel agent directory

- `search_agents` finds registered specialist channel agents by purpose, examples and effective
shared skills, with channel links and bounded pagination. Current organization, template,
channel-section and explicit channel grants/dependencies resolve at lookup; personal skills
never contribute. Managers describe or hide their own agent with `set_channel_agent_profile`.
- The always-injected `gateway-usage` guide checks the directory when a specialist may help and
recommends a channel without sending messages, delegating or transferring files. Organization
skills can supply company terminology; no company directory is hardcoded in the product skill.
- 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.
- 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.
A one-to-one bot DM uses its requester's access. Admin status never substitutes for membership.
Slack supplies the membership interface; unsupported/disconnected platforms fail closed.

## Composio connection visibility

- Admin UI user pages list that person's personal Composio applications and accounts. Channel
Expand Down
34 changes: 34 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
# ChannelGate — Test Plan

## Channel agent directory (2026-10-21)

- [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,
membership pagination/failure, profile bounds and scoped writes, both engine MCP contexts,
loopback-secret host IPC, reduced toolsets, nightly timers and cancellation. Cached metadata
narrows keyword work but never authorizes disclosure. Dedicated security coverage guards this
cross-channel boundary.
- [ ] Live **Claude and Codex**, separately, in owned Read fixtures, with an owned specialist
destination whose complete roster includes the private source's roster: set a presentation
purpose/shared slide skill, then ask “Can you help me create a presentation? Is there an
existing specialist agent?” and “List the agents and their skills.” **Pass:** `search_agents`
returns the expected purpose, current shared skills and HTTPS channel link, the answer
recommends it, pagination works, and no message/invitation/delegation/file transfer occurs.
Record exact source/target IDs, revision, engine/model, tool result and thread evidence.
- [ ] Live **Claude and Codex** privacy fixture: include a personal skill/dependency, hidden/Lean
channel, DM/group DM and a private specialist the requester belongs to but another source
reader does not. Repeat from a public/shared source, revoke shared grants/use/membership, and
interrupt the membership provider. **Pass:** no forbidden names/links/descriptions/hit counts;
personal skills never contribute; subsequent lookups respect changes; admin rank and cached
metadata cannot bypass audience proof. Unsupported platforms return no stale disclosure.
- [ ] Live **Claude and Codex** manager fixture: describe this agent and set example tasks;
hide/unhide it; clear examples; retry as a non-manager. **Pass:** only this channel changes,
omitted fields and unrelated grants/access persist, changes are announced, non-managers are
refused and the audit stores changed keys without descriptive content. Restore fixture state.
- [ ] Isolated daemon fixture, **engine-independent**: prove startup and host-local 03:00 refresh,
local-calendar/DST rearming, overlap deduplication, stop cancellation, rename/removal updates,
bounded cache/no memberships and disconnected-provider behavior. **Pass:** no engine session
or user schedule is created, and cached descriptors never become access authority. Platform
names/purposes used for keyword preselection may wait for the next nightly metadata refresh;
current curated profiles and shared skill changes are searched immediately.


## Composio connection inventory (2026-10-21)

- [x] Regression: `node --test test/composio-inventory.test.js test/composio-inventory-ui.test.js
Expand Down
5 changes: 5 additions & 0 deletions scripts/security-coverage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const help = spawnSync(process.execPath, ["--help"], { encoding: "utf8" }).stdout || "";
const branchFlag = help.includes("--test-coverage-branches") ? "--test-coverage-branches" : "--test-coverage-branch";
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"],
},
{
name: "plugin-packages", floor: [95, 85, 90],
include: ["src/gateway/plugin-runtime.js", "src/gateway/skills/plugin-package.js"],
Expand Down
158 changes: 158 additions & 0 deletions src/gateway/agent-directory-refresh.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Platform metadata is a cache for operator inspection, never an authorization source. Every
// recommendation obtains live metadata AND proves the whole response audience may see it.
import { getChannelsIndex } from "../config/store.js";
import { metaGet, metaSet } from "../db/index.js";
import { parseConversationId } from "../platforms/ids.js";
import { liveConnector } from "../platforms/live.js";
import { platformSupports } from "../platforms/registry.js";

export const AGENT_DIRECTORY_METADATA_KEY = "agent_directory_metadata_v1";
const MAX_CHANNELS = 10_000;
const MAX_MEMBERS = 10_000;
const bounded = (value, max) => typeof value === "string" ? value.slice(0, max) : "";

function descriptor(value, platform, verifiedAt) {
if (!value || !["channel", "group", "im", "mpim"].includes(value.type)) return null;
const purpose = bounded(value.purpose, 1000);
const topic = bounded(value.topic, 1000);
return {
platform, name: bounded(value.name, 160), purpose, topic,
description: [purpose, topic].filter(Boolean).join(" — ").slice(0, 2000),
url: bounded(value.url, 1000), type: value.type,
isDM: value.type === "im", isGroupDM: value.type === "mpim",
private: value.type !== "channel", shared: value.shared === true, archived: value.archived === true,
verifiedAt,
};
}

export function readAgentDirectoryMetadata({ read = metaGet } = {}) {
try {
const raw = read(AGENT_DIRECTORY_METADATA_KEY);
if (!raw || raw.length > 50_000_000) return {};
const parsed = JSON.parse(raw);
const out = {};
for (const [id, value] of Object.entries(parsed.channels || {}).slice(0, MAX_CHANNELS)) {
const clean = descriptor(value, bounded(value?.platform, 40), bounded(value?.verifiedAt, 40));
if (clean && ["channel", "group"].includes(clean.type)) out[id] = clean;
}
return out;
} catch { return {}; }
}

export async function reconcileAgentDirectory({
listChannels = getChannelsIndex, connectorFor = liveConnector, now = Date.now,
read = metaGet, write = metaSet, shouldStop = () => false,
} = {}) {
const index = await listChannels();
const previous = readAgentDirectoryMetadata({ read });
const channels = {};
const summary = { updated: 0, failed: 0, unsupported: 0, removed: 0 };
const verifiedAt = new Date(now()).toISOString();
const entries = Object.entries(index || {}).slice(0, MAX_CHANNELS);
for (const [channelId] of entries) {
if (shouldStop()) return { ...summary, cancelled: true };
const { platform, id } = parseConversationId(channelId);
const directory = platformSupports(platform, "agentDirectory") ? connectorFor(platform)?.agentDirectory : null;
if (!directory?.describeConversation) {
summary.unsupported += 1;
if (previous[channelId]) channels[channelId] = previous[channelId];
continue;
}
try {
const clean = descriptor(await directory.describeConversation({ conversationId: id }), platform, verifiedAt);
if (clean && ["channel", "group"].includes(clean.type)) { channels[channelId] = clean; summary.updated += 1; }
else if (clean) { /* DMs and group DMs have no directory entry, even in the metadata cache. */ }
else { summary.failed += 1; if (previous[channelId]) channels[channelId] = previous[channelId]; }
} catch {
summary.failed += 1;
if (previous[channelId]) channels[channelId] = previous[channelId];
}
}
if (shouldStop()) return { ...summary, cancelled: true };
summary.removed = Object.keys(previous).filter((id) => !Object.hasOwn(index || {}, id)).length;
write(AGENT_DIRECTORY_METADATA_KEY, JSON.stringify({ version: 1, updatedAt: verifiedAt, channels }));
return summary;
}

const eligibleDestination = (value) => value && !value.archived && ["channel", "group"].includes(value.type);
function memberSet(value) {
if (!Array.isArray(value) || !value.length || value.length > MAX_MEMBERS
|| value.some((id) => typeof id !== "string" || !id)) return null;
return new Set(value);
}

// A requester's membership alone cannot authorize disclosing a private channel to everybody in
// the source conversation. Public destinations use the same conservative audience proof.
export async function prepareDirectoryVerifier({ createdBy, channelId }, {
connectorFor = liveConnector, now = Date.now,
} = {}) {
const deny = async () => null;
if (!createdBy || !channelId) return deny;
const source = parseConversationId(channelId);
const directory = platformSupports(source.platform, "agentDirectory") ? connectorFor(source.platform)?.agentDirectory : null;
if (!directory?.describeConversation || !directory?.conversationMembers) return deny;
let sourceInfo, audience;
try {
sourceInfo = descriptor(await directory.describeConversation({ conversationId: source.id }), source.platform, new Date(now()).toISOString());
audience = memberSet(await directory.conversationMembers({ conversationId: source.id }));
} catch { return deny; }
if (!sourceInfo || sourceInfo.archived || sourceInfo.isGroupDM || !audience?.has(createdBy)
|| (sourceInfo.isDM && audience.size !== 2)) return deny;
return async (entry) => {
const target = parseConversationId(entry?.channelId);
if (!target.id || target.platform !== source.platform) return null;
if (entry.channelId === channelId) return eligibleDestination(sourceInfo) ? { visible: true, ...sourceInfo } : null;
try {
const info = descriptor(await directory.describeConversation({ conversationId: target.id }), target.platform, new Date(now()).toISOString());
if (!eligibleDestination(info)) return null;
// Public and shared sources can acquire readers beyond today's roster. Never disclose a
// private destination there, even if every current member happens to belong to it.
if (info.private && (sourceInfo.type === "channel" || sourceInfo.shared)) return null;
const members = memberSet(await directory.conversationMembers({ conversationId: target.id }));
if (!members || [...audience].some((id) => !members.has(id))) return null;
return { visible: true, ...info };
} catch { return null; }
};
}

export async function verifyDirectoryChannel({ entry, createdBy, channelId }, dependencies) {
return (await prepareDirectoryVerifier({ createdBy, channelId }, dependencies))(entry);
}

export function nextAgentDirectoryRefreshDelay(at = Date.now()) {
const next = new Date(at);
next.setHours(3, 0, 0, 0);
if (next.getTime() <= at) next.setDate(next.getDate() + 1);
return next.getTime() - at;
}

// Built-in daemon housekeeping: no engine turn, schedule row, or language-model spend. A new
// local-calendar deadline on each tick honors DST instead of drifting through a 24-hour interval.
export function startAgentDirectoryRefresh({
reconcile = reconcileAgentDirectory, now = Date.now,
setTimer = setTimeout, clearTimer = clearTimeout, log = console,
} = {}) {
let stopped = false;
let timer;
let running = null;
const refresh = () => {
if (stopped) return Promise.resolve(null);
if (!running) {
running = Promise.resolve().then(() => reconcile({ shouldStop: () => stopped }))
.catch(() => { if (!stopped) log.warn?.("[agent-directory] metadata refresh failed; live discovery remains fail closed"); })
.finally(() => { running = null; });
}
return running;
};
const arm = () => {
if (stopped) return;
timer = setTimer(() => { refresh().finally(arm); }, nextAgentDirectoryRefreshDelay(now()));
timer?.unref?.();
};
arm();
void refresh();
return {
refresh,
stop() { stopped = true; if (timer !== undefined) clearTimer(timer); },
};
}
Loading
Loading