diff --git a/src/hooks/toolPermission/handlers/__tests__/interactiveHandler.test.ts b/src/hooks/toolPermission/handlers/__tests__/interactiveHandler.test.ts new file mode 100644 index 000000000..69693ad73 --- /dev/null +++ b/src/hooks/toolPermission/handlers/__tests__/interactiveHandler.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test' +import { getLatestChannelContextHint } from '../interactiveHandler.js' + +describe('getLatestChannelContextHint', () => { + test('extracts source server and chat id from latest channel user message', () => { + expect( + getLatestChannelContextHint([ + { + type: 'user', + origin: { kind: 'channel', server: 'plugin:weixin:weixin' }, + message: { + content: [ + { + type: 'text', + text: '\nhello\n', + }, + ], + }, + }, + ]), + ).toEqual({ + sourceServer: 'plugin:weixin:weixin', + chatId: 'user-1', + }) + }) + + test('returns null when there is no channel-origin user message', () => { + expect( + getLatestChannelContextHint([ + { + type: 'user', + origin: { kind: 'manual' }, + message: { content: [{ type: 'text', text: 'hello' }] }, + }, + ]), + ).toBeNull() + }) +}) diff --git a/src/hooks/toolPermission/handlers/interactiveHandler.ts b/src/hooks/toolPermission/handlers/interactiveHandler.ts index 8255d5d87..e7cb7a85d 100644 --- a/src/hooks/toolPermission/handlers/interactiveHandler.ts +++ b/src/hooks/toolPermission/handlers/interactiveHandler.ts @@ -1,6 +1,7 @@ import { feature } from 'bun:bundle' import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' import { randomUUID } from 'crypto' +import { CHANNEL_TAG } from 'src/constants/xml.js' import { logForDebugging } from 'src/utils/debug.js' import { getAllowedChannels } from '../../../bootstrap/state.js' import type { BridgePermissionCallbacks } from '../../../bridge/bridgePermissionCallbacks.js' @@ -46,6 +47,76 @@ type InteractivePermissionParams = { channelCallbacks?: ChannelPermissionCallbacks } +type ChannelContextHint = { + sourceServer?: string + chatId?: string +} + +function getTextBlocksText(content: unknown): string { + if (typeof content === 'string') { + return content + } + if (!Array.isArray(content)) { + return '' + } + return content + .filter( + (block): block is { type: 'text'; text: string } => + typeof block === 'object' && + block !== null && + (block as { type?: unknown }).type === 'text' && + typeof (block as { text?: unknown }).text === 'string', + ) + .map(block => block.text) + .join('\n') +} + +function parseChannelContextHintFromText(text: string): ChannelContextHint | null { + const tagMatch = text.match(new RegExp(`<${CHANNEL_TAG}\\b([^>]*)>`)) + if (!tagMatch?.[1]) { + return null + } + + const attrs = tagMatch[1] + const sourceServer = attrs.match(/\bsource="([^"]+)"/)?.[1] + const chatId = attrs.match(/\bchat_id="([^"]+)"/)?.[1] + + if (!sourceServer && !chatId) { + return null + } + + return { sourceServer, chatId } +} + +export function getLatestChannelContextHint(messages: readonly unknown[]): ChannelContextHint | null { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index] as { + type?: unknown + origin?: { kind?: unknown; server?: unknown } + message?: { content?: unknown } + } + + if (message?.type !== 'user' || message?.origin?.kind !== 'channel') { + continue + } + + const text = getTextBlocksText(message.message?.content) + const parsed = parseChannelContextHintFromText(text) + if (parsed) { + return { + sourceServer: + parsed.sourceServer || + (typeof message.origin.server === 'string' + ? message.origin.server + : undefined), + chatId: parsed.chatId, + } + } + } + + return null +} + /** * Handles the interactive (main-agent) permission flow. * @@ -420,6 +491,17 @@ function handleInteractivePermission( description, input_preview: truncateForPreview(displayInput), } + const channelContext = getLatestChannelContextHint( + ctx.toolUseContext.messages, + ) + if (channelContext?.sourceServer || channelContext?.chatId) { + params.channel_context = { + ...(channelContext.sourceServer && { + source_server: channelContext.sourceServer, + }), + ...(channelContext.chatId && { chat_id: channelContext.chatId }), + } + } for (const client of channelClients) { if (client.type !== 'connected') continue // refine for TS diff --git a/src/services/mcp/__tests__/channelPermissions.test.ts b/src/services/mcp/__tests__/channelPermissions.test.ts index fcdba4d8c..552eb776f 100644 --- a/src/services/mcp/__tests__/channelPermissions.test.ts +++ b/src/services/mcp/__tests__/channelPermissions.test.ts @@ -5,6 +5,7 @@ mock.module("src/services/analytics/growthbook.js", () => ({ })); const { + filterPermissionRelayClients, shortRequestId, truncateForPreview, PERMISSION_REPLY_RE, @@ -160,3 +161,34 @@ describe("createChannelPermissionCallbacks", () => { expect(received?.behavior).toBe("deny"); }); }); + +describe("filterPermissionRelayClients", () => { + test("requires truthy permission capability", () => { + const clients = [ + { + type: "connected", + name: "plugin:weixin:weixin", + capabilities: { + experimental: { + "claude/channel": {}, + "claude/channel/permission": false, + }, + }, + }, + { + type: "connected", + name: "plugin:telegram:telegram", + capabilities: { + experimental: { + "claude/channel": {}, + "claude/channel/permission": {}, + }, + }, + }, + ]; + + expect( + filterPermissionRelayClients(clients, () => true).map(client => client.name), + ).toEqual(["plugin:telegram:telegram"]); + }); +}); diff --git a/src/services/mcp/channelNotification.ts b/src/services/mcp/channelNotification.ts index 5e7ede9ee..975bca285 100644 --- a/src/services/mcp/channelNotification.ts +++ b/src/services/mcp/channelNotification.ts @@ -91,8 +91,33 @@ export type ChannelPermissionRequestParams = { * input is in the local terminal dialog; this is a phone-sized * preview. Server decides whether/how to show it. */ input_preview: string + /** Optional source-channel routing hint for servers that support + * multi-chat routing. Backwards compatible: servers that don't care can + * ignore it and keep their existing fallback behavior. */ + channel_context?: { + source_server?: string + chat_id?: string + } } +export const ChannelPermissionRequestNotificationSchema = lazySchema(() => + z.object({ + method: z.literal(CHANNEL_PERMISSION_REQUEST_METHOD), + params: z.object({ + request_id: z.string(), + tool_name: z.string(), + description: z.string(), + input_preview: z.string(), + channel_context: z + .object({ + source_server: z.string().optional(), + chat_id: z.string().optional(), + }) + .optional(), + }), + }), +) + /** * Meta keys become XML attribute NAMES — a crafted key like * `x="" injected="y` would break out of the attribute structure. Only diff --git a/src/services/mcp/channelPermissions.ts b/src/services/mcp/channelPermissions.ts index 1a2a65f00..de84fbc12 100644 --- a/src/services/mcp/channelPermissions.ts +++ b/src/services/mcp/channelPermissions.ts @@ -34,7 +34,7 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' * don't apply until restart. */ export function isChannelPermissionRelayEnabled(): boolean { - return getFeatureValue_CACHED_MAY_BE_STALE('tengu_harbor_permissions', false) + return getFeatureValue_CACHED_MAY_BE_STALE('tengu_harbor_permissions', true) } export type ChannelPermissionResponse = { @@ -188,8 +188,8 @@ export function filterPermissionRelayClients< (c): c is T & { type: 'connected' } => c.type === 'connected' && isInAllowlist(c.name) && - c.capabilities?.experimental?.['claude/channel'] !== undefined && - c.capabilities?.experimental?.['claude/channel/permission'] !== undefined, + Boolean(c.capabilities?.experimental?.['claude/channel']) && + Boolean(c.capabilities?.experimental?.['claude/channel/permission']), ) } diff --git a/src/services/mcp/useManageMCPConnections.ts b/src/services/mcp/useManageMCPConnections.ts index 070a8b09f..c7124c63d 100644 --- a/src/services/mcp/useManageMCPConnections.ts +++ b/src/services/mcp/useManageMCPConnections.ts @@ -538,7 +538,7 @@ export function useManageMCPConnections( if ( client.capabilities?.experimental?.[ 'claude/channel/permission' - ] !== undefined + ] ) { client.client.setNotificationHandler( ChannelPermissionNotificationSchema(),