File size: 7,182 Bytes
064bfd6
 
 
 
 
 
3199d61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
064bfd6
3199d61
 
 
 
 
 
 
 
064bfd6
3199d61
064bfd6
3199d61
 
 
 
 
 
 
 
 
 
064bfd6
3199d61
064bfd6
3199d61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
064bfd6
3199d61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
064bfd6
3199d61
064bfd6
3199d61
 
 
064bfd6
3199d61
 
 
 
 
 
 
064bfd6
 
 
3199d61
 
064bfd6
 
3199d61
064bfd6
 
 
 
 
3199d61
 
 
064bfd6
 
 
 
 
 
3199d61
064bfd6
 
 
3199d61
064bfd6
 
 
3199d61
064bfd6
 
 
 
3199d61
 
064bfd6
3199d61
064bfd6
 
3199d61
064bfd6
3199d61
 
 
 
 
 
064bfd6
 
3199d61
 
 
 
 
064bfd6
 
3199d61
064bfd6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
// Conditionally require()'d in LogoV2.tsx behind feature('KAIROS') ||
// feature('KAIROS_CHANNELS'). No feature() guard here — the whole file
// tree-shakes via the require pattern when both flags are false (see
// docs/feature-gating.md). Do NOT import this module statically from
// unguarded code.

import * as React from 'react'
import { useState } from 'react'
import {
  type ChannelEntry,
  getAllowedChannels,
  getHasDevChannels,
} from '../../bootstrap/state.js'
import { Box, Text } from '../../ink.js'
import { isChannelsEnabled } from '../../services/mcp/channelAllowlist.js'
import { getEffectiveChannelAllowlist } from '../../services/mcp/channelNotification.js'
import { getMcpConfigsByScope } from '../../services/mcp/config.js'
import {
  getClaudeAIOAuthTokens,
  getSubscriptionType,
} from '../../utils/auth.js'
import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'
import { getSettingsForSource } from '../../utils/settings/settings.js'

export function ChannelsNotice(): React.ReactNode {
  // Snapshot all reads at mount. This notice enters scrollback immediately
  // after the logo; any re-render past that point forces a full terminal
  // reset. getAllowedChannels (bootstrap state), getSettingsForSource
  // (session cache updated by background polling / /login), and
  // 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(() => {
      const ch = getAllowedChannels()
      if (ch.length === 0)
        return {
          channels: ch,
          disabled: false,
          noAuth: false,
          policyBlocked: false,
          list: '',
          unmatched: [] as Unmatched[],
        }
      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 {
        channels: ch,
        disabled: !isChannelsEnabled(),
        noAuth: !getClaudeAIOAuthTokens()?.accessToken,
        policyBlocked: managed && policy?.channelsEnabled !== true,
        list: l,
        unmatched: findUnmatched(ch, allowlist),
      }
    })
  if (channels.length === 0) return null

  // When both flags are passed, the list mixes entries and a single flag
  // name would be wrong for half of it. entry.dev distinguishes origin.
  const hasNonDev = channels.some(c => !c.dev)
  const flag =
    getHasDevChannels() && hasNonDev
      ? 'Channels'
      : getHasDevChannels()
        ? '--dangerously-load-development-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
  // was set. Server connection, capability declaration, and whether the name
  // even matches a configured MCP server are all still unknown.
  return (
    <Box paddingLeft={2} flexDirection="column">
      <Text color="error">Listening for channel messages from: {list}</Text>
      <Text dimColor>
        Experimental · inbound messages will be pushed into this session, this
        carries prompt injection risks. Restart Claude Code without {flag} to
        disable.
      </Text>
      {unmatched.map(u => (
        <Text key={`${formatEntry(u.entry)}:${u.why}`} color="warning">
          {formatEntry(u.entry)} · {u.why}
        </Text>
      ))}
    </Box>
  )
}

function formatEntry(c: ChannelEntry): string {
  return c.kind === 'plugin'
    ? `plugin:${c.name}@${c.marketplace}`
    : `server:${c.name}`
}

type Unmatched = { entry: ChannelEntry; why: string }

function findUnmatched(
  entries: readonly ChannelEntry[],
  allowlist: ReturnType<typeof getEffectiveChannelAllowlist>,
): Unmatched[] {
  // Server-kind: build one Set from all scopes up front. getMcpConfigsByScope
  // is not cached (project scope walks the dir tree); getMcpConfigByName would
  // redo that walk per entry.
  const scopes = ['enterprise', 'user', 'project', 'local'] as const
  const configured = new Set<string>()
  for (const scope of scopes) {
    for (const name of Object.keys(getMcpConfigsByScope(scope).servers)) {
      configured.add(name)
    }
  }

  // Plugin-kind installed check: installed_plugins.json keys are
  // `name@marketplace`. loadInstalledPluginsV2 is cached.
  const installedPluginIds = new Set(
    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[] = []
  for (const entry of entries) {
    if (entry.kind === 'server') {
      if (!configured.has(entry.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
    }
    if (!installedPluginIds.has(`${entry.name}@${entry.marketplace}`)) {
      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
}