feat: 恢复 --channels 能力 (#297)

* feat: 恢复  --channels 能力

* docs: 添加 channels 注释
This commit is contained in:
claude-code-best
2026-04-19 10:24:34 +08:00
committed by GitHub
parent c5edee431f
commit 481e2a58a9
10 changed files with 319 additions and 479 deletions

View File

@@ -22,6 +22,7 @@
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) | | **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) | | **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 | | **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord 等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) |
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) | | **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) | | Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) | | Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |

52
docs/features/channels.md Normal file
View File

@@ -0,0 +1,52 @@
# Channels — 外部频道消息接入
> 启动参数:`--channels` / `--dangerously-load-development-channels`
> 状态:已解除 feature flag 和 OAuth 限制,可直接使用
## 概述
Channel 是一个 MCP 服务器,它将外部事件推送到你运行中的 Claude Code 会话中,以便 Claude 可以在你不在终端时做出反应。详细使用说明请参考以下文档:
- **官方文档**[使用 channels 将事件推送到运行中的会话](https://code.claude.com/docs/zh-CN/channels)
- **飞书插件**[claude-code-feishu-channel](https://github.com/whobot-ai/claude-code-feishu-channel) — 社区首个飞书 Channel 插件,支持双向消息、配对认证、群组聊天、文件附件
## 快速开始
```bash
# 启用频道监听plugin 格式)
ccb --channels plugin:feishu@claude-code-feishu-channel
# 启用频道监听server 格式)
ccb --channels server:my-slack-bridge
# 同时启用多个频道
ccb --channels plugin:feishu@claude-code-feishu-channel --channels server:discord-bot
# 开发模式(跳过 allowlist 检查,用于测试自定义 channel
ccb --dangerously-load-development-channels server:my-custom-channel
```
## 支持的 Channel
| Channel | 说明 | 来源 |
|---------|------|------|
| **Telegram** | 官方 Telegram Bot 集成 | `/plugin install telegram@claude-plugins-official` |
| **Discord** | 官方 Discord Bot 集成 | `/plugin install discord@claude-plugins-official` |
| **iMessage** | macOS 原生消息 | `/plugin install imessage@claude-plugins-official` |
| **飞书 (Feishu/Lark)** | 双向消息、群组聊天、文件附件 | `/plugin install feishu@claude-code-feishu-channel` |
## 相关文件
| 文件 | 职责 |
|------|------|
| `src/services/mcp/channelNotification.ts` | 频道 gate 逻辑、消息包装 |
| `src/services/mcp/channelAllowlist.ts` | 频道开关(已默认开启) |
| `src/services/mcp/useManageMCPConnections.ts` | MCP 连接管理中的频道注册 |
| `src/components/LogoV2/ChannelsNotice.tsx` | 启动时频道状态提示 |
| `src/main.tsx` | `--channels` 参数解析 |
| `src/interactiveHelpers.tsx` | Dev channels 确认对话框 |
## 参考链接
- [官方 Channels 文档](https://code.claude.com/docs/zh-CN/channels) — 完整使用说明、安全性、Enterprise 控制
- [飞书 Channel 插件](https://github.com/whobot-ai/claude-code-feishu-channel) — 安装配置教程、MCP 工具、Skill 命令参考

View File

@@ -135,6 +135,7 @@
"group": "运行模式", "group": "运行模式",
"pages": [ "pages": [
"docs/features/kairos", "docs/features/kairos",
"docs/features/channels",
"docs/features/voice-mode", "docs/features/voice-mode",
"docs/features/bridge-mode", "docs/features/bridge-mode",
"docs/features/remote-control-self-hosting", "docs/features/remote-control-self-hosting",

View File

@@ -4948,7 +4948,7 @@ function handleChannelEnable(
function reregisterChannelHandlerAfterReconnect( function reregisterChannelHandlerAfterReconnect(
connection: MCPServerConnection, connection: MCPServerConnection,
): void { ): void {
if (!(feature('KAIROS') || feature('KAIROS_CHANNELS'))) return // Channels always available — feature flag guard removed
if (connection.type !== 'connected') return if (connection.type !== 'connected') return
const gate = gateChannelServer( const gate = gateChannelServer(

View File

@@ -12,50 +12,27 @@ import {
getHasDevChannels, getHasDevChannels,
} from '../../bootstrap/state.js' } from '../../bootstrap/state.js'
import { Box, Text } from '@anthropic/ink' import { Box, Text } from '@anthropic/ink'
import { isChannelsEnabled } from '../../services/mcp/channelAllowlist.js'
import { getEffectiveChannelAllowlist } from '../../services/mcp/channelNotification.js'
import { getMcpConfigsByScope } from '../../services/mcp/config.js' import { getMcpConfigsByScope } from '../../services/mcp/config.js'
import {
getClaudeAIOAuthTokens,
getSubscriptionType,
} from '../../utils/auth.js'
import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js' import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'
import { getSettingsForSource } from '../../utils/settings/settings.js'
export function ChannelsNotice(): React.ReactNode { export function ChannelsNotice(): React.ReactNode {
// Snapshot all reads at mount. This notice enters scrollback immediately // Snapshot all reads at mount. This notice enters scrollback immediately
// after the logo; any re-render past that point forces a full terminal // after the logo; any re-render past that point forces a full terminal
// reset. getAllowedChannels (bootstrap state), getSettingsForSource // reset.
// (session cache updated by background polling / /login), and const [{ channels, list, unmatched }] =
// isChannelsEnabled (GrowthBook 5-min refresh) must be captured once
// so a later re-render cannot flip branches.
const [{ channels, disabled, noAuth, policyBlocked, list, unmatched }] =
useState(() => { useState(() => {
const ch = getAllowedChannels() const ch = getAllowedChannels()
if (ch.length === 0) if (ch.length === 0)
return { return {
channels: ch, channels: ch,
disabled: false,
noAuth: false,
policyBlocked: false,
list: '', list: '',
unmatched: [] as Unmatched[], unmatched: [] as Unmatched[],
} }
const l = ch.map(formatEntry).join(', ') const l = ch.map(formatEntry).join(', ')
const sub = getSubscriptionType()
const managed = sub === 'team' || sub === 'enterprise'
const policy = getSettingsForSource('policySettings')
const allowlist = getEffectiveChannelAllowlist(
sub,
policy?.allowedChannelPlugins,
)
return { return {
channels: ch, channels: ch,
disabled: !isChannelsEnabled(),
noAuth: !getClaudeAIOAuthTokens()?.accessToken,
policyBlocked: managed && policy?.channelsEnabled !== true,
list: l, list: l,
unmatched: findUnmatched(ch, allowlist), unmatched: findUnmatched(ch),
} }
}) })
if (channels.length === 0) return null if (channels.length === 0) return null
@@ -70,50 +47,6 @@ export function ChannelsNotice(): React.ReactNode {
? '--dangerously-load-development-channels' ? '--dangerously-load-development-channels'
: '--channels' : '--channels'
if (disabled) {
return (
<Box paddingLeft={2} flexDirection="column">
<Text color="error">
{flag} ignored ({list})
</Text>
<Text dimColor>Channels are not currently available</Text>
</Box>
)
}
if (noAuth) {
return (
<Box paddingLeft={2} flexDirection="column">
<Text color="error">
{flag} ignored ({list})
</Text>
<Text dimColor>
Channels require claude.ai authentication · run /login, then restart
</Text>
</Box>
)
}
if (policyBlocked) {
return (
<Box paddingLeft={2} flexDirection="column">
<Text color="error">
{flag} blocked by org policy ({list})
</Text>
<Text dimColor>Inbound messages will be silently dropped</Text>
<Text dimColor>
Have an administrator set channelsEnabled: true in managed settings to
enable
</Text>
{unmatched.map(u => (
<Text key={`${formatEntry(u.entry)}:${u.why}`} color="warning">
{formatEntry(u.entry)} · {u.why}
</Text>
))}
</Box>
)
}
// "Listening for" not "active" — at this point we only know the allowlist // "Listening for" not "active" — at this point we only know the allowlist
// was set. Server connection, capability declaration, and whether the name // was set. Server connection, capability declaration, and whether the name
// even matches a configured MCP server are all still unknown. // even matches a configured MCP server are all still unknown.
@@ -144,7 +77,6 @@ type Unmatched = { entry: ChannelEntry; why: string }
function findUnmatched( function findUnmatched(
entries: readonly ChannelEntry[], entries: readonly ChannelEntry[],
allowlist: ReturnType<typeof getEffectiveChannelAllowlist>,
): Unmatched[] { ): Unmatched[] {
// Server-kind: build one Set from all scopes up front. getMcpConfigsByScope // Server-kind: build one Set from all scopes up front. getMcpConfigsByScope
// is not cached (project scope walks the dir tree); getMcpConfigByName would // is not cached (project scope walks the dir tree); getMcpConfigByName would
@@ -163,46 +95,17 @@ function findUnmatched(
Object.keys(loadInstalledPluginsV2().plugins), Object.keys(loadInstalledPluginsV2().plugins),
) )
// Plugin-kind allowlist check: same {marketplace, plugin} test as the
// gate at channelNotification.ts. entry.dev bypasses (dev flag opts out
// of the allowlist). Org list replaces ledger when set (team/enterprise).
// GrowthBook _CACHED_MAY_BE_STALE — cold cache yields [] so every plugin
// entry warns; same tradeoff the gate already accepts.
const { entries: allowed, source } = allowlist
// Independent ifs — a plugin entry that's both uninstalled AND
// unlisted shows two lines. Server kind checks config + dev flag.
const out: Unmatched[] = [] const out: Unmatched[] = []
for (const entry of entries) { for (const entry of entries) {
if (entry.kind === 'server') { if (entry.kind === 'server') {
if (!configured.has(entry.name)) { if (!configured.has(entry.name)) {
out.push({ entry, why: 'no MCP server configured with that name' }) out.push({ entry, why: 'no MCP server configured with that name' })
} }
if (!entry.dev) {
out.push({
entry,
why: 'server: entries need --dangerously-load-development-channels',
})
}
continue continue
} }
if (!installedPluginIds.has(`${entry.name}@${entry.marketplace}`)) { if (!installedPluginIds.has(`${entry.name}@${entry.marketplace}`)) {
out.push({ entry, why: 'plugin not installed' }) out.push({ entry, why: 'plugin not installed' })
} }
if (
!entry.dev &&
!allowed.some(
e => e.plugin === entry.name && e.marketplace === entry.marketplace,
)
) {
out.push({
entry,
why:
source === 'org'
? "not on your org's approved channels list"
: 'not on the approved channels allowlist',
})
}
} }
return out return out
} }

View File

@@ -332,59 +332,25 @@ export async function showSetupScreens(
// dev channels to any --channels list already set in main.tsx. Org policy // dev channels to any --channels list already set in main.tsx. Org policy
// is NOT bypassed — gateChannelServer() still runs; this flag only exists // is NOT bypassed — gateChannelServer() still runs; this flag only exists
// to sidestep the --channels approved-server allowlist. // to sidestep the --channels approved-server allowlist.
if (feature('KAIROS') || feature('KAIROS_CHANNELS')) { if (devChannels && devChannels.length > 0) {
// gateChannelServer and ChannelsNotice read tengu_harbor after this const { DevChannelsDialog } = await import(
// function returns. A cold disk cache (fresh install, or first run after './components/DevChannelsDialog.js'
// the flag was added server-side) defaults to false and silently drops )
// channel notifications for the whole session — gh#37026. await showSetupDialog(root, done => (
// checkGate_CACHED_OR_BLOCKING returns immediately if disk already says <DevChannelsDialog
// true; only blocks on a cold/stale-false cache (awaits the same memoized channels={devChannels}
// initializeGrowthBook promise fired earlier). Also warms the onAccept={() => {
// isChannelsEnabled() check in the dev-channels dialog below. // Mark dev entries per-entry so the allowlist bypass doesn't leak
if (getAllowedChannels().length > 0 || (devChannels?.length ?? 0) > 0) { // to --channels entries when both flags are passed.
await checkGate_CACHED_OR_BLOCKING('tengu_harbor') setAllowedChannels([
} ...getAllowedChannels(),
...devChannels.map(c => ({ ...c, dev: true })),
if (devChannels && devChannels.length > 0) { ])
const [{ isChannelsEnabled }, { getClaudeAIOAuthTokens }] = setHasDevChannels(true)
await Promise.all([ void done()
import('./services/mcp/channelAllowlist.js'), }}
import('./utils/auth.js'), />
]) ))
// Skip the dialog when channels are blocked (tengu_harbor off or no
// OAuth) — accepting then immediately seeing "not available" in
// ChannelsNotice is worse than no dialog. Append entries anyway so
// ChannelsNotice renders the blocked branch with the dev entries
// named. dev:true here is for the flag label in ChannelsNotice
// (hasNonDev check); the allowlist bypass it also grants is moot
// since the gate blocks upstream.
if (!isChannelsEnabled() || !getClaudeAIOAuthTokens()?.accessToken) {
setAllowedChannels([
...getAllowedChannels(),
...devChannels.map(c => ({ ...c, dev: true })),
])
setHasDevChannels(true)
} else {
const { DevChannelsDialog } = await import(
'./components/DevChannelsDialog.js'
)
await showSetupDialog(root, done => (
<DevChannelsDialog
channels={devChannels}
onAccept={() => {
// Mark dev entries per-entry so the allowlist bypass doesn't leak
// to --channels entries when both flags are passed.
setAllowedChannels([
...getAllowedChannels(),
...devChannels.map(c => ({ ...c, dev: true })),
])
setHasDevChannels(true)
void done()
}}
/>
))
}
}
} }
// Show Chrome onboarding for first-time Claude in Chrome users // Show Chrome onboarding for first-time Claude in Chrome users

View File

@@ -2558,111 +2558,109 @@ async function run(): Promise<CommanderCommand> {
// devChannels is deferred: showSetupScreens shows a confirmation dialog // devChannels is deferred: showSetupScreens shows a confirmation dialog
// and only appends to allowedChannels on accept. // and only appends to allowedChannels on accept.
let devChannels: ChannelEntry[] | undefined; let devChannels: ChannelEntry[] | undefined;
if (feature("KAIROS") || feature("KAIROS_CHANNELS")) { // Parse plugin:name@marketplace / server:Y tags into typed entries.
// Parse plugin:name@marketplace / server:Y tags into typed entries. // Tag decides trust model downstream: plugin-kind hits marketplace
// Tag decides trust model downstream: plugin-kind hits marketplace // verification + GrowthBook allowlist, server-kind always fails
// verification + GrowthBook allowlist, server-kind always fails // allowlist (schema is plugin-only) unless dev flag is set.
// allowlist (schema is plugin-only) unless dev flag is set. // Untagged or marketplace-less plugin entries are hard errors —
// Untagged or marketplace-less plugin entries are hard errors — // silently not-matching in the gate would look like channels are
// silently not-matching in the gate would look like channels are // "on" but nothing ever fires.
// "on" but nothing ever fires. const parseChannelEntries = (
const parseChannelEntries = ( raw: string[],
raw: string[], flag: string,
flag: string, ): ChannelEntry[] => {
): ChannelEntry[] => { const entries: ChannelEntry[] = [];
const entries: ChannelEntry[] = []; const bad: string[] = [];
const bad: string[] = []; for (const c of raw) {
for (const c of raw) { if (c.startsWith("plugin:")) {
if (c.startsWith("plugin:")) { const rest = c.slice(7);
const rest = c.slice(7); const at = rest.indexOf("@");
const at = rest.indexOf("@"); if (at <= 0 || at === rest.length - 1) {
if (at <= 0 || at === rest.length - 1) {
bad.push(c);
} else {
entries.push({
kind: "plugin",
name: rest.slice(0, at),
marketplace: rest.slice(at + 1),
});
}
} else if (c.startsWith("server:") && c.length > 7) {
entries.push({ kind: "server", name: c.slice(7) });
} else {
bad.push(c); bad.push(c);
} else {
entries.push({
kind: "plugin",
name: rest.slice(0, at),
marketplace: rest.slice(at + 1),
});
} }
} else if (c.startsWith("server:") && c.length > 7) {
entries.push({ kind: "server", name: c.slice(7) });
} else {
bad.push(c);
} }
if (bad.length > 0) { }
process.stderr.write( if (bad.length > 0) {
chalk.red( process.stderr.write(
`${flag} entries must be tagged: ${bad.join(", ")}\n` + chalk.red(
` plugin:<name>@<marketplace> — plugin-provided channel (allowlist enforced)\n` + `${flag} entries must be tagged: ${bad.join(", ")}\n` +
` server:<name> — manually configured MCP server\n`, ` plugin:<name>@<marketplace> — plugin-provided channel (allowlist enforced)\n` +
), ` server:<name> — manually configured MCP server\n`,
); ),
process.exit(1);
}
return entries;
};
const channelOpts = options as {
channels?: string[];
dangerouslyLoadDevelopmentChannels?: string[];
};
const rawChannels = channelOpts.channels;
const rawDev = channelOpts.dangerouslyLoadDevelopmentChannels;
// Always parse + set. ChannelsNotice reads getAllowedChannels() and
// renders the appropriate branch (disabled/noAuth/policyBlocked/
// listening) in the startup screen. gateChannelServer() enforces.
// --channels works in both interactive and print/SDK modes; dev-channels
// stays interactive-only (requires a confirmation dialog).
let channelEntries: ChannelEntry[] = [];
if (rawChannels && rawChannels.length > 0) {
channelEntries = parseChannelEntries(
rawChannels,
"--channels",
); );
setAllowedChannels(channelEntries); process.exit(1);
} }
if (!isNonInteractiveSession) { return entries;
if (rawDev && rawDev.length > 0) { };
devChannels = parseChannelEntries(
rawDev, const channelOpts = options as {
"--dangerously-load-development-channels", channels?: string[];
); dangerouslyLoadDevelopmentChannels?: string[];
} };
} const rawChannels = channelOpts.channels;
// Flag-usage telemetry. Plugin identifiers are logged (same tier as const rawDev = channelOpts.dangerouslyLoadDevelopmentChannels;
// tengu_plugin_installed — public-registry-style names); server-kind // Always parse + set. ChannelsNotice reads getAllowedChannels() and
// names are not (MCP-server-name tier, opt-in-only elsewhere). // renders the appropriate branch (disabled/noAuth/policyBlocked/
// Per-server gate outcomes land in tengu_mcp_channel_gate once // listening) in the startup screen. gateChannelServer() enforces.
// servers connect. Dev entries go through a confirmation dialog after // --channels works in both interactive and print/SDK modes; dev-channels
// this — dev_plugins captures what was typed, not what was accepted. // stays interactive-only (requires a confirmation dialog).
if ( let channelEntries: ChannelEntry[] = [];
channelEntries.length > 0 || if (rawChannels && rawChannels.length > 0) {
(devChannels?.length ?? 0) > 0 channelEntries = parseChannelEntries(
) { rawChannels,
const joinPluginIds = (entries: ChannelEntry[]) => { "--channels",
const ids = entries.flatMap((e) => );
e.kind === "plugin" setAllowedChannels(channelEntries);
? [`${e.name}@${e.marketplace}`] }
: [], if (!isNonInteractiveSession) {
); if (rawDev && rawDev.length > 0) {
return ids.length > 0 devChannels = parseChannelEntries(
? (ids rawDev,
.sort() "--dangerously-load-development-channels",
.join( );
",",
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
: undefined;
};
logEvent("tengu_mcp_channel_flags", {
channels_count: channelEntries.length,
dev_count: devChannels?.length ?? 0,
plugins: joinPluginIds(channelEntries),
dev_plugins: joinPluginIds(devChannels ?? []),
});
} }
} }
// Flag-usage telemetry. Plugin identifiers are logged (same tier as
// tengu_plugin_installed — public-registry-style names); server-kind
// names are not (MCP-server-name tier, opt-in-only elsewhere).
// Per-server gate outcomes land in tengu_mcp_channel_gate once
// servers connect. Dev entries go through a confirmation dialog after
// this — dev_plugins captures what was typed, not what was accepted.
if (
channelEntries.length > 0 ||
(devChannels?.length ?? 0) > 0
) {
const joinPluginIds = (entries: ChannelEntry[]) => {
const ids = entries.flatMap((e) =>
e.kind === "plugin"
? [`${e.name}@${e.marketplace}`]
: [],
);
return ids.length > 0
? (ids
.sort()
.join(
",",
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
: undefined;
};
logEvent("tengu_mcp_channel_flags", {
channels_count: channelEntries.length,
dev_count: devChannels?.length ?? 0,
plugins: joinPluginIds(channelEntries),
dev_plugins: joinPluginIds(devChannels ?? []),
});
}
// SDK opt-in for SendUserMessage via --tools. All sessions require // SDK opt-in for SendUserMessage via --tools. All sessions require
// explicit opt-in; listing it in --tools signals intent. Runs BEFORE // explicit opt-in; listing it in --tools signals intent. Runs BEFORE
@@ -5627,20 +5625,18 @@ async function run(): Promise<CommanderCommand> {
).hideHelp(), ).hideHelp(),
); );
} }
if (feature("KAIROS") || feature("KAIROS_CHANNELS")) { program.addOption(
program.addOption( new Option(
new Option( "--channels <servers...>",
"--channels <servers...>", "MCP servers whose channel notifications (inbound push) should register this session. Space-separated server names.",
"MCP servers whose channel notifications (inbound push) should register this session. Space-separated server names.", ).hideHelp(),
).hideHelp(), );
); program.addOption(
program.addOption( new Option(
new Option( "--dangerously-load-development-channels <servers...>",
"--dangerously-load-development-channels <servers...>", "Load channel servers not on the approved allowlist. For local channel development only. Shows a confirmation dialog at startup.",
"Load channel servers not on the approved allowlist. For local channel development only. Shows a confirmation dialog at startup.", ).hideHelp(),
).hideHelp(), );
);
}
// Teammate identity options (set by leader when spawning tmux teammates) // Teammate identity options (set by leader when spawning tmux teammates)
// These replace the CLAUDE_CODE_* environment variables // These replace the CLAUDE_CODE_* environment variables

View File

@@ -44,12 +44,10 @@ export function getChannelAllowlist(): ChannelAllowlistEntry[] {
} }
/** /**
* Overall channels on/off. Checked before any per-server gating — * Overall channels on/off. Always enabled — GrowthBook gate bypassed.
* when false, --channels is a no-op and no handlers register.
* Default false; GrowthBook 5-min refresh.
*/ */
export function isChannelsEnabled(): boolean { export function isChannelsEnabled(): boolean {
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_harbor', false) return true
} }
/** /**

View File

@@ -21,7 +21,6 @@ import { z } from 'zod/v4'
import { type ChannelEntry, getAllowedChannels } from '../../bootstrap/state.js' import { type ChannelEntry, getAllowedChannels } from '../../bootstrap/state.js'
import { CHANNEL_TAG } from '../../constants/xml.js' import { CHANNEL_TAG } from '../../constants/xml.js'
import { import {
getClaudeAIOAuthTokens,
getSubscriptionType, getSubscriptionType,
} from '../../utils/auth.js' } from '../../utils/auth.js'
import { lazySchema } from '../../utils/lazySchema.js' import { lazySchema } from '../../utils/lazySchema.js'
@@ -205,45 +204,6 @@ export function gateChannelServer(
} }
} }
// Overall runtime gate. After capability so normal MCP servers never hit
// this path. Before auth/policy so the killswitch works regardless of
// session state.
if (!isChannelsEnabled()) {
return {
action: 'skip',
kind: 'disabled',
reason: 'channels feature is not currently available',
}
}
// OAuth-only. API key users (console) are blocked — there's no
// channelsEnabled admin surface in console yet, so the policy opt-in
// flow doesn't exist for them. Drop this when console parity lands.
if (!getClaudeAIOAuthTokens()?.accessToken) {
return {
action: 'skip',
kind: 'auth',
reason: 'channels requires claude.ai authentication (run /login)',
}
}
// Teams/Enterprise opt-in. Managed orgs must explicitly enable channels.
// Default OFF — absent or false blocks. Keyed off subscription tier, not
// "policy settings exist" — a team org with zero configured policy keys
// (remote endpoint returns 404) is still a managed org and must not fall
// through to the unmanaged path.
const sub = getSubscriptionType()
const managed = sub === 'team' || sub === 'enterprise'
const policy = managed ? getSettingsForSource('policySettings') : undefined
if (managed && policy?.channelsEnabled !== true) {
return {
action: 'skip',
kind: 'policy',
reason:
'channels not enabled by org policy (set channelsEnabled: true in managed settings)',
}
}
// User-level session opt-in. A server must be explicitly listed in // User-level session opt-in. A server must be explicitly listed in
// --channels to push inbound this session — protects against a trusted // --channels to push inbound this session — protects against a trusted
// server surprise-adding the capability. // server surprise-adding the capability.
@@ -275,41 +235,6 @@ export function gateChannelServer(
} }
} }
// Approved-plugin allowlist. Marketplace gate already verified
// tag == reality, so this is a pure entry check. entry.dev (per-entry,
// not the session-wide bit) bypasses — so accepting the dev dialog for
// one entry doesn't leak allowlist-bypass to --channels entries.
if (!entry.dev) {
const { entries, source } = getEffectiveChannelAllowlist(
sub,
policy?.allowedChannelPlugins,
)
if (
!entries.some(
e => e.plugin === entry.name && e.marketplace === entry.marketplace,
)
) {
return {
action: 'skip',
kind: 'allowlist',
reason:
source === 'org'
? `plugin ${entry.name}@${entry.marketplace} is not on your org's approved channels list (set allowedChannelPlugins in managed settings)`
: `plugin ${entry.name}@${entry.marketplace} is not on the approved channels allowlist (use --dangerously-load-development-channels for local dev)`,
}
}
}
} else {
// server-kind: allowlist schema is {marketplace, plugin} — a server entry
// can never match. Without this, --channels server:plugin:foo:bar would
// match a plugin's runtime name and register with no allowlist check.
if (!entry.dev) {
return {
action: 'skip',
kind: 'allowlist',
reason: `server ${entry.name} is not on the approved channels allowlist (use --dangerously-load-development-channels for local dev)`,
}
}
} }
return { action: 'register' } return { action: 'register' }

View File

@@ -470,147 +470,145 @@ export function useManageMCPConnections(
// Channel push: notifications/claude/channel → enqueue(). // Channel push: notifications/claude/channel → enqueue().
// Gate decides whether to register the handler; connection stays // Gate decides whether to register the handler; connection stays
// up either way (allowedMcpServers controls that). // up either way (allowedMcpServers controls that).
if (feature('KAIROS') || feature('KAIROS_CHANNELS')) { const gate = gateChannelServer(
const gate = gateChannelServer( client.name,
client.name, client.capabilities,
client.capabilities, client.config.pluginSource,
client.config.pluginSource, )
) const entry = findChannelEntry(client.name, getAllowedChannels())
const entry = findChannelEntry(client.name, getAllowedChannels()) // Plugin identifier for telemetry — log name@marketplace for any
// Plugin identifier for telemetry — log name@marketplace for any // plugin-kind entry (same tier as tengu_plugin_installed, which
// plugin-kind entry (same tier as tengu_plugin_installed, which // logs arbitrary plugin_id+marketplace_name ungated). server-kind
// logs arbitrary plugin_id+marketplace_name ungated). server-kind // names are MCP-server-name tier; those are opt-in-only elsewhere
// names are MCP-server-name tier; those are opt-in-only elsewhere // (see isAnalyticsToolDetailsLoggingEnabled in metadata.ts) and
// (see isAnalyticsToolDetailsLoggingEnabled in metadata.ts) and // stay unlogged here. is_dev/entry_kind segment the rest.
// stay unlogged here. is_dev/entry_kind segment the rest. const pluginId =
const pluginId = entry?.kind === 'plugin'
entry?.kind === 'plugin' ? (`${entry.name}@${entry.marketplace}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
? (`${entry.name}@${entry.marketplace}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) : undefined
: undefined // Skip capability-miss — every non-channel MCP server trips it.
// Skip capability-miss — every non-channel MCP server trips it. if (gate.action === 'register' || gate.kind !== 'capability') {
if (gate.action === 'register' || gate.kind !== 'capability') { logEvent('tengu_mcp_channel_gate', {
logEvent('tengu_mcp_channel_gate', { registered: gate.action === 'register',
registered: gate.action === 'register', skip_kind:
skip_kind: gate.action === 'skip'
gate.action === 'skip' ? (gate.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
? (gate.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) : undefined,
: undefined, entry_kind:
entry_kind: entry?.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
entry?.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, is_dev: entry?.dev ?? false,
is_dev: entry?.dev ?? false, plugin: pluginId,
plugin: pluginId, })
}) }
} switch (gate.action) {
switch (gate.action) { case 'register':
case 'register': logMCPDebug(client.name, 'Channel notifications registered')
logMCPDebug(client.name, 'Channel notifications registered') client.client.setNotificationHandler(
ChannelMessageNotificationSchema(),
async notification => {
const { content, meta } = notification.params
logMCPDebug(
client.name,
`notifications/claude/channel: ${content.slice(0, 80)}`,
)
logEvent('tengu_mcp_channel_message', {
content_length: content.length,
meta_key_count: Object.keys(meta ?? {}).length,
entry_kind:
entry?.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
is_dev: entry?.dev ?? false,
plugin: pluginId,
})
enqueue({
mode: 'prompt',
value: wrapChannelMessage(client.name, content, meta),
priority: 'next',
isMeta: true,
origin: { kind: 'channel', server: client.name } as any,
skipSlashCommands: true,
})
},
)
// Permission-reply handler — separate event, separate
// capability. Only registers if the server declares
// claude/channel/permission (same opt-in check as the send
// path in interactiveHandler.ts). Server parses the user's
// reply and emits {request_id, behavior}; no regex on our
// side, text in the general channel can't accidentally match.
if (
client.capabilities?.experimental?.[
'claude/channel/permission'
] !== undefined
) {
client.client.setNotificationHandler( client.client.setNotificationHandler(
ChannelMessageNotificationSchema(), ChannelPermissionNotificationSchema(),
async notification => { async notification => {
const { content, meta } = notification.params const { request_id, behavior } = notification.params
const resolved =
channelPermCallbacksRef.current?.resolve(
request_id,
behavior,
client.name,
) ?? false
logMCPDebug( logMCPDebug(
client.name, client.name,
`notifications/claude/channel: ${content.slice(0, 80)}`, `notifications/claude/channel/permission: ${request_id}${behavior} (${resolved ? 'matched pending' : 'no pending entry — stale or unknown ID'})`,
) )
logEvent('tengu_mcp_channel_message', {
content_length: content.length,
meta_key_count: Object.keys(meta ?? {}).length,
entry_kind:
entry?.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
is_dev: entry?.dev ?? false,
plugin: pluginId,
})
enqueue({
mode: 'prompt',
value: wrapChannelMessage(client.name, content, meta),
priority: 'next',
isMeta: true,
origin: { kind: 'channel', server: client.name } as any,
skipSlashCommands: true,
})
}, },
) )
// Permission-reply handler — separate event, separate }
// capability. Only registers if the server declares break
// claude/channel/permission (same opt-in check as the send case 'skip':
// path in interactiveHandler.ts). Server parses the user's // Idempotent teardown so a register→skip re-gate (e.g.
// reply and emits {request_id, behavior}; no regex on our // effect re-runs after /logout) actually removes the live
// side, text in the general channel can't accidentally match. // handler. Without this, mid-session demotion is one-way:
if ( // the gate says skip but the earlier handler keeps enqueuing.
client.capabilities?.experimental?.[ // Map.delete — safe when never registered.
'claude/channel/permission' client.client.removeNotificationHandler(
] !== undefined 'notifications/claude/channel',
) { )
client.client.setNotificationHandler( client.client.removeNotificationHandler(
ChannelPermissionNotificationSchema(), CHANNEL_PERMISSION_METHOD,
async notification => { )
const { request_id, behavior } = notification.params logMCPDebug(
const resolved = client.name,
channelPermCallbacksRef.current?.resolve( `Channel notifications skipped: ${gate.reason}`,
request_id, )
behavior, // Surface a once-per-kind toast when a channel server is
client.name, // blocked. This is the only
) ?? false // user-visible signal (logMCPDebug above requires --debug).
logMCPDebug( // Capability/session skips are expected noise and stay
client.name, // debug-only. marketplace/allowlist run after session — if
`notifications/claude/channel/permission: ${request_id}${behavior} (${resolved ? 'matched pending' : 'no pending entry — stale or unknown ID'})`, // we're here with those kinds, the user asked for it.
) if (
}, gate.kind !== 'capability' &&
) gate.kind !== 'session' &&
} !channelWarnedKindsRef.current.has(gate.kind) &&
break (gate.kind === 'marketplace' ||
case 'skip': gate.kind === 'allowlist' ||
// Idempotent teardown so a register→skip re-gate (e.g. entry !== undefined)
// effect re-runs after /logout) actually removes the live ) {
// handler. Without this, mid-session demotion is one-way: channelWarnedKindsRef.current.add(gate.kind)
// the gate says skip but the earlier handler keeps enqueuing. // disabled/auth/policy get custom toast copy (shorter, actionable);
// Map.delete — safe when never registered. // marketplace/allowlist reuse the gate's reason verbatim
client.client.removeNotificationHandler( // since it already names the mismatch.
'notifications/claude/channel', const text =
) gate.kind === 'disabled'
client.client.removeNotificationHandler( ? 'Channels are not currently available'
CHANNEL_PERMISSION_METHOD, : gate.kind === 'auth'
) ? 'Channels require claude.ai authentication · run /login'
logMCPDebug( : gate.kind === 'policy'
client.name, ? 'Channels are not enabled for your org · have an administrator set channelsEnabled: true in managed settings'
`Channel notifications skipped: ${gate.reason}`, : gate.reason
) addNotification({
// Surface a once-per-kind toast when a channel server is key: `channels-blocked-${gate.kind}`,
// blocked. This is the only priority: 'high',
// user-visible signal (logMCPDebug above requires --debug). text,
// Capability/session skips are expected noise and stay color: 'warning',
// debug-only. marketplace/allowlist run after session — if timeoutMs: 12000,
// we're here with those kinds, the user asked for it. })
if ( }
gate.kind !== 'capability' && break
gate.kind !== 'session' &&
!channelWarnedKindsRef.current.has(gate.kind) &&
(gate.kind === 'marketplace' ||
gate.kind === 'allowlist' ||
entry !== undefined)
) {
channelWarnedKindsRef.current.add(gate.kind)
// disabled/auth/policy get custom toast copy (shorter, actionable);
// marketplace/allowlist reuse the gate's reason verbatim
// since it already names the mismatch.
const text =
gate.kind === 'disabled'
? 'Channels are not currently available'
: gate.kind === 'auth'
? 'Channels require claude.ai authentication · run /login'
: gate.kind === 'policy'
? 'Channels are not enabled for your org · have an administrator set channelsEnabled: true in managed settings'
: gate.reason
addNotification({
key: `channels-blocked-${gate.kind}`,
priority: 'high',
text,
color: 'warning',
timeoutMs: 12000,
})
}
break
}
} }
// Register notification handlers for list_changed notifications // Register notification handlers for list_changed notifications