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

@@ -44,12 +44,10 @@ export function getChannelAllowlist(): ChannelAllowlistEntry[] {
}
/**
* Overall channels on/off. Checked before any per-server gating —
* when false, --channels is a no-op and no handlers register.
* Default false; GrowthBook 5-min refresh.
* Overall channels on/off. Always enabled — GrowthBook gate bypassed.
*/
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 { CHANNEL_TAG } from '../../constants/xml.js'
import {
getClaudeAIOAuthTokens,
getSubscriptionType,
} from '../../utils/auth.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
// --channels to push inbound this session — protects against a trusted
// 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' }

View File

@@ -470,147 +470,145 @@ export function useManageMCPConnections(
// Channel push: notifications/claude/channel → enqueue().
// Gate decides whether to register the handler; connection stays
// up either way (allowedMcpServers controls that).
if (feature('KAIROS') || feature('KAIROS_CHANNELS')) {
const gate = gateChannelServer(
client.name,
client.capabilities,
client.config.pluginSource,
)
const entry = findChannelEntry(client.name, getAllowedChannels())
// Plugin identifier for telemetry — log name@marketplace for any
// plugin-kind entry (same tier as tengu_plugin_installed, which
// logs arbitrary plugin_id+marketplace_name ungated). server-kind
// names are MCP-server-name tier; those are opt-in-only elsewhere
// (see isAnalyticsToolDetailsLoggingEnabled in metadata.ts) and
// stay unlogged here. is_dev/entry_kind segment the rest.
const pluginId =
entry?.kind === 'plugin'
? (`${entry.name}@${entry.marketplace}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
: undefined
// Skip capability-miss — every non-channel MCP server trips it.
if (gate.action === 'register' || gate.kind !== 'capability') {
logEvent('tengu_mcp_channel_gate', {
registered: gate.action === 'register',
skip_kind:
gate.action === 'skip'
? (gate.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
: undefined,
entry_kind:
entry?.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
is_dev: entry?.dev ?? false,
plugin: pluginId,
})
}
switch (gate.action) {
case 'register':
logMCPDebug(client.name, 'Channel notifications registered')
const gate = gateChannelServer(
client.name,
client.capabilities,
client.config.pluginSource,
)
const entry = findChannelEntry(client.name, getAllowedChannels())
// Plugin identifier for telemetry — log name@marketplace for any
// plugin-kind entry (same tier as tengu_plugin_installed, which
// logs arbitrary plugin_id+marketplace_name ungated). server-kind
// names are MCP-server-name tier; those are opt-in-only elsewhere
// (see isAnalyticsToolDetailsLoggingEnabled in metadata.ts) and
// stay unlogged here. is_dev/entry_kind segment the rest.
const pluginId =
entry?.kind === 'plugin'
? (`${entry.name}@${entry.marketplace}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
: undefined
// Skip capability-miss — every non-channel MCP server trips it.
if (gate.action === 'register' || gate.kind !== 'capability') {
logEvent('tengu_mcp_channel_gate', {
registered: gate.action === 'register',
skip_kind:
gate.action === 'skip'
? (gate.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS)
: undefined,
entry_kind:
entry?.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
is_dev: entry?.dev ?? false,
plugin: pluginId,
})
}
switch (gate.action) {
case 'register':
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(
ChannelMessageNotificationSchema(),
ChannelPermissionNotificationSchema(),
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(
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
// 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(
ChannelPermissionNotificationSchema(),
async notification => {
const { request_id, behavior } = notification.params
const resolved =
channelPermCallbacksRef.current?.resolve(
request_id,
behavior,
client.name,
) ?? false
logMCPDebug(
client.name,
`notifications/claude/channel/permission: ${request_id}${behavior} (${resolved ? 'matched pending' : 'no pending entry — stale or unknown ID'})`,
)
},
)
}
break
case 'skip':
// Idempotent teardown so a register→skip re-gate (e.g.
// effect re-runs after /logout) actually removes the live
// handler. Without this, mid-session demotion is one-way:
// the gate says skip but the earlier handler keeps enqueuing.
// Map.delete — safe when never registered.
client.client.removeNotificationHandler(
'notifications/claude/channel',
)
client.client.removeNotificationHandler(
CHANNEL_PERMISSION_METHOD,
)
logMCPDebug(
client.name,
`Channel notifications skipped: ${gate.reason}`,
)
// Surface a once-per-kind toast when a channel server is
// blocked. This is the only
// user-visible signal (logMCPDebug above requires --debug).
// Capability/session skips are expected noise and stay
// debug-only. marketplace/allowlist run after session — if
// we're here with those kinds, the user asked for it.
if (
gate.kind !== 'capability' &&
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
}
}
break
case 'skip':
// Idempotent teardown so a register→skip re-gate (e.g.
// effect re-runs after /logout) actually removes the live
// handler. Without this, mid-session demotion is one-way:
// the gate says skip but the earlier handler keeps enqueuing.
// Map.delete — safe when never registered.
client.client.removeNotificationHandler(
'notifications/claude/channel',
)
client.client.removeNotificationHandler(
CHANNEL_PERMISSION_METHOD,
)
logMCPDebug(
client.name,
`Channel notifications skipped: ${gate.reason}`,
)
// Surface a once-per-kind toast when a channel server is
// blocked. This is the only
// user-visible signal (logMCPDebug above requires --debug).
// Capability/session skips are expected noise and stay
// debug-only. marketplace/allowlist run after session — if
// we're here with those kinds, the user asked for it.
if (
gate.kind !== 'capability' &&
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