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
13 changes: 10 additions & 3 deletions apps/desktop/src/main/__tests__/endpointManifestCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ describe('缓存端点的受信任域约束(安全边界)', () => {
const GLOBAL_BASE = 'https://hotfix.cindy.app/cindy';
const CN_BASE = 'https://hotfix.cindy.com.cn/cindy';
const TRUSTED = Object.values(REGION_ENDPOINT_DOMAIN);
/** CN 构建的策略:非跨区端点锁 cindy.com.cn,slack/telegram hook 才允许 cindy.app。 */
/** CN 构建的策略:非跨区端点锁 cindy.com.cn,slack/telegram/x hook 才允许 cindy.app。 */
const CN_POLICY = {
regionDomain: REGION_ENDPOINT_DOMAIN.cn,
crossRegionDomain: REGION_ENDPOINT_DOMAIN.global,
Expand All @@ -165,9 +165,13 @@ describe('缓存端点的受信任域约束(安全边界)', () => {
expect(REGION_ENDPOINT_DOMAIN.global).toBe('cindy.app');
});

it('跨区例外只有 slack / telegram hook 两个 key', () => {
it('跨区例外只有 slack / telegram / x hook 三个 key', () => {
// 每加一个 key 就等于允许该端点跨区,而跨区 token 误发正是要防的事。
expect([...CROSS_REGION_ENDPOINT_KEYS].sort()).toEqual(['slackHookWsUrl', 'telegramHookWsUrl']);
expect([...CROSS_REGION_ENDPOINT_KEYS].sort()).toEqual([
'slackHookWsUrl',
'telegramHookWsUrl',
'xHookWsUrl',
]);
});

it('CN 构建拒绝换成 Global 真实服务的伪造缓存(跨区 token 误发)', () => {
Expand Down Expand Up @@ -223,6 +227,9 @@ describe('缓存端点的受信任域约束(安全边界)', () => {
authApiBaseUrl: 'https://auth.cindy.com.cn',
slackHookWsUrl: 'wss://slack-hook.cindy.app',
telegramHookWsUrl: 'wss://telegram-hook.cindy.app',
// CN 清单按 Telegram 同款单部署模式放量 X 时,离线缓存回退必须仍受信
// (PR #1230 review:漏登记会让 CN 用户断网时失去缓存启动出口)。
xHookWsUrl: 'wss://x-hook.cindy.app',
websiteUrl: 'https://cindy.com.cn',
cdnBaseUrl: 'https://hotfix.cindy.com.cn/cindy',
authDesktopCallbackUrl: 'https://auth.cindy.com.cn/api/auth/desktop/callback',
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/main/endpointManifestCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,14 +234,15 @@ export const REGION_ENDPOINT_DOMAIN: Readonly<Record<'cn' | 'global', string>> =
};

/**
* 跨区共享的 hook 服务:两份清单(含 CN)都指向 cindy.app,所以只有这两个 key 允许
* 落在 Global 域。**别往这里加 key** —— 每加一个就等于允许该端点跨区,而这个集合之外
* 跨区共享的 hook 服务:两份清单(含 CN)都指向 cindy.app,所以只有这几个 hook key
* 允许落在 Global 域。**别往这里加 key** —— 每加一个就等于允许该端点跨区,而这个集合之外
* 的所有端点(尤其 auth / device-link / oauth-broker / model-access / voice)必须锁在
* 本构建区域,否则就回到上面说的跨区 token 误发。
*/
export const CROSS_REGION_ENDPOINT_KEYS: ReadonlySet<string> = new Set([
'slackHookWsUrl',
'telegramHookWsUrl',
'xHookWsUrl',
]);

/** 缓存端点的来源策略:按 key 决定它允许落在哪个域。 */
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop/src/main/hook-control/__tests__/outbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,19 @@ describe('buildHookPromptNote', () => {
expect(buildHookPromptNote('slack')).not.toContain('[Telegram 回复格式]');
});

it('X 平台名正确且给出纯文本回帖格式约束, 不复用 Telegram/Slack 提示', () => {
const x = buildHookPromptNote('x');
expect(x).toContain('本会话来自 X。');
expect(x).toContain('[X 回复格式]');
expect(x).toContain('纯文本');
expect(x).not.toContain('[Telegram 回复格式]');
expect(x).not.toContain('本会话来自 Slack');
// 未接线前的回归写法: x 曾被三元兜底误标成 Slack。
expect(buildHookPromptNote('slack')).not.toContain('[X 回复格式]');
});

it('两个平台都在开头声明「不是用户消息」,防止模型把渠道说明当成用户请求(2026-07 实踩)', () => {
for (const im of ['telegram', 'slack'] as const) {
for (const im of ['telegram', 'slack', 'x'] as const) {
const note = buildHookPromptNote(im);
// guard 必须在附件正文之前出现,才能在模型读到附件指令前先定性。
expect(note).toContain('不是用户发来的消息');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,13 @@ describe('默认态与持久化', () => {
expect(store.get()).toEqual({
enabled: false,
telegramEnabled: false,
xEnabled: false,
urlOverride: null,
workspaces: {},
bindingsCache: [],
lifecycleAnnouncementOverride: null,
telegramBindingCache: null,
xBindingCache: null,
});
expect(store.effectiveUrl()).toBe(TEST_DEFAULT_URL);
});
Expand Down Expand Up @@ -168,11 +170,13 @@ describe('旧多连接文件迁移', () => {
expect(makeStore().get()).toEqual({
enabled: false,
telegramEnabled: false,
xEnabled: false,
urlOverride: null,
workspaces: {},
bindingsCache: [],
lifecycleAnnouncementOverride: null,
telegramBindingCache: null,
xBindingCache: null,
});
});
});
Expand Down Expand Up @@ -249,7 +253,7 @@ describe('provider 与 Cindy 账号隔离', () => {

store.setEnabled(true);
store.setProviderEnabled('telegram', true);
store.setTelegramBindingCache(telegramBinding);
store.setProviderBindingCache('telegram', telegramBinding);
store.setWorkspaces({ cindy: abs });

expect(store.anyProviderEnabled()).toBe(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,30 +60,38 @@ function memoryStore(initial: Partial<SlackHookConfigState> & { url: string }):
let state: SlackHookConfigState = {
enabled: initial.enabled ?? true,
telegramEnabled: initial.telegramEnabled ?? false,
xEnabled: initial.xEnabled ?? false,
urlOverride: initial.url,
workspaces: initial.workspaces ?? {},
bindingsCache: initial.bindingsCache ?? [],
lifecycleAnnouncementOverride: initial.lifecycleAnnouncementOverride ?? null,
telegramBindingCache: initial.telegramBindingCache ?? null,
xBindingCache: initial.xBindingCache ?? null,
};
return {
get: () => ({
...state,
workspaces: { ...state.workspaces },
bindingsCache: state.bindingsCache.map((e) => ({ ...e })),
telegramBindingCache: state.telegramBindingCache ? { ...state.telegramBindingCache } : null,
xBindingCache: state.xBindingCache ? { ...state.xBindingCache } : null,
}),
effectiveUrl: () => state.urlOverride ?? 'wss://unused.example',
setEnabled(enabled) {
state = { ...state, enabled };
return state;
},
setProviderEnabled(provider, enabled) {
state = provider === 'slack' ? { ...state, enabled } : { ...state, telegramEnabled: enabled };
state =
provider === 'slack'
? { ...state, enabled }
: provider === 'x'
? { ...state, xEnabled: enabled }
: { ...state, telegramEnabled: enabled };
return state;
},
anyProviderEnabled() {
return state.enabled || state.telegramEnabled;
return state.enabled || state.telegramEnabled || state.xEnabled;
},
setWorkspaces(workspaces) {
state = { ...state, workspaces };
Expand All @@ -97,8 +105,11 @@ function memoryStore(initial: Partial<SlackHookConfigState> & { url: string }):
state = { ...state, lifecycleAnnouncementOverride: enabled };
return state;
},
setTelegramBindingCache(entry) {
state = { ...state, telegramBindingCache: entry ? { ...entry } : null };
setProviderBindingCache(provider, entry) {
state =
provider === 'x'
? { ...state, xBindingCache: entry ? { ...entry } : null }
: { ...state, telegramBindingCache: entry ? { ...entry } : null };
return state;
},
};
Expand All @@ -112,6 +123,8 @@ function makeManager(
store,
createTransport: createHookTransport,
getTelegramUrl: () => store.effectiveUrl(),
// X lane 默认不配端点(未部署形态), 相关用例用 overrides 显式注入。
getXUrl: () => '',
getAuthToken: async () => 'jwt-token-1',
refreshAuthToken: async () => false,
deviceInfo: () => ({ deviceId: 'dev-1', deviceName: 'TestBox' }),
Expand Down Expand Up @@ -504,13 +517,44 @@ describe('provider dispatch boundary', () => {
expect(providerForTaskDispatch({ externalKey: 'telegram:dm:bot-1:user-1:g0' })).toBeNull();
});

it('routes X dispatches only when source and lane key agree, failing closed on mismatch', () => {
expect(
providerForTaskDispatch({
externalKey: 'x:conv:999:conv-1:111:g1',
source: { im: 'x' },
}),
).toBe('x');
// source/key 任一缺失或错配一律 fail closed —— X 不得继承 Slack 语义。
expect(providerForTaskDispatch({ externalKey: 'x:conv:999:conv-1:111:g1' })).toBeNull();
expect(
providerForTaskDispatch({
externalKey: 'x:conv:999:conv-1:111:g1',
source: { im: 'slack' },
}),
).toBeNull();
expect(
providerForTaskDispatch({
externalKey: 'x:conv:999:conv-1:111:g1',
source: { im: 'telegram' },
}),
).toBeNull();
expect(providerForTaskDispatch({ externalKey: 'T1:C1:1.1', source: { im: 'x' } })).toBeNull();
expect(
providerForTaskDispatch({
externalKey: 'telegram:dm:bot-1:user-1:g0',
source: { im: 'x' },
}),
).toBeNull();
});

it('routes only known provider lane keys for session archive', () => {
expect(providerForExternalKey('slack:dm:T1:U1:g2')).toBe('slack');
expect(providerForExternalKey('dm:U1:g2')).toBe('slack');
expect(providerForExternalKey('dm:T1:U1:g2')).toBe('slack');
expect(providerForExternalKey('team-slack:C1:1.1')).toBe('slack');
expect(providerForExternalKey('T1:C1:1.1')).toBe('slack');
expect(providerForExternalKey('telegram:dm:bot:user:g2')).toBe('telegram');
expect(providerForExternalKey('x:conv:999:conv-1:111:g1')).toBe('x');
expect(providerForExternalKey('discord:channel-1')).toBeNull();
expect(providerForExternalKey('arbitrary')).toBeNull();
});
Expand Down Expand Up @@ -2152,7 +2196,7 @@ describe('Telegram provider capability, binding and prefs', () => {
it('本地绑定缓存写失败时仍保留并广播服务端确认态', async () => {
const { wss, url } = await startServer();
const store = memoryStore({ url, enabled: false, telegramEnabled: true });
store.setTelegramBindingCache = () => {
store.setProviderBindingCache = () => {
throw new Error('disk full');
};
const warnings: string[] = [];
Expand Down Expand Up @@ -2433,15 +2477,15 @@ describe('Telegram provider capability, binding and prefs', () => {
principalId: 'telegram-user-1',
scopeId: 'bot-1',
});
await expect(manager.openTelegramAction('connect')).resolves.toBe(false);
await expect(manager.openTelegramAction('provider')).resolves.toBe(true);
await expect(manager.openTelegramAction('add-to-group')).resolves.toBe(true);
await expect(manager.openProviderAction('telegram', 'connect')).resolves.toBe(false);
await expect(manager.openProviderAction('telegram', 'provider')).resolves.toBe(true);
await expect(manager.openProviderAction('telegram', 'add-to-group')).resolves.toBe(true);
expect(opened).toEqual([
'https://t.me/cindy_example_bot',
'https://t.me/cindy_example_bot?startgroup=true',
]);
rejectOpen = true;
await expect(manager.openTelegramAction('provider')).rejects.toThrow('no system URL handler');
await expect(manager.openProviderAction('telegram', 'provider')).rejects.toThrow('no system URL handler');

const prefs = {
provider: 'telegram' as const,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ describe('workspaceProviderSourceStore', () => {
it('写读一条来源偏好;不同渠道/目录互不串', () => {
setWorkspaceProviderSource('telegram', null, 'chat', 'anthropic');
setWorkspaceProviderSource('slack', null, 'chat', 'openai');
setWorkspaceProviderSource('x', null, 'chat', 'google');
expect(getWorkspaceProviderSource('telegram', null, 'chat')).toBe('anthropic');
expect(getWorkspaceProviderSource('slack', null, 'chat')).toBe('openai');
expect(getWorkspaceProviderSource('x', null, 'chat')).toBe('google');
expect(getWorkspaceProviderSource('telegram', null, 'repo')).toBeNull();
expect(getWorkspaceProviderSource('x', null, 'repo')).toBeNull();
});

it('teamId 精确匹配优先, null 行兜底(multi-team 宽松语义)', () => {
Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/src/main/hook-control/__tests__/xDeepLink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';

import {
parseXConnectUrl,
validateXExternalUrl,
xProfileUrl,
xProfileUrlOrNull,
} from '../xDeepLink';

const AUTHORIZE_QS =
'response_type=code&client_id=client-1&redirect_uri=https%3A%2F%2Fx-hook.example%2Fx%2Foauth%2Fcallback&scope=tweet.read%20tweet.write&state=state-abc_123&code_challenge=challenge-abc_123&code_challenge_method=S256';
const VALID_AUTHORIZE = `https://x.com/i/oauth2/authorize?${AUTHORIZE_QS}`;

describe('parseXConnectUrl', () => {
it('accepts the canonical X OAuth2 (PKCE) authorize URL', () => {
expect(parseXConnectUrl(VALID_AUTHORIZE).url).toContain('https://x.com/i/oauth2/authorize?');
});

it.each([
['whitespace padding', ` ${VALID_AUTHORIZE}`],
['http downgrade', VALID_AUTHORIZE.replace('https:', 'http:')],
['wrong host', VALID_AUTHORIZE.replace('x.com', 'twitter.com')],
['host lookalike', VALID_AUTHORIZE.replace('x.com', 'x.com.evil.example')],
['embedded credentials', VALID_AUTHORIZE.replace('https://x.com', 'https://user:pw@x.com')],
['explicit default port', VALID_AUTHORIZE.replace('https://x.com', 'https://x.com:443')],
['fragment', `${VALID_AUTHORIZE}#frag`],
['empty fragment delimiter', `${VALID_AUTHORIZE}#`],
['wrong path', VALID_AUTHORIZE.replace('/i/oauth2/authorize', '/i/oauth2/authorize/extra')],
['unexpected parameter', `${VALID_AUTHORIZE}&prompt=none`],
['repeated parameter', `${VALID_AUTHORIZE}&state=state-2`],
['missing parameter', VALID_AUTHORIZE.replace('&state=state-abc_123', '')],
['empty parameter', VALID_AUTHORIZE.replace('state=state-abc_123', 'state=')],
['implicit flow', VALID_AUTHORIZE.replace('response_type=code', 'response_type=token')],
['plain PKCE challenge', VALID_AUTHORIZE.replace('code_challenge_method=S256', 'code_challenge_method=plain')],
['not a URL', 'not a url'],
])('rejects %s', (_label, input) => {
expect(() => parseXConnectUrl(input)).toThrow(/X (binding URL|link)/);
});
});

describe('xProfileUrl', () => {
it('builds the bot profile URL and strips a leading @', () => {
expect(xProfileUrl('@CindyBot')).toBe('https://x.com/CindyBot');
expect(xProfileUrl('CindyBot')).toBe('https://x.com/CindyBot');
});

it('rejects malformed handles; the null-safe variant swallows them', () => {
expect(() => xProfileUrl('has space')).toThrow(/Invalid X handle/);
expect(() => xProfileUrl('way-too-long-handle-x')).toThrow(/Invalid X handle/);
expect(xProfileUrlOrNull('has space')).toBeNull();
expect(xProfileUrlOrNull(null)).toBeNull();
expect(xProfileUrlOrNull('@CindyBot')).toBe('https://x.com/CindyBot');
});
});

describe('validateXExternalUrl', () => {
it('accepts the authorize URL and bare profile URLs, nothing else', () => {
expect(validateXExternalUrl(VALID_AUTHORIZE)).toContain('/i/oauth2/authorize?');
expect(validateXExternalUrl('https://x.com/CindyBot')).toBe('https://x.com/CindyBot');
// profile URL 不允许携带 query / 编码路径 / 多段路径
expect(() => validateXExternalUrl('https://x.com/CindyBot?ref=1')).toThrow();
expect(() => validateXExternalUrl('https://x.com/%43indyBot')).toThrow();
expect(() => validateXExternalUrl('https://x.com/CindyBot/status/1')).toThrow();
expect(() => validateXExternalUrl('https://t.me/CindyBot')).toThrow();
});
});
4 changes: 3 additions & 1 deletion apps/desktop/src/main/hook-control/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1531,7 +1531,9 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher {
payload.prompt,
bareKey,
payload.source?.teamName ??
(payload.source?.im === 'telegram' ? payload.source.channelName : null),
(payload.source?.im === 'telegram' || payload.source?.im === 'x'
? payload.source.channelName
: null),
),
prompt: payload.prompt,
attachments: payload.attachments,
Expand Down
Loading
Loading