diff --git a/apps/desktop/src/main/__tests__/bot-runtime-consistency-contract.test.ts b/apps/desktop/src/main/__tests__/bot-runtime-consistency-contract.test.ts index 74548e26ec..594cea05c3 100644 --- a/apps/desktop/src/main/__tests__/bot-runtime-consistency-contract.test.ts +++ b/apps/desktop/src/main/__tests__/bot-runtime-consistency-contract.test.ts @@ -23,7 +23,7 @@ async function readRepo(path: string): Promise { * `isImplemented(X) === true` * * Runtime-labeled platforms must ALSO be listed in - * `BOT_DELIVERY_PROVIDERS` (`packages/core/src/settings.ts`) so plan + * `BOT_DELIVERY_PROVIDERS` (`packages/core/src/bot-chat-settings.ts`) so plan * reminders can target them. The reverse is NOT required: a platform * can be delivery-capable without being `'runtime'` — WeChat is a * delivery target via the optional local wechat-bridge, but its diff --git a/apps/desktop/src/main/network-settings-main.ts b/apps/desktop/src/main/network-settings-main.ts index ba5a4b1ae8..d9ab135113 100644 --- a/apps/desktop/src/main/network-settings-main.ts +++ b/apps/desktop/src/main/network-settings-main.ts @@ -2,12 +2,12 @@ import type { AppSettings } from '@maka/core'; import { NETWORK_DEFAULTS, maskSensitive, - type NetworkSettings as ContractNetworkSettings, + type RuntimeNetworkSettings, } from '@maka/core/settings/network-settings'; type StoredNetworkSettings = AppSettings['network']; -export function toContractNetworkSettings(network: StoredNetworkSettings): ContractNetworkSettings { +export function toContractNetworkSettings(network: StoredNetworkSettings): RuntimeNetworkSettings { const proxy = network.proxy; return { ...NETWORK_DEFAULTS, @@ -24,9 +24,7 @@ export function toContractNetworkSettings(network: StoredNetworkSettings): Contr }; } - - -export function maskNetworkSettings(settings: ContractNetworkSettings): ContractNetworkSettings { +export function maskNetworkSettings(settings: RuntimeNetworkSettings): RuntimeNetworkSettings { return { ...settings, proxy: { @@ -35,4 +33,3 @@ export function maskNetworkSettings(settings: ContractNetworkSettings): Contract }, }; } - diff --git a/packages/core/src/__tests__/bot-chat-settings.test.ts b/packages/core/src/__tests__/bot-chat-settings.test.ts new file mode 100644 index 0000000000..67e52492ec --- /dev/null +++ b/packages/core/src/__tests__/bot-chat-settings.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + createDefaultBotChatSettings, + mergeBotChatSettings, + normalizeBotChatSettings, + parseAllowedUserIdsFromText, +} from '../bot-chat-settings.js'; + +describe('bot chat settings owner', () => { + test('preserves provider-specific defaults', () => { + const settings = createDefaultBotChatSettings(); + + assert.equal(settings.channels.telegram.proxyUrl, 'http://127.0.0.1:7890'); + assert.equal(settings.channels.wechat.webhookUrl, 'http://127.0.0.1:18400'); + assert.equal(settings.channels.discord.readiness, 'scaffolded'); + }); + + test('normalizes an explicitly patched allowlist without touching it on unrelated patches', () => { + const defaults = createDefaultBotChatSettings(); + const withAllowlist = mergeBotChatSettings(defaults, { + channels: { + telegram: { allowedUserIds: [' 123 ', '456', '123', ''] }, + }, + }); + const tokenPatched = mergeBotChatSettings(withAllowlist, { + channels: { telegram: { token: 'telegram-token' } }, + }); + + assert.deepEqual(withAllowlist.channels.telegram.allowedUserIds, ['123', '456']); + assert.strictEqual( + tokenPatched.channels.telegram.allowedUserIds, + withAllowlist.channels.telegram.allowedUserIds, + ); + }); + + test('preserves legacy readiness derivation and downgrade-only coercion', () => { + const legacy = createDefaultBotChatSettings(); + delete (legacy.channels.telegram as Partial).readiness; + legacy.channels.telegram.enabled = true; + legacy.channels.telegram.connected = true; + legacy.channels.telegram.token = 'telegram-token'; + + const legacyNormalized = normalizeBotChatSettings(legacy, legacy); + assert.equal(legacyNormalized.channels.telegram.readiness, 'credentials_valid'); + + legacyNormalized.channels.telegram.token = ''; + legacyNormalized.channels.telegram.readiness = 'operational'; + const cleared = normalizeBotChatSettings(legacyNormalized, legacyNormalized); + assert.equal(cleared.channels.telegram.readiness, 'scaffolded'); + + cleared.channels.telegram.token = 'new-token'; + cleared.channels.telegram.readiness = 'scaffolded'; + const credentialed = normalizeBotChatSettings(cleared, cleared); + assert.equal(credentialed.channels.telegram.readiness, 'scaffolded'); + }); + + test('parses textarea allowlists with trim, deduplication, and the defensive cap', () => { + const raw = [ + ' 123 ', + '456', + '123', + '', + ...Array.from({ length: 60 }, (_, i) => `user-${i}`), + ].join('\n'); + const parsed = parseAllowedUserIdsFromText(raw); + + assert.equal(parsed.length, 50); + assert.deepEqual(parsed.slice(0, 3), ['123', '456', 'user-0']); + assert.equal(parsed.at(-1), 'user-47'); + }); +}); diff --git a/packages/core/src/__tests__/plan-reminders.test.ts b/packages/core/src/__tests__/plan-reminders.test.ts index e2bb6268a7..ebe9c84731 100644 --- a/packages/core/src/__tests__/plan-reminders.test.ts +++ b/packages/core/src/__tests__/plan-reminders.test.ts @@ -1,6 +1,10 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { BOT_DELIVERY_PROVIDERS, BOT_PROVIDERS, isBotDeliveryProvider } from '../settings.js'; +import { + BOT_DELIVERY_PROVIDERS, + BOT_PROVIDERS, + isBotDeliveryProvider, +} from '../bot-chat-settings.js'; import { isPlanReminderDue, nextPlanReminderStateAfterTrigger, diff --git a/packages/core/src/__tests__/settings-extraction-contract.test.ts b/packages/core/src/__tests__/settings-extraction-contract.test.ts new file mode 100644 index 0000000000..370422ee2e --- /dev/null +++ b/packages/core/src/__tests__/settings-extraction-contract.test.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { describe, test } from 'node:test'; +import * as botChatSettings from '../bot-chat-settings.js'; +import * as core from '../index.js'; +import { + NETWORK_DEFAULTS, + type NetworkSettings as LegacyRuntimeNetworkSettings, + type RuntimeNetworkSettings, +} from '../settings/network-settings.js'; +import * as settings from '../settings.js'; +import type { + AppNetworkSettings, + AppSettings, + NetworkSettings as LegacyAppNetworkSettings, +} from '../settings.js'; + +const REPO_ROOT = resolveRepoRoot(); + +async function readRepo(path: string): Promise { + return readFile(join(REPO_ROOT, path), 'utf8'); +} + +function resolveRepoRoot(): string { + const cwd = resolve(process.cwd()); + if (existsSync(join(cwd, 'packages', 'core', 'src', 'settings.ts'))) return cwd; + const fromWorkspace = resolve(cwd, '..', '..'); + if (existsSync(join(fromWorkspace, 'packages', 'core', 'src', 'settings.ts'))) + return fromWorkspace; + return cwd; +} + +describe('settings domain extraction contract', () => { + test('keeps the existing settings and root bot-chat exports compatible', () => { + assert.strictEqual(settings.BOT_READINESS_STATES, botChatSettings.BOT_READINESS_STATES); + assert.strictEqual(settings.BOT_PROVIDERS, botChatSettings.BOT_PROVIDERS); + assert.strictEqual(settings.BOT_DELIVERY_PROVIDERS, botChatSettings.BOT_DELIVERY_PROVIDERS); + assert.strictEqual(settings.MAX_ALLOWED_USER_IDS, botChatSettings.MAX_ALLOWED_USER_IDS); + assert.strictEqual(settings.createDefaultBotChannel, botChatSettings.createDefaultBotChannel); + assert.strictEqual(settings.hasBotChannelCredentials, botChatSettings.hasBotChannelCredentials); + assert.strictEqual(settings.normalizeAllowedUserIds, botChatSettings.normalizeAllowedUserIds); + assert.strictEqual( + settings.parseAllowedUserIdsFromText, + botChatSettings.parseAllowedUserIdsFromText, + ); + assert.strictEqual(core.BOT_PROVIDERS, botChatSettings.BOT_PROVIDERS); + assert.strictEqual(core.createDefaultBotChannel, botChatSettings.createDefaultBotChannel); + }); + + test('gives persisted and runtime network contracts distinct canonical shapes', () => { + const persisted: AppNetworkSettings = settings.createDefaultSettings().network; + const legacy: LegacyAppNetworkSettings = persisted; + const canonicalAgain: AppNetworkSettings = legacy; + const runtime: RuntimeNetworkSettings = NETWORK_DEFAULTS; + const legacyRuntime: LegacyRuntimeNetworkSettings = runtime; + const canonicalRuntimeAgain: RuntimeNetworkSettings = legacyRuntime; + const fromAppSettings: AppSettings['network'] = canonicalAgain; + + assert.equal('timeout' in persisted, false); + assert.equal(canonicalRuntimeAgain.timeout, 30_000); + assert.strictEqual(fromAppSettings, persisted); + }); + + test('keeps ownership in the leaf modules and composition in settings.ts', async () => { + const [aggregate, botOwner, webSearchOwner, networkOwner, barrel] = await Promise.all([ + readRepo('packages/core/src/settings.ts'), + readRepo('packages/core/src/bot-chat-settings.ts'), + readRepo('packages/core/src/web-search.ts'), + readRepo('packages/core/src/settings/network-settings.ts'), + readRepo('packages/core/src/index.ts'), + ]); + + assert.match(aggregate, /from '\.\/bot-chat-settings\.js'/); + assert.match(aggregate, /botChat: createDefaultBotChatSettings\(\)/); + assert.match(aggregate, /botChat: mergeBotChatSettings\(current\.botChat, patch\.botChat\)/); + assert.match(aggregate, /botChat: normalizeBotChatSettings\(base\.botChat, value\.botChat\)/); + assert.doesNotMatch(aggregate, /export type BotProvider =/); + assert.doesNotMatch(aggregate, /export interface BotChannelSettings/); + assert.doesNotMatch(aggregate, /function normalizeBotChannel/); + assert.doesNotMatch(aggregate, /function coerceReadinessForCurrentState/); + + assert.match(botOwner, /export interface BotChannelSettings/); + assert.match(botOwner, /function normalizeBotChannel/); + assert.match(botOwner, /function coerceReadinessForCurrentState/); + assert.doesNotMatch(botOwner, /from '\.\/settings\.js'/); + assert.doesNotMatch(botOwner, /from 'node:/); + + assert.match( + aggregate, + /webSearch: mergeWebSearchSettings\(current\.webSearch, patch\.webSearch\)/, + ); + assert.match(aggregate, /webSearch: normalizeWebSearchSettings\(base\.webSearch\)/); + assert.doesNotMatch(aggregate, /function mergeWebSearchSettings/); + assert.doesNotMatch(aggregate, /function normalizeWebSearchSettings/); + assert.match(webSearchOwner, /export function mergeWebSearchSettings/); + assert.match(webSearchOwner, /export function normalizeWebSearchSettings/); + assert.doesNotMatch(webSearchOwner, /from '\.\/settings\.js'/); + + assert.match(aggregate, /export interface AppNetworkSettings/); + assert.match(aggregate, /export type NetworkSettings = AppNetworkSettings/); + assert.match(networkOwner, /export interface RuntimeNetworkSettings/); + assert.match(networkOwner, /export type NetworkSettings = RuntimeNetworkSettings/); + + assert.match(barrel, /from '\.\/bot-chat-settings\.js'/); + assert.doesNotMatch(barrel, /mergeBotChatSettings|normalizeBotChatSettings/); + }); + + test('points package-local bot consumers at the owner instead of the aggregate', async () => { + const paths = [ + 'packages/core/src/bot-events.ts', + 'packages/core/src/bot-onboarding.ts', + 'packages/core/src/bot-platform-hints.ts', + 'packages/core/src/capabilities.ts', + 'packages/core/src/plan-reminders.ts', + ]; + const sources = await Promise.all(paths.map(readRepo)); + + for (const [index, source] of sources.entries()) { + assert.match( + source, + /from '\.\/bot-chat-settings\.js'/, + `${paths[index]} must import its bot contract from the owner`, + ); + assert.doesNotMatch( + source, + /from '\.\/settings\.js'/, + `${paths[index]} must not depend on the aggregate settings module`, + ); + } + }); +}); diff --git a/packages/core/src/__tests__/settings.test.ts b/packages/core/src/__tests__/settings.test.ts index caff02086e..14bbf0ae19 100644 --- a/packages/core/src/__tests__/settings.test.ts +++ b/packages/core/src/__tests__/settings.test.ts @@ -587,117 +587,19 @@ describe('open gateway settings contract', () => { expect(patched.openGateway.token).toBe('stored-token'); }); - test('web search credential status persists independently from masked key round-trips', () => { - const current = mergeSettings(createDefaultSettings(), { - webSearch: { - providers: { - tavily: { - apiKey: 'stored-key', - credentialStatus: 'valid', - credentialCheckedAt: '2026-05-29T00:00:00.000Z', - }, - }, - }, - }); - - const patched = mergeSettings(current, { - webSearch: { - providers: { - tavily: { - apiKey: '••••••', - }, - }, - }, - }); - - expect(patched.webSearch.providers.tavily.apiKey).toBe('stored-key'); - expect(patched.webSearch.providers.tavily.credentialSource).toBe('saved'); - expect(patched.webSearch.providers.tavily.credentialVersion).toBe(1); - expect(patched.webSearch.providers.tavily.credentialStatus).toBe('valid'); - expect(patched.webSearch.providers.tavily.credentialCheckedAt).toBe('2026-05-29T00:00:00.000Z'); - }); - - test('web search credential status resets when the saved key changes', () => { - const current = mergeSettings(createDefaultSettings(), { - webSearch: { - providers: { - tavily: { - apiKey: 'old-key', - credentialStatus: 'valid', - credentialCheckedAt: '2026-05-29T00:00:00.000Z', - }, - }, - }, - }); - - const patched = mergeSettings(current, { - webSearch: { - providers: { - tavily: { - apiKey: 'new-key', - }, - }, - }, - }); - - expect(patched.webSearch.providers.tavily.apiKey).toBe('new-key'); - expect(patched.webSearch.providers.tavily.credentialSource).toBe('saved'); - expect(patched.webSearch.providers.tavily.credentialVersion).toBe(2); - expect(patched.webSearch.providers.tavily.credentialStatus).toBe('untested'); - expect(patched.webSearch.providers.tavily.credentialCheckedAt).toBeUndefined(); - }); - - test('web search credential test result is ignored when it targets a stale key version', () => { - const current = mergeSettings(createDefaultSettings(), { - webSearch: { - providers: { - tavily: { - apiKey: 'current-key', - }, - }, - }, - }); - const updatedKey = mergeSettings(current, { - webSearch: { - providers: { - tavily: { - apiKey: 'newer-key', - }, - }, - }, - }); - - const staleResult = mergeSettings(updatedKey, { - webSearch: { - providers: { - tavily: { - credentialVersion: current.webSearch.providers.tavily.credentialVersion, - credentialStatus: 'invalid_credentials', - credentialCheckedAt: '2026-05-29T00:00:00.000Z', - }, - }, - }, - }); - const freshResult = mergeSettings(updatedKey, { + test('delegates web search patches and persisted normalization to the web-search owner', () => { + const patched = mergeSettings(createDefaultSettings(), { webSearch: { - providers: { - tavily: { - credentialVersion: updatedKey.webSearch.providers.tavily.credentialVersion, - credentialStatus: 'valid', - credentialCheckedAt: '2026-05-29T00:01:00.000Z', - }, - }, + enabled: true, + providers: { tavily: { apiKey: 'stored-key' } }, }, }); + const normalized = normalizeSettings(patched); - expect(updatedKey.webSearch.providers.tavily.credentialVersion).toBe(2); - expect(staleResult.webSearch.providers.tavily.credentialSource).toBe('saved'); - expect(staleResult.webSearch.providers.tavily.credentialStatus).toBe('untested'); - expect(staleResult.webSearch.providers.tavily.credentialCheckedAt).toBeUndefined(); - expect(freshResult.webSearch.providers.tavily.credentialStatus).toBe('valid'); - expect(freshResult.webSearch.providers.tavily.credentialCheckedAt).toBe( - '2026-05-29T00:01:00.000Z', - ); + expect(normalized.webSearch.enabled).toBe(true); + expect(normalized.webSearch.providers.tavily.apiKey).toBe('stored-key'); + expect(normalized.webSearch.providers.tavily.credentialSource).toBe('saved'); + expect(normalized.webSearch.providers.tavily.credentialVersion).toBe(1); }); test('workspace instructions are visible settings and default to enabled', () => { diff --git a/packages/core/src/__tests__/web-search.test.ts b/packages/core/src/__tests__/web-search.test.ts index 489764f488..5b0f436e15 100644 --- a/packages/core/src/__tests__/web-search.test.ts +++ b/packages/core/src/__tests__/web-search.test.ts @@ -13,8 +13,10 @@ import { isWebSearchCredentialSource, isWebSearchProvider, maskedTokenForDisplay, + mergeWebSearchSettings, normalizeWebSearchLimit, normalizeWebSearchQuery, + normalizeWebSearchSettings, reconcileMaskedToken, webSearchCredentialStatusFromResponse, webSearchCredentialSourceFromStoredKey, @@ -111,6 +113,112 @@ describe('defaultWebSearchSettings', () => { }); }); +describe('web search settings reconciliation', () => { + it('preserves credential status and version across a masked key round-trip', () => { + const current = mergeWebSearchSettings(defaultWebSearchSettings(), { + providers: { + tavily: { + apiKey: 'stored-key', + credentialStatus: 'valid', + credentialCheckedAt: '2026-05-29T00:00:00.000Z', + }, + }, + }); + + const patched = mergeWebSearchSettings(current, { + providers: { tavily: { apiKey: MASKED_TOKEN_SENTINEL } }, + }); + + assert.equal(patched.providers.tavily.apiKey, 'stored-key'); + assert.equal(patched.providers.tavily.credentialSource, 'saved'); + assert.equal(patched.providers.tavily.credentialVersion, 1); + assert.equal(patched.providers.tavily.credentialStatus, 'valid'); + assert.equal(patched.providers.tavily.credentialCheckedAt, '2026-05-29T00:00:00.000Z'); + }); + + it('increments the credential version and clears stale status when the saved key changes', () => { + const current = mergeWebSearchSettings(defaultWebSearchSettings(), { + providers: { + tavily: { + apiKey: 'old-key', + credentialStatus: 'valid', + credentialCheckedAt: '2026-05-29T00:00:00.000Z', + }, + }, + }); + + const patched = mergeWebSearchSettings(current, { + providers: { tavily: { apiKey: 'new-key' } }, + }); + + assert.equal(patched.providers.tavily.apiKey, 'new-key'); + assert.equal(patched.providers.tavily.credentialSource, 'saved'); + assert.equal(patched.providers.tavily.credentialVersion, 2); + assert.equal(patched.providers.tavily.credentialStatus, 'untested'); + assert.equal(patched.providers.tavily.credentialCheckedAt, undefined); + }); + + it('ignores a credential result for an older key version', () => { + const current = mergeWebSearchSettings(defaultWebSearchSettings(), { + providers: { tavily: { apiKey: 'current-key' } }, + }); + const updatedKey = mergeWebSearchSettings(current, { + providers: { tavily: { apiKey: 'newer-key' } }, + }); + + const staleResult = mergeWebSearchSettings(updatedKey, { + providers: { + tavily: { + credentialVersion: current.providers.tavily.credentialVersion, + credentialStatus: 'invalid_credentials', + credentialCheckedAt: '2026-05-29T00:00:00.000Z', + }, + }, + }); + const freshResult = mergeWebSearchSettings(updatedKey, { + providers: { + tavily: { + credentialVersion: updatedKey.providers.tavily.credentialVersion, + credentialStatus: 'valid', + credentialCheckedAt: '2026-05-29T00:01:00.000Z', + }, + }, + }); + + assert.equal(updatedKey.providers.tavily.credentialVersion, 2); + assert.equal(staleResult.providers.tavily.credentialStatus, 'untested'); + assert.equal(staleResult.providers.tavily.credentialCheckedAt, undefined); + assert.equal(freshResult.providers.tavily.credentialStatus, 'valid'); + assert.equal(freshResult.providers.tavily.credentialCheckedAt, '2026-05-29T00:01:00.000Z'); + }); + + it('normalizes malformed persisted credential metadata fail-closed', () => { + const malformed = { + enabled: 'yes', + defaultProvider: 'unknown', + providers: { + tavily: { + apiKey: 'x'.repeat(257), + credentialSource: 'saved', + credentialVersion: -1, + credentialStatus: 'unknown', + credentialCheckedAt: 'x'.repeat(65), + }, + }, + } as unknown as Parameters[0]; + + const normalized = normalizeWebSearchSettings(malformed); + + assert.equal(normalized.enabled, false); + assert.equal(normalized.defaultProvider, 'tavily'); + assert.equal(normalized.providers.tavily.apiKey, ''); + assert.equal(normalized.providers.tavily.credentialSource, 'none'); + assert.equal(normalized.providers.tavily.credentialVersion, 0); + assert.equal(normalized.providers.tavily.credentialStatus, 'untested'); + assert.equal(normalized.providers.tavily.credentialCheckedAt, undefined); + }); +}); + describe('web search credential status helpers', () => { it('accepts only the closed credential status enum', () => { assert.equal(isWebSearchCredentialStatus('valid'), true); diff --git a/packages/core/src/bot-chat-settings.ts b/packages/core/src/bot-chat-settings.ts new file mode 100644 index 0000000000..6e97d167c6 --- /dev/null +++ b/packages/core/src/bot-chat-settings.ts @@ -0,0 +1,306 @@ +export type BotProvider = + | 'telegram' + | 'feishu' + | 'wecom' + | 'wechat' + | 'discord' + | 'dingtalk' + | 'qq'; + +export const BOT_READINESS_STATES = [ + 'unscaffolded', + 'scaffolded', + 'configured', + 'credentials_valid', + 'operational', + 'degraded', +] as const; +export type BotReadinessState = (typeof BOT_READINESS_STATES)[number]; + +export interface BotChannelSettings { + provider: BotProvider; + enabled: boolean; + /** + * Legacy credential-test boolean. Do not use this to mean runtime + * operational; prefer `readiness`. + */ + connected: boolean; + readiness: BotReadinessState; + readinessReason?: string; + readinessUpdatedAt?: number; + token: string; + proxyUrl: string; + webhookUrl?: string; + /** Public callback/domain configured in the bot platform console. */ + domain?: string; + appId?: string; + appSecret?: string; + botUserId?: string; + lastTestAt?: number; + lastError?: string; + /** + * PR-BOT-USER-ALLOWLIST-0 (external bot research): platform-native user IDs + * permitted to message this bot. `undefined` or empty means no + * restriction (preserves the V0.1 behavior for existing installs). + * When non-empty, the bot bridge silently drops inbound messages from + * any other user — no acknowledgement is sent back, so unauthorized + * scanners cannot use bounce behavior to enumerate the bot's policy. + * + * Stored as a string array since Telegram IDs are 64-bit and JS + * `Number` loses precision past 2^53. + */ + allowedUserIds?: ReadonlyArray; +} + +export interface BotChatSettings { + channels: Record; +} + +export type BotChatSettingsPatch = Partial<{ + channels: Partial>>; +}>; + +export function isBotReadinessState(value: unknown): value is BotReadinessState { + return typeof value === 'string' && (BOT_READINESS_STATES as readonly string[]).includes(value); +} + +export const BOT_PROVIDERS: BotProvider[] = [ + 'telegram', + 'feishu', + 'wecom', + 'wechat', + 'discord', + 'dingtalk', + 'qq', +]; + +export type BotDeliveryProvider = Extract< + BotProvider, + 'telegram' | 'wechat' | 'discord' | 'dingtalk' | 'qq' +>; + +export const BOT_DELIVERY_PROVIDERS: BotDeliveryProvider[] = [ + 'telegram', + 'wechat', + 'discord', + 'dingtalk', + 'qq', +]; + +export function isBotDeliveryProvider(value: unknown): value is BotDeliveryProvider { + return typeof value === 'string' && (BOT_DELIVERY_PROVIDERS as readonly string[]).includes(value); +} + +export function createDefaultBotChannel(provider: BotProvider): BotChannelSettings { + return { + provider, + enabled: false, + connected: false, + readiness: 'scaffolded', + token: '', + proxyUrl: provider === 'telegram' ? 'http://127.0.0.1:7890' : '', + ...(provider === 'wechat' ? { webhookUrl: 'http://127.0.0.1:18400' } : {}), + }; +} + +export function createDefaultBotChatSettings(): BotChatSettings { + return { + channels: Object.fromEntries( + BOT_PROVIDERS.map((provider) => [provider, createDefaultBotChannel(provider)]), + ) as Record, + }; +} + +export function mergeBotChatSettings( + current: BotChatSettings, + patch: BotChatSettingsPatch | undefined, +): BotChatSettings { + return { + ...current, + channels: { + ...current.channels, + ...Object.fromEntries( + Object.entries(patch?.channels ?? {}).map(([provider, channelPatch]) => { + const merged = { + ...current.channels[provider as BotProvider], + ...channelPatch, + }; + // PR-BOT-USER-ALLOWLIST-0: keep the persisted allowlist + // shape consistent on every save, not only on initial load. + // The renderer textarea sends an array; the normalize step + // trims/dedups/caps and downgrades the empty case to + // `undefined` (the V0.1 "no restriction" sentinel). + if ('allowedUserIds' in (channelPatch ?? {})) { + const normalized = normalizeAllowedUserIds(merged.allowedUserIds); + if (normalized) merged.allowedUserIds = normalized; + else delete merged.allowedUserIds; + } + return [provider, merged]; + }), + ), + }, + }; +} + +export function normalizeBotChatSettings( + settings: BotChatSettings, + rawSettings: Partial | undefined, +): BotChatSettings { + return { + channels: Object.fromEntries( + BOT_PROVIDERS.map((provider) => { + const rawChannel = rawSettings?.channels?.[provider] as + | Partial + | undefined; + return [provider, normalizeBotChannel(provider, settings.channels[provider], rawChannel)]; + }), + ) as Record, + }; +} + +function normalizeBotChannel( + provider: BotProvider, + channel: BotChannelSettings, + rawChannel: Partial | undefined, +): BotChannelSettings { + const hasExplicitReadiness = rawChannel && 'readiness' in rawChannel; + const connected = channel.connected === true; + const candidateReadiness = + hasExplicitReadiness && isBotReadinessState(rawChannel?.readiness) + ? channel.readiness + : connected + ? 'credentials_valid' + : readinessFromChannel(channel); + const allowedUserIds = normalizeAllowedUserIds(channel.allowedUserIds); + return { + ...channel, + provider, + connected, + ...(allowedUserIds ? { allowedUserIds } : { allowedUserIds: undefined }), + // PR-HEALTH-1 (xuan msg `e4887ffd`, I1 — bot readiness single-authority, + // write path): coerce the persisted readiness to be consistent with + // current credential state. The previous behavior trusted whatever was + // on disk, so clearing a token with `mergeBotChatSettings` over + // `{ readiness: 'credentials_valid', token: 'X' }` would persist a + // stale `'credentials_valid'` even though credentials no longer exist. + // `coerceReadinessForCurrentState` downgrades credential-claiming states + // (`configured` / `credentials_valid` / `operational` / `degraded`) + // back to `'scaffolded'` when no credentials remain. Live bridges keep + // their own authoritative readiness via `BotStatus`; they are not + // affected by this settings-write coerce path. + readiness: coerceReadinessForCurrentState(channel, candidateReadiness), + readinessReason: + typeof channel.readinessReason === 'string' ? channel.readinessReason : undefined, + readinessUpdatedAt: + typeof channel.readinessUpdatedAt === 'number' && Number.isFinite(channel.readinessUpdatedAt) + ? channel.readinessUpdatedAt + : undefined, + }; +} + +export function hasBotChannelCredentials(channel: BotChannelSettings): boolean { + if (channel.token.trim().length > 0 || Boolean(channel.appId) || Boolean(channel.appSecret)) + return true; + if (channel.provider === 'wechat' && Boolean(channel.webhookUrl?.trim())) return true; + return false; +} + +function readinessFromChannel(channel: BotChannelSettings): BotReadinessState { + if (!channel.enabled) return 'scaffolded'; + if (!hasBotChannelCredentials(channel)) return 'scaffolded'; + return 'configured'; +} + +/** + * PR-HEALTH-1 (xuan msg `e4887ffd`, I1 lock): downgrade a persisted + * `BotReadinessState` to be consistent with the channel's current + * credential state. + * + * Why: `mergeBotChatSettings` spreads a `channelPatch` over the current channel. + * If the user clears `token` without explicitly patching `readiness`, the + * prior `'credentials_valid'` (or any other credential-claiming state) + * survives. That stale value then surfaces through + * `bot-registry.scaffoldStatus()` into `BotStatus.readiness`, which the + * capability snapshot maps into `CapabilityRuntimeProbeSignal.state` — + * producing a "configured / verified" UI for a channel that actually has + * no credentials. + * + * Rule: credential-claiming readiness (`'configured'` / `'credentials_valid'` + * / `'operational'` / `'degraded'`) requires SOMETHING in the credential + * trio (`token` / `appId` / `appSecret`). When all three are empty, + * downgrade to `'scaffolded'`. `'unscaffolded'` and `'scaffolded'` are + * always consistent with any credential state, so they pass through. + * + * Note: this is a write-path consistency gate, not an operational probe. + * Even when credentials exist, we do NOT promote `'scaffolded'` to + * `'configured'` here — that is the live bridge / connection-test path's + * responsibility. We only downgrade; never upgrade. + */ +function coerceReadinessForCurrentState( + channel: BotChannelSettings, + candidate: BotReadinessState, +): BotReadinessState { + const hasCredentials = hasBotChannelCredentials(channel); + const claimsCredentials = + candidate === 'configured' || + candidate === 'credentials_valid' || + candidate === 'operational' || + candidate === 'degraded'; + if (claimsCredentials && !hasCredentials) { + return 'scaffolded'; + } + return candidate; +} + +/** + * PR-BOT-USER-ALLOWLIST-0: shape-validate the persisted allowlist. + * Returns `undefined` when there is nothing to enforce (preserves the + * V0.1 "no restriction" behavior). Drops non-strings, trims, dedups, and + * caps at MAX_ALLOWED_USER_IDS entries; the cap is defensive against + * pathological persisted settings, not a product UX limit. + * + * IDs are stored as strings because Telegram user IDs are 64-bit and + * JS `Number` loses precision past 2^53. Trimming a candidate to '' is + * treated as absent rather than as a wildcard. + */ +export const MAX_ALLOWED_USER_IDS = 50; +export function normalizeAllowedUserIds( + candidate: ReadonlyArray | undefined | unknown, +): ReadonlyArray | undefined { + if (!Array.isArray(candidate)) return undefined; + const seen = new Set(); + const out: string[] = []; + for (const raw of candidate) { + if (typeof raw !== 'string') continue; + const trimmed = raw.trim(); + if (trimmed.length === 0) continue; + if (seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + if (out.length >= MAX_ALLOWED_USER_IDS) break; + } + return out.length === 0 ? undefined : Object.freeze(out); +} + +/** + * PR-BOT-USER-ALLOWLIST-UI-0: textarea-friendly parse helper for the + * Settings UI. Splits on newline, trims each line, drops blanks, dedups, + * and caps at MAX_ALLOWED_USER_IDS. Returns a string[] (not undefined) + * because the renderer needs to be able to show "current 0 / 50" before + * commit. The IPC merge layer will downgrade an empty list to `undefined` + * at persist time so the V0.1 "no restriction" sentinel is preserved. + */ +export function parseAllowedUserIdsFromText(raw: string): string[] { + if (typeof raw !== 'string' || raw.length === 0) return []; + const seen = new Set(); + const out: string[] = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + if (seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + if (out.length >= MAX_ALLOWED_USER_IDS) break; + } + return out; +} diff --git a/packages/core/src/bot-events.ts b/packages/core/src/bot-events.ts index e6e5a4dc35..742740fb47 100644 --- a/packages/core/src/bot-events.ts +++ b/packages/core/src/bot-events.ts @@ -1,4 +1,4 @@ -import type { BotProvider } from './settings.js'; +import type { BotProvider } from './bot-chat-settings.js'; export type BotPlatform = BotProvider; diff --git a/packages/core/src/bot-onboarding.ts b/packages/core/src/bot-onboarding.ts index f74f4e6b9e..e7afcb2ecd 100644 --- a/packages/core/src/bot-onboarding.ts +++ b/packages/core/src/bot-onboarding.ts @@ -1,4 +1,4 @@ -import type { BotProvider } from './settings.js'; +import type { BotProvider } from './bot-chat-settings.js'; export const BOT_ONBOARDING_PROVIDERS = [ 'dingtalk', diff --git a/packages/core/src/bot-platform-hints.ts b/packages/core/src/bot-platform-hints.ts index 9eab138460..d161be55bb 100644 --- a/packages/core/src/bot-platform-hints.ts +++ b/packages/core/src/bot-platform-hints.ts @@ -1,4 +1,4 @@ -import { BOT_PROVIDERS, type BotProvider } from './settings.js'; +import { BOT_PROVIDERS, type BotProvider } from './bot-chat-settings.js'; export type BotFormattingProfile = 'plain_text' | 'chat_markdown' | 'enterprise_chat'; diff --git a/packages/core/src/capabilities.ts b/packages/core/src/capabilities.ts index 4f146d9f60..7577a38739 100644 --- a/packages/core/src/capabilities.ts +++ b/packages/core/src/capabilities.ts @@ -1,4 +1,4 @@ -import type { BotProvider, BotReadinessState } from './settings.js'; +import type { BotProvider, BotReadinessState } from './bot-chat-settings.js'; export const OS_PERMISSION_IDS = [ 'accessibility', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b1096453b8..5e4efb929e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1158,11 +1158,8 @@ export { resolveModelVisionSupport } from './model-metadata.js'; // settings.ts export type { AppearanceSettings, + AppNetworkSettings, AppSettings, - BotChannelSettings, - BotChatSettings, - BotProvider, - BotReadinessState, ChatDefaultPermissionMode, ChatDefaultsSettings, NetworkProxySettings, @@ -1190,27 +1187,37 @@ export type { UsageTab, } from './settings.js'; export { - BOT_READINESS_STATES, - BOT_DELIVERY_PROVIDERS, - BOT_PROVIDERS, CHAT_DEFAULT_PERMISSION_MODES, DEFAULT_PROXY_BYPASS_DOMAINS, - MAX_ALLOWED_USER_IDS, SETTINGS_SECTIONS, THEME_PALETTES, - createDefaultBotChannel, createDefaultSettings, - hasBotChannelCredentials, - isBotDeliveryProvider, - isBotReadinessState, isChatDefaultPermissionMode, isThemePalette, mergeSettings, - normalizeAllowedUserIds, normalizeSettings, - parseAllowedUserIdsFromText, } from './settings.js'; -export type { BotDeliveryProvider } from './settings.js'; + +// bot-chat-settings.ts +export type { + BotChannelSettings, + BotChatSettings, + BotDeliveryProvider, + BotProvider, + BotReadinessState, +} from './bot-chat-settings.js'; +export { + BOT_DELIVERY_PROVIDERS, + BOT_PROVIDERS, + BOT_READINESS_STATES, + MAX_ALLOWED_USER_IDS, + createDefaultBotChannel, + hasBotChannelCredentials, + isBotDeliveryProvider, + isBotReadinessState, + normalizeAllowedUserIds, + parseAllowedUserIdsFromText, +} from './bot-chat-settings.js'; // bot-onboarding.ts export { diff --git a/packages/core/src/plan-reminders.ts b/packages/core/src/plan-reminders.ts index 89d15cbe8a..3522876b7f 100644 --- a/packages/core/src/plan-reminders.ts +++ b/packages/core/src/plan-reminders.ts @@ -1,4 +1,4 @@ -import { BOT_PROVIDERS, isBotDeliveryProvider, type BotProvider } from './settings.js'; +import { BOT_PROVIDERS, isBotDeliveryProvider, type BotProvider } from './bot-chat-settings.js'; export const PLAN_REMINDER_TITLE_MAX_CHARS = 120; export const PLAN_REMINDER_NOTE_MAX_CHARS = 1000; diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index c1d805f6aa..f4ac9d6782 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -1,18 +1,17 @@ import type { OnboardingMilestone } from './onboarding.js'; import { sanitizeOnboardingMilestones } from './onboarding.js'; -import type { - WebSearchProvider, - WebSearchProviderSettings, - WebSearchSettings, -} from './web-search.js'; +import type { WebSearchSettingsPatch, WebSearchSettings } from './web-search.js'; +import type { BotChatSettings, BotChatSettingsPatch } from './bot-chat-settings.js'; +import { + createDefaultBotChatSettings, + mergeBotChatSettings, + normalizeBotChatSettings, +} from './bot-chat-settings.js'; import type { LocalMemorySettings } from './local-memory.js'; import { - MASKED_TOKEN_SENTINEL, defaultWebSearchSettings, - isWebSearchCredentialStatus, - isWebSearchProvider, - reconcileMaskedToken, - webSearchCredentialSourceFromStoredKey, + mergeWebSearchSettings, + normalizeWebSearchSettings, } from './web-search.js'; import { defaultLocalMemorySettings, normalizeLocalMemorySettings } from './local-memory.js'; import type { PermissionMode } from './permission.js'; @@ -25,6 +24,25 @@ import { export { UI_LOCALE_PREFERENCES, isUiLocalePreference } from './ui-locale.js'; export type { UiLocalePreference } from './ui-locale.js'; +export type { + BotChannelSettings, + BotChatSettings, + BotDeliveryProvider, + BotProvider, + BotReadinessState, +} from './bot-chat-settings.js'; +export { + BOT_DELIVERY_PROVIDERS, + BOT_PROVIDERS, + BOT_READINESS_STATES, + MAX_ALLOWED_USER_IDS, + createDefaultBotChannel, + hasBotChannelCredentials, + isBotDeliveryProvider, + isBotReadinessState, + normalizeAllowedUserIds, + parseAllowedUserIdsFromText, +} from './bot-chat-settings.js'; /** * PR-SETTINGS-IA-CONSOLIDATE-0 + PR-SETTINGS-REVIEW-0 (WAWQAQ msg @@ -79,71 +97,16 @@ export interface NetworkProxySettings { autoBypassDomains: string[]; } -export interface NetworkSettings { +/** + * Persisted application network settings. Runtime proxy execution uses the + * separate contract in `settings/network-settings.ts`. + */ +export interface AppNetworkSettings { proxy: NetworkProxySettings; } -export type BotProvider = - | 'telegram' - | 'feishu' - | 'wecom' - | 'wechat' - | 'discord' - | 'dingtalk' - | 'qq'; - -export const BOT_READINESS_STATES = [ - 'unscaffolded', - 'scaffolded', - 'configured', - 'credentials_valid', - 'operational', - 'degraded', -] as const; -export type BotReadinessState = (typeof BOT_READINESS_STATES)[number]; - -export interface BotChannelSettings { - provider: BotProvider; - enabled: boolean; - /** - * Legacy credential-test boolean. Do not use this to mean runtime - * operational; prefer `readiness`. - */ - connected: boolean; - readiness: BotReadinessState; - readinessReason?: string; - readinessUpdatedAt?: number; - token: string; - proxyUrl: string; - webhookUrl?: string; - /** Public callback/domain configured in the bot platform console. */ - domain?: string; - appId?: string; - appSecret?: string; - botUserId?: string; - lastTestAt?: number; - lastError?: string; - /** - * PR-BOT-USER-ALLOWLIST-0 (external bot research): platform-native user IDs - * permitted to message this bot. `undefined` or empty means no - * restriction (preserves the V0.1 behavior for existing installs). - * When non-empty, the bot bridge silently drops inbound messages from - * any other user — no acknowledgement is sent back, so unauthorized - * scanners cannot use bounce behavior to enumerate the bot's policy. - * - * Stored as a string array since Telegram IDs are 64-bit and JS - * `Number` loses precision past 2^53. - */ - allowedUserIds?: ReadonlyArray; -} - -export function isBotReadinessState(value: unknown): value is BotReadinessState { - return typeof value === 'string' && (BOT_READINESS_STATES as readonly string[]).includes(value); -} - -export interface BotChatSettings { - channels: Record; -} +/** @deprecated Use AppNetworkSettings for the persisted application settings shape. */ +export type NetworkSettings = AppNetworkSettings; export type UsageRange = '24h' | '7d' | '30d' | 'all'; export type UsageStatus = 'all' | 'success' | 'error'; @@ -325,7 +288,7 @@ export interface SystemSettings { export interface AppSettings { schemaVersion: 1; - network: NetworkSettings; + network: AppNetworkSettings; botChat: BotChatSettings; usage: UsageSettings; appearance: AppearanceSettings; @@ -405,9 +368,7 @@ export type UpdateAppSettingsInput = Partial<{ network: Partial<{ proxy: Partial; }>; - botChat: Partial<{ - channels: Partial>>; - }>; + botChat: BotChatSettingsPatch; usage: Partial; appearance: Partial; personalization: Partial; @@ -418,13 +379,7 @@ export type UpdateAppSettingsInput = Partial<{ chatDefaults: Partial; notifications: Partial; system: Partial; - webSearch: Partial<{ - enabled: boolean; - defaultProvider: WebSearchProvider; - providers: Partial<{ - tavily: Partial; - }>; - }>; + webSearch: WebSearchSettingsPatch; }>; export type PersonalizationSettingsWarning = @@ -441,33 +396,6 @@ export interface UpdateAppSettingsResult { warnings?: UpdateAppSettingsWarnings; } -export const BOT_PROVIDERS: BotProvider[] = [ - 'telegram', - 'feishu', - 'wecom', - 'wechat', - 'discord', - 'dingtalk', - 'qq', -]; - -export type BotDeliveryProvider = Extract< - BotProvider, - 'telegram' | 'wechat' | 'discord' | 'dingtalk' | 'qq' ->; - -export const BOT_DELIVERY_PROVIDERS: BotDeliveryProvider[] = [ - 'telegram', - 'wechat', - 'discord', - 'dingtalk', - 'qq', -]; - -export function isBotDeliveryProvider(value: unknown): value is BotDeliveryProvider { - return typeof value === 'string' && (BOT_DELIVERY_PROVIDERS as readonly string[]).includes(value); -} - export const DEFAULT_PROXY_BYPASS_DOMAINS = [ 'localhost', '127.0.0.1', @@ -477,18 +405,6 @@ export const DEFAULT_PROXY_BYPASS_DOMAINS = [ '*.local', ]; -export function createDefaultBotChannel(provider: BotProvider): BotChannelSettings { - return { - provider, - enabled: false, - connected: false, - readiness: 'scaffolded', - token: '', - proxyUrl: provider === 'telegram' ? 'http://127.0.0.1:7890' : '', - ...(provider === 'wechat' ? { webhookUrl: 'http://127.0.0.1:18400' } : {}), - }; -} - export function createDefaultSettings(): AppSettings { return { schemaVersion: 1, @@ -505,11 +421,7 @@ export function createDefaultSettings(): AppSettings { autoBypassDomains: DEFAULT_PROXY_BYPASS_DOMAINS, }, }, - botChat: { - channels: Object.fromEntries( - BOT_PROVIDERS.map((provider) => [provider, createDefaultBotChannel(provider)]), - ) as Record, - }, + botChat: createDefaultBotChatSettings(), usage: { range: '24h', status: 'all', @@ -564,31 +476,7 @@ export function mergeSettings(current: AppSettings, patch: UpdateAppSettingsInpu ...(patch.network?.proxy ?? {}), }, }, - botChat: { - ...current.botChat, - channels: { - ...current.botChat.channels, - ...Object.fromEntries( - Object.entries(patch.botChat?.channels ?? {}).map(([provider, channelPatch]) => { - const merged = { - ...current.botChat.channels[provider as BotProvider], - ...channelPatch, - }; - // PR-BOT-USER-ALLOWLIST-0: keep the persisted allowlist - // shape consistent on every save, not only on initial load. - // The renderer textarea sends an array; the normalize step - // trims/dedups/caps and downgrades the empty case to - // `undefined` (the V0.1 "no restriction" sentinel). - if ('allowedUserIds' in (channelPatch ?? {})) { - const normalized = normalizeAllowedUserIds(merged.allowedUserIds); - if (normalized) merged.allowedUserIds = normalized; - else delete merged.allowedUserIds; - } - return [provider, merged]; - }), - ), - }, - }, + botChat: mergeBotChatSettings(current.botChat, patch.botChat), usage: { ...current.usage, ...(patch.usage ?? {}), @@ -638,71 +526,6 @@ export function mergeSettings(current: AppSettings, patch: UpdateAppSettingsInpu }; } -function mergeWebSearchSettings( - current: WebSearchSettings, - patch: UpdateAppSettingsInput['webSearch'], -): WebSearchSettings { - if (!patch) return current; - const tavilyPatch = patch.providers?.tavily; - const candidateProvider = patch.defaultProvider; - const nextProvider: WebSearchProvider = isWebSearchProvider(candidateProvider) - ? candidateProvider - : current.defaultProvider; - // Mask-sentinel preservation lives here so the IPC boundary does - // not have to special-case the round-tripped masked value. - const nextApiKey = - tavilyPatch && typeof tavilyPatch.apiKey === 'string' - ? reconcileMaskedToken(current.providers.tavily.apiKey, tavilyPatch.apiKey) - : current.providers.tavily.apiKey; - const currentCredentialVersion = normalizeCredentialVersion( - current.providers.tavily.credentialVersion, - ); - const explicitCredentialCheckedAt = - tavilyPatch && - typeof tavilyPatch.credentialCheckedAt === 'string' && - tavilyPatch.credentialCheckedAt.length <= 64 - ? tavilyPatch.credentialCheckedAt - : undefined; - const apiKeyChanged = - tavilyPatch && - typeof tavilyPatch.apiKey === 'string' && - tavilyPatch.apiKey !== MASKED_TOKEN_SENTINEL && - nextApiKey !== current.providers.tavily.apiKey; - const nextCredentialVersion = apiKeyChanged - ? currentCredentialVersion + 1 - : currentCredentialVersion; - const patchCredentialVersion = tavilyPatch - ? normalizeOptionalCredentialVersion(tavilyPatch.credentialVersion) - : undefined; - const hasExplicitCredentialStatus = - tavilyPatch && - isWebSearchCredentialStatus(tavilyPatch.credentialStatus) && - (patchCredentialVersion === undefined || patchCredentialVersion === currentCredentialVersion); - const credentialStatus = hasExplicitCredentialStatus - ? tavilyPatch.credentialStatus - : apiKeyChanged - ? 'untested' - : current.providers.tavily.credentialStatus; - const credentialCheckedAt = hasExplicitCredentialStatus - ? explicitCredentialCheckedAt - : apiKeyChanged - ? undefined - : current.providers.tavily.credentialCheckedAt; - return { - enabled: typeof patch.enabled === 'boolean' ? patch.enabled : current.enabled, - defaultProvider: nextProvider, - providers: { - tavily: { - apiKey: nextApiKey, - credentialSource: webSearchCredentialSourceFromStoredKey(nextApiKey), - credentialVersion: nextCredentialVersion, - credentialStatus, - ...(credentialCheckedAt ? { credentialCheckedAt } : {}), - }, - }, - }; -} - export function normalizeSettings(input: unknown): AppSettings { const defaults = createDefaultSettings(); if (!input || typeof input !== 'object') return defaults; @@ -768,19 +591,7 @@ export function normalizeSettings(input: unknown): AppSettings { ? base.personalization.uiLocale : 'auto', }, - botChat: { - channels: Object.fromEntries( - BOT_PROVIDERS.map((provider) => { - const rawChannel = value.botChat?.channels?.[provider] as - | Partial - | undefined; - return [ - provider, - normalizeBotChannel(provider, base.botChat.channels[provider], rawChannel), - ]; - }), - ) as Record, - }, + botChat: normalizeBotChatSettings(base.botChat, value.botChat), onboarding: { milestones: sanitizeOnboardingMilestones(rawMilestones), }, @@ -847,52 +658,6 @@ function normalizePrivacySettings(settings: PrivacySettings): PrivacySettings { }; } -function normalizeWebSearchSettings(settings: WebSearchSettings): WebSearchSettings { - const enabled = settings.enabled === true; - const defaultProvider = isWebSearchProvider(settings.defaultProvider) - ? settings.defaultProvider - : 'tavily'; - // Cap apiKey length defensively. Tavily keys are < 64 chars; anything - // longer is almost certainly garbage that would break log redaction. - const rawApiKey = settings.providers?.tavily?.apiKey; - const apiKey = typeof rawApiKey === 'string' && rawApiKey.length <= 256 ? rawApiKey : ''; - const rawCredentialStatus = settings.providers?.tavily?.credentialStatus; - const credentialStatus = isWebSearchCredentialStatus(rawCredentialStatus) - ? rawCredentialStatus - : 'untested'; - const rawCredentialCheckedAt = settings.providers?.tavily?.credentialCheckedAt; - const credentialCheckedAt = - typeof rawCredentialCheckedAt === 'string' && rawCredentialCheckedAt.length <= 64 - ? rawCredentialCheckedAt - : undefined; - const credentialVersion = normalizeCredentialVersion( - settings.providers?.tavily?.credentialVersion, - ); - return { - enabled, - defaultProvider, - providers: { - tavily: { - apiKey, - credentialSource: webSearchCredentialSourceFromStoredKey(apiKey), - credentialVersion, - credentialStatus, - ...(credentialCheckedAt ? { credentialCheckedAt } : {}), - }, - }, - }; -} - -function normalizeCredentialVersion(value: unknown): number { - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) return 0; - return value; -} - -function normalizeOptionalCredentialVersion(value: unknown): number | undefined { - if (value === undefined) return undefined; - return normalizeCredentialVersion(value); -} - function normalizeOpenGatewaySettings(settings: OpenGatewaySettings): OpenGatewaySettings { const port = Number.isInteger(settings.port) && settings.port >= 1024 && settings.port <= 65535 @@ -908,150 +673,3 @@ function normalizeOpenGatewaySettings(settings: OpenGatewaySettings): OpenGatewa token, }; } - -function normalizeBotChannel( - provider: BotProvider, - channel: BotChannelSettings, - rawChannel: Partial | undefined, -): BotChannelSettings { - const hasExplicitReadiness = rawChannel && 'readiness' in rawChannel; - const connected = channel.connected === true; - const candidateReadiness = - hasExplicitReadiness && isBotReadinessState(rawChannel?.readiness) - ? channel.readiness - : connected - ? 'credentials_valid' - : readinessFromChannel(channel); - const allowedUserIds = normalizeAllowedUserIds(channel.allowedUserIds); - return { - ...channel, - provider, - connected, - ...(allowedUserIds ? { allowedUserIds } : { allowedUserIds: undefined }), - // PR-HEALTH-1 (xuan msg `e4887ffd`, I1 — bot readiness single-authority, - // write path): coerce the persisted readiness to be consistent with - // current credential state. The previous behavior trusted whatever was - // on disk, so `mergeSettings({channels:{telegram:{token:''}}})` over - // `{readiness:'credentials_valid', token:'X'}` would persist a stale - // `'credentials_valid'` even though credentials no longer exist. - // `coerceReadinessForCurrentState` downgrades credential-claiming states - // (`configured` / `credentials_valid` / `operational` / `degraded`) - // back to `'scaffolded'` when no credentials remain. Live bridges keep - // their own authoritative readiness via `BotStatus`; they are not - // affected by this settings-write coerce path. - readiness: coerceReadinessForCurrentState(channel, candidateReadiness), - readinessReason: - typeof channel.readinessReason === 'string' ? channel.readinessReason : undefined, - readinessUpdatedAt: - typeof channel.readinessUpdatedAt === 'number' && Number.isFinite(channel.readinessUpdatedAt) - ? channel.readinessUpdatedAt - : undefined, - }; -} - -export function hasBotChannelCredentials(channel: BotChannelSettings): boolean { - if (channel.token.trim().length > 0 || Boolean(channel.appId) || Boolean(channel.appSecret)) - return true; - if (channel.provider === 'wechat' && Boolean(channel.webhookUrl?.trim())) return true; - return false; -} - -function readinessFromChannel(channel: BotChannelSettings): BotReadinessState { - if (!channel.enabled) return 'scaffolded'; - if (!hasBotChannelCredentials(channel)) return 'scaffolded'; - return 'configured'; -} - -/** - * PR-HEALTH-1 (xuan msg `e4887ffd`, I1 lock): downgrade a persisted - * `BotReadinessState` to be consistent with the channel's current - * credential state. - * - * Why: `mergeSettings` spreads a `channelPatch` over the current channel. - * If the user clears `token` without explicitly patching `readiness`, the - * prior `'credentials_valid'` (or any other credential-claiming state) - * survives. That stale value then surfaces through - * `bot-registry.scaffoldStatus()` into `BotStatus.readiness`, which the - * capability snapshot maps into `CapabilityRuntimeProbeSignal.state` — - * producing a "configured / verified" UI for a channel that actually has - * no credentials. - * - * Rule: credential-claiming readiness (`'configured'` / `'credentials_valid'` - * / `'operational'` / `'degraded'`) requires SOMETHING in the credential - * trio (`token` / `appId` / `appSecret`). When all three are empty, - * downgrade to `'scaffolded'`. `'unscaffolded'` and `'scaffolded'` are - * always consistent with any credential state, so they pass through. - * - * Note: this is a write-path consistency gate, not an operational probe. - * Even when credentials exist, we do NOT promote `'scaffolded'` to - * `'configured'` here — that is the live bridge / connection-test path's - * responsibility. We only downgrade; never upgrade. - */ -function coerceReadinessForCurrentState( - channel: BotChannelSettings, - candidate: BotReadinessState, -): BotReadinessState { - const hasCredentials = hasBotChannelCredentials(channel); - const claimsCredentials = - candidate === 'configured' || - candidate === 'credentials_valid' || - candidate === 'operational' || - candidate === 'degraded'; - if (claimsCredentials && !hasCredentials) { - return 'scaffolded'; - } - return candidate; -} - -/** - * PR-BOT-USER-ALLOWLIST-0: shape-validate the persisted allowlist. - * Returns `undefined` when there is nothing to enforce (preserves the - * V0.1 "no restriction" behavior). Drops non-strings, trims, dedups, and - * caps at MAX_ALLOWED_USER_IDS entries; the cap is defensive against - * pathological persisted settings, not a product UX limit. - * - * IDs are stored as strings because Telegram user IDs are 64-bit and - * JS `Number` loses precision past 2^53. Trimming a candidate to '' is - * treated as absent rather than as a wildcard. - */ -export const MAX_ALLOWED_USER_IDS = 50; -export function normalizeAllowedUserIds( - candidate: ReadonlyArray | undefined | unknown, -): ReadonlyArray | undefined { - if (!Array.isArray(candidate)) return undefined; - const seen = new Set(); - const out: string[] = []; - for (const raw of candidate) { - if (typeof raw !== 'string') continue; - const trimmed = raw.trim(); - if (trimmed.length === 0) continue; - if (seen.has(trimmed)) continue; - seen.add(trimmed); - out.push(trimmed); - if (out.length >= MAX_ALLOWED_USER_IDS) break; - } - return out.length === 0 ? undefined : Object.freeze(out); -} - -/** - * PR-BOT-USER-ALLOWLIST-UI-0: textarea-friendly parse helper for the - * Settings UI. Splits on newline, trims each line, drops blanks, dedups, - * and caps at MAX_ALLOWED_USER_IDS. Returns a string[] (not undefined) - * because the renderer needs to be able to show "current 0 / 50" before - * commit. The IPC merge layer will downgrade an empty list to `undefined` - * at persist time so the V0.1 "no restriction" sentinel is preserved. - */ -export function parseAllowedUserIdsFromText(raw: string): string[] { - if (typeof raw !== 'string' || raw.length === 0) return []; - const seen = new Set(); - const out: string[] = []; - for (const line of raw.split('\n')) { - const trimmed = line.trim(); - if (trimmed.length === 0) continue; - if (seen.has(trimmed)) continue; - seen.add(trimmed); - out.push(trimmed); - if (out.length >= MAX_ALLOWED_USER_IDS) break; - } - return out; -} diff --git a/packages/core/src/settings/network-settings.ts b/packages/core/src/settings/network-settings.ts index 96bcda286c..0fa358c47f 100644 --- a/packages/core/src/settings/network-settings.ts +++ b/packages/core/src/settings/network-settings.ts @@ -26,7 +26,7 @@ export interface ProxySettings { bypassList: string[]; } -export interface NetworkSettings { +export interface RuntimeNetworkSettings { proxy: ProxySettings; timeout: number; retryAttempts: number; @@ -34,6 +34,9 @@ export interface NetworkSettings { preferIpv4: boolean; } +/** @deprecated Use RuntimeNetworkSettings for the runtime network contract. */ +export type NetworkSettings = RuntimeNetworkSettings; + export const PROXY_DEFAULTS: ProxySettings = { enabled: false, type: 'http', @@ -42,7 +45,7 @@ export const PROXY_DEFAULTS: ProxySettings = { bypassList: ['localhost', '127.0.0.1', '::1', '*.local'], }; -export const NETWORK_DEFAULTS: NetworkSettings = { +export const NETWORK_DEFAULTS: RuntimeNetworkSettings = { proxy: PROXY_DEFAULTS, timeout: 30_000, retryAttempts: 3, diff --git a/packages/core/src/web-search.ts b/packages/core/src/web-search.ts index f411bc7367..0785d819d9 100644 --- a/packages/core/src/web-search.ts +++ b/packages/core/src/web-search.ts @@ -117,6 +117,14 @@ export interface WebSearchSettings { readonly providers: { readonly tavily: WebSearchProviderSettings }; } +export type WebSearchSettingsPatch = Partial<{ + enabled: boolean; + defaultProvider: WebSearchProvider; + providers: Partial<{ + tavily: Partial; + }>; +}>; + export function defaultWebSearchSettings(): WebSearchSettings { return { enabled: false, @@ -132,6 +140,107 @@ export function defaultWebSearchSettings(): WebSearchSettings { }; } +export function mergeWebSearchSettings( + current: WebSearchSettings, + patch: WebSearchSettingsPatch | undefined, +): WebSearchSettings { + if (!patch) return current; + const tavilyPatch = patch.providers?.tavily; + const candidateProvider = patch.defaultProvider; + const nextProvider: WebSearchProvider = isWebSearchProvider(candidateProvider) + ? candidateProvider + : current.defaultProvider; + // Mask-sentinel preservation lives here so the IPC boundary does + // not have to special-case the round-tripped masked value. + const nextApiKey = + tavilyPatch && typeof tavilyPatch.apiKey === 'string' + ? reconcileMaskedToken(current.providers.tavily.apiKey, tavilyPatch.apiKey) + : current.providers.tavily.apiKey; + const currentCredentialVersion = normalizeCredentialVersion( + current.providers.tavily.credentialVersion, + ); + const explicitCredentialCheckedAt = + tavilyPatch && + typeof tavilyPatch.credentialCheckedAt === 'string' && + tavilyPatch.credentialCheckedAt.length <= 64 + ? tavilyPatch.credentialCheckedAt + : undefined; + const apiKeyChanged = + tavilyPatch && + typeof tavilyPatch.apiKey === 'string' && + tavilyPatch.apiKey !== MASKED_TOKEN_SENTINEL && + nextApiKey !== current.providers.tavily.apiKey; + const nextCredentialVersion = apiKeyChanged + ? currentCredentialVersion + 1 + : currentCredentialVersion; + const patchCredentialVersion = tavilyPatch + ? normalizeOptionalCredentialVersion(tavilyPatch.credentialVersion) + : undefined; + const hasExplicitCredentialStatus = + tavilyPatch && + isWebSearchCredentialStatus(tavilyPatch.credentialStatus) && + (patchCredentialVersion === undefined || patchCredentialVersion === currentCredentialVersion); + const credentialStatus = hasExplicitCredentialStatus + ? tavilyPatch.credentialStatus + : apiKeyChanged + ? 'untested' + : current.providers.tavily.credentialStatus; + const credentialCheckedAt = hasExplicitCredentialStatus + ? explicitCredentialCheckedAt + : apiKeyChanged + ? undefined + : current.providers.tavily.credentialCheckedAt; + return { + enabled: typeof patch.enabled === 'boolean' ? patch.enabled : current.enabled, + defaultProvider: nextProvider, + providers: { + tavily: { + apiKey: nextApiKey, + credentialSource: webSearchCredentialSourceFromStoredKey(nextApiKey), + credentialVersion: nextCredentialVersion, + credentialStatus, + ...(credentialCheckedAt ? { credentialCheckedAt } : {}), + }, + }, + }; +} + +export function normalizeWebSearchSettings(settings: WebSearchSettings): WebSearchSettings { + const enabled = settings.enabled === true; + const defaultProvider = isWebSearchProvider(settings.defaultProvider) + ? settings.defaultProvider + : 'tavily'; + // Cap apiKey length defensively. Tavily keys are < 64 chars; anything + // longer is almost certainly garbage that would break log redaction. + const rawApiKey = settings.providers?.tavily?.apiKey; + const apiKey = typeof rawApiKey === 'string' && rawApiKey.length <= 256 ? rawApiKey : ''; + const rawCredentialStatus = settings.providers?.tavily?.credentialStatus; + const credentialStatus = isWebSearchCredentialStatus(rawCredentialStatus) + ? rawCredentialStatus + : 'untested'; + const rawCredentialCheckedAt = settings.providers?.tavily?.credentialCheckedAt; + const credentialCheckedAt = + typeof rawCredentialCheckedAt === 'string' && rawCredentialCheckedAt.length <= 64 + ? rawCredentialCheckedAt + : undefined; + const credentialVersion = normalizeCredentialVersion( + settings.providers?.tavily?.credentialVersion, + ); + return { + enabled, + defaultProvider, + providers: { + tavily: { + apiKey, + credentialSource: webSearchCredentialSourceFromStoredKey(apiKey), + credentialVersion, + credentialStatus, + ...(credentialCheckedAt ? { credentialCheckedAt } : {}), + }, + }, + }; +} + /** * Helper for the IPC store boundary: given a (possibly stale) * persisted token and the renderer-sent update token, choose which @@ -172,3 +281,13 @@ export function webSearchCredentialStatusFromResponse( if (isWebSearchCredentialStatus(response.reason)) return response.reason; return 'network_error'; } + +function normalizeCredentialVersion(value: unknown): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) return 0; + return value; +} + +function normalizeOptionalCredentialVersion(value: unknown): number | undefined { + if (value === undefined) return undefined; + return normalizeCredentialVersion(value); +}