From 245948870345dac44b51e00e791995d9eac111b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 4 May 2026 21:37:00 +0800 Subject: [PATCH] Harden provider auth, adapter paths, and notification retries This captures the pending worktree fixes before applying them to the current local main. The changes tighten IM adapter path and credential handling, preserve retry behavior for failed desktop notifications, and make Azure/OpenAI provider auth and stop reasons reflect actual runtime state. Constraint: Worktree was detached from an older local main with pending uncommitted fixes Rejected: Merge the detached HEAD directly | would also replay unrelated stale history Rejected: Leave notification dedupe as fire-and-forget | failed sends consumed retry keys Confidence: high Scope-risk: broad Directive: Keep adapter absolute-path matching constrained to configured work roots Tested: git diff --check Not-tested: full quality gate before local main integration --- adapters/bun.lock | 16 +++++--- adapters/common/__tests__/http-client.test.ts | 26 +++++++++++- adapters/common/http-client.ts | 23 ++++++++++- adapters/dingtalk/index.ts | 2 +- adapters/feishu/index.ts | 2 +- adapters/package.json | 6 ++- adapters/telegram/index.ts | 2 +- adapters/wechat/index.ts | 2 +- bun.lock | 5 ++- ...ScheduledTaskDesktopNotifications.test.tsx | 40 ++++++++++++++++++ .../useScheduledTaskDesktopNotifications.ts | 4 +- desktop/src/lib/desktopNotifications.test.ts | 18 +++++++- desktop/src/lib/desktopNotifications.ts | 39 +++++++++++++----- desktop/src/pages/AdapterSettings.tsx | 6 +++ desktop/src/stores/adapterStore.ts | 4 +- package-lock.json | 6 +-- package.json | 3 ++ src/server/__tests__/adapters.test.ts | 13 ++++++ src/server/__tests__/providers.test.ts | 41 +++++++++++++++++++ src/server/services/adapterService.ts | 8 +++- src/server/services/providerService.ts | 9 +++- src/services/api/azureOpenAI.ts | 19 +++++++-- src/services/api/claude.ts | 2 +- tests/azureOpenAI.test.ts | 40 ++++++++++++++++++ 24 files changed, 294 insertions(+), 42 deletions(-) diff --git a/adapters/bun.lock b/adapters/bun.lock index 2e9bce2f..afad9705 100644 --- a/adapters/bun.lock +++ b/adapters/bun.lock @@ -16,6 +16,10 @@ }, }, }, + "overrides": { + "follow-redirects": "^1.16.0", + "protobufjs": "^7.5.5", + }, "packages": { "@grammyjs/types": ["@grammyjs/types@3.26.0", "https://registry.npmmirror.com/@grammyjs/types/-/types-3.26.0.tgz", {}, "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A=="], @@ -25,7 +29,7 @@ "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "https://registry.npmmirror.com/@protobufjs/base64/-/base64-1.1.2.tgz", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], - "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "https://registry.npmmirror.com/@protobufjs/codegen/-/codegen-2.0.4.tgz", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "https://registry.npmmirror.com/@protobufjs/codegen/-/codegen-2.0.5.tgz", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "https://registry.npmmirror.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], @@ -33,13 +37,13 @@ "@protobufjs/float": ["@protobufjs/float@1.0.2", "https://registry.npmmirror.com/@protobufjs/float/-/float-1.0.2.tgz", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], - "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.0.tgz", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.1", "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.1.tgz", {}, "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew=="], "@protobufjs/path": ["@protobufjs/path@1.1.2", "https://registry.npmmirror.com/@protobufjs/path/-/path-1.1.2.tgz", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "https://registry.npmmirror.com/@protobufjs/pool/-/pool-1.1.0.tgz", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], - "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.0.tgz", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.1.tgz", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], "@types/node": ["@types/node@25.5.2", "https://registry.npmmirror.com/@types/node/-/node-25.5.2.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], @@ -77,7 +81,7 @@ "event-target-shim": ["event-target-shim@5.0.1", "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-5.0.1.tgz", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], - "follow-redirects": ["follow-redirects@1.15.11", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + "follow-redirects": ["follow-redirects@1.16.0", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], @@ -117,7 +121,7 @@ "object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "protobufjs": ["protobufjs@7.5.4", "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.4.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "protobufjs": ["protobufjs@7.5.6", "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.6.tgz", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg=="], "proxy-from-env": ["proxy-from-env@1.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], @@ -140,5 +144,7 @@ "whatwg-url": ["whatwg-url@5.0.0", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "ws": ["ws@8.20.0", "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "@protobufjs/fetch/@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.0.tgz", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], } } diff --git a/adapters/common/__tests__/http-client.test.ts b/adapters/common/__tests__/http-client.test.ts index 870ed85f..e4c83622 100644 --- a/adapters/common/__tests__/http-client.test.ts +++ b/adapters/common/__tests__/http-client.test.ts @@ -56,9 +56,11 @@ describe('AdapterHttpClient', () => { expect(projects[0].projectName).toBe('my-app') }) - it('matchProject accepts an absolute local project path without recent history', async () => { - const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-project-')) + it('matchProject accepts an absolute local project path inside an allowed root without recent history', async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-')) + const projectDir = fs.mkdtempSync(path.join(rootDir, 'project-')) try { + client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] }) globalThis.fetch = mock(() => { throw new Error('recent projects should not be queried for absolute paths') }) as any @@ -69,6 +71,26 @@ describe('AdapterHttpClient', () => { expect(result.project?.projectName).toBe(path.basename(projectDir)) expect((globalThis.fetch as any).mock.calls).toHaveLength(0) } finally { + fs.rmSync(rootDir, { recursive: true, force: true }) + } + }) + + it('matchProject rejects absolute local project paths outside allowed roots', async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-')) + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-project-')) + try { + client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] }) + globalThis.fetch = mock(() => { + throw new Error('recent projects should not be queried for rejected absolute paths') + }) as any + + const result = await client.matchProject(projectDir) + + expect(result.project).toBeUndefined() + expect(result.ambiguous).toBeUndefined() + expect((globalThis.fetch as any).mock.calls).toHaveLength(0) + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }) fs.rmSync(projectDir, { recursive: true, force: true }) } }) diff --git a/adapters/common/http-client.ts b/adapters/common/http-client.ts index 3a68f58a..61bd0bcb 100644 --- a/adapters/common/http-client.ts +++ b/adapters/common/http-client.ts @@ -28,14 +28,18 @@ export type SessionTask = { export class AdapterHttpClient { readonly httpBaseUrl: string + private readonly allowedProjectRoots: string[] /** Default timeout for HTTP requests (30 seconds) */ private static readonly DEFAULT_TIMEOUT_MS = 30_000 - constructor(wsUrl: string) { + constructor(wsUrl: string, options?: { allowedProjectRoots?: string[] }) { this.httpBaseUrl = wsUrl .replace(/^ws:/, 'http:') .replace(/^wss:/, 'https:') .replace(/\/$/, '') + this.allowedProjectRoots = (options?.allowedProjectRoots ?? []) + .map(resolveExistingProjectPath) + .filter((value): value is string => Boolean(value)) } /** Create an AbortController with timeout */ @@ -91,6 +95,10 @@ export class AdapterHttpClient { async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> { const directPath = resolveExistingProjectPath(query) if (directPath) { + if (!isPathWithinAllowedRoots(directPath, this.allowedProjectRoots)) { + return {} + } + return { project: { projectPath: directPath, @@ -165,6 +173,19 @@ export class AdapterHttpClient { } } +function isPathWithinAllowedRoots(target: string, roots: string[]): boolean { + if (roots.length === 0) return false + + for (const root of roots) { + const relative = path.relative(root, target) + if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) { + return true + } + } + + return false +} + function resolveExistingProjectPath(query: string): string | null { const trimmed = query.trim() if (!trimmed) return null diff --git a/adapters/dingtalk/index.ts b/adapters/dingtalk/index.ts index 1f51597c..15bafce1 100644 --- a/adapters/dingtalk/index.ts +++ b/adapters/dingtalk/index.ts @@ -57,7 +57,7 @@ const defaultWorkDir = getConfiguredWorkDir(config, config.dingtalk) const bridge = new WsBridge(config.serverUrl, 'dingtalk') const dedup = new MessageDedup() const sessionStore = new SessionStore() -const httpClient = new AdapterHttpClient(config.serverUrl) +const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] }) const attachmentStore = new AttachmentStore() const media = new DingTalkMediaService(attachmentStore) const aiCards = new DingTalkAiCardService(getAccessToken, config.dingtalk.clientId) diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index 03be72f7..c58ab4ca 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -49,8 +49,8 @@ const larkClient = new Lark.Client({ const bridge = new WsBridge(config.serverUrl, 'feishu') const dedup = new MessageDedup() const sessionStore = new SessionStore() -const httpClient = new AdapterHttpClient(config.serverUrl) const defaultWorkDir = getConfiguredWorkDir(config, config.feishu) +const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] }) // Attachment plumbing — shared by inbound (download) and outbound (upload) paths. const attachmentStore = new AttachmentStore() diff --git a/adapters/package.json b/adapters/package.json index 4456c617..785b80e8 100644 --- a/adapters/package.json +++ b/adapters/package.json @@ -16,11 +16,15 @@ "test:dingtalk": "bun test dingtalk/" }, "dependencies": { + "@larksuiteoapi/node-sdk": "^1.60.0", "dingtalk-stream": "2.1.4", "grammy": "^1.42.0", - "@larksuiteoapi/node-sdk": "^1.60.0", "ws": "^8.18.0" }, + "overrides": { + "follow-redirects": "^1.16.0", + "protobufjs": "^7.5.5" + }, "devDependencies": { "@types/ws": "^8.5.0", "bun-types": "latest" diff --git a/adapters/telegram/index.ts b/adapters/telegram/index.ts index cdd1d3fd..90cba96b 100644 --- a/adapters/telegram/index.ts +++ b/adapters/telegram/index.ts @@ -47,8 +47,8 @@ const bot = new Bot(config.telegram.botToken) const bridge = new WsBridge(config.serverUrl, 'tg') const dedup = new MessageDedup() const sessionStore = new SessionStore() -const httpClient = new AdapterHttpClient(config.serverUrl) const defaultWorkDir = getConfiguredWorkDir(config, config.telegram) +const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] }) const attachmentStore = new AttachmentStore() const media = new TelegramMediaService(bot, attachmentStore) attachmentStore.gc().catch((err) => { diff --git a/adapters/wechat/index.ts b/adapters/wechat/index.ts index b095781e..b1a577fb 100644 --- a/adapters/wechat/index.ts +++ b/adapters/wechat/index.ts @@ -46,8 +46,8 @@ const botToken = config.wechat.botToken const bridge = new WsBridge(config.serverUrl, 'wechat') const dedup = new MessageDedup() const sessionStore = new SessionStore() -const httpClient = new AdapterHttpClient(config.serverUrl) const defaultWorkDir = getConfiguredWorkDir(config, config.wechat) +const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] }) const attachmentStore = new AttachmentStore() const media = new WechatMediaService(attachmentStore) const pendingProjectSelection = new Map() diff --git a/bun.lock b/bun.lock index 58e888fe..f321d88e 100644 --- a/bun.lock +++ b/bun.lock @@ -78,6 +78,9 @@ }, }, }, + "overrides": { + "follow-redirects": "^1.16.0", + }, "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "https://registry.npmmirror.com/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], @@ -857,7 +860,7 @@ "focus-trap": ["focus-trap@7.8.0", "https://registry.npmmirror.com/focus-trap/-/focus-trap-7.8.0.tgz", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="], - "follow-redirects": ["follow-redirects@1.15.11", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + "follow-redirects": ["follow-redirects@1.16.0", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], diff --git a/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx b/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx index 0c8efca1..5f57c1b5 100644 --- a/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx +++ b/desktop/src/hooks/useScheduledTaskDesktopNotifications.test.tsx @@ -31,6 +31,7 @@ describe('useScheduledTaskDesktopNotifications', () => { listMock.mockReset() getRecentRunsMock.mockReset() notifyDesktopMock.mockReset() + notifyDesktopMock.mockResolvedValue(true) }) it('does not notify old runs on first poll and notifies new desktop-enabled task runs later', async () => { @@ -126,4 +127,43 @@ describe('useScheduledTaskDesktopNotifications', () => { expect(notifyDesktopMock).not.toHaveBeenCalled() }) + + it('does not mark a run as notified when desktop notification delivery fails', async () => { + notifyDesktopMock + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + listMock.mockResolvedValue({ + tasks: [{ + id: 'task-1', + name: 'Daily review', + cron: '* * * * *', + prompt: 'review', + enabled: true, + createdAt: 1, + notification: { enabled: true, channels: ['desktop'] }, + }], + }) + getRecentRunsMock + .mockResolvedValueOnce({ runs: [] }) + .mockResolvedValue({ + runs: [{ + id: 'run-new', + taskId: 'task-1', + taskName: 'Daily review', + startedAt: '2026-05-03T00:01:00.000Z', + completedAt: '2026-05-03T00:01:01.000Z', + status: 'completed', + prompt: 'review', + }], + }) + + render() + await vi.waitFor(() => expect(getRecentRunsMock).toHaveBeenCalledTimes(1)) + + await vi.advanceTimersByTimeAsync(30_000) + await vi.waitFor(() => expect(notifyDesktopMock).toHaveBeenCalledTimes(1)) + + await vi.advanceTimersByTimeAsync(30_000) + await vi.waitFor(() => expect(notifyDesktopMock).toHaveBeenCalledTimes(2)) + }) }) diff --git a/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts b/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts index 7bf70ad3..061db96c 100644 --- a/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts +++ b/desktop/src/hooks/useScheduledTaskDesktopNotifications.ts @@ -89,12 +89,12 @@ export function useScheduledTaskDesktopNotifications(): void { for (const run of pendingRuns) { const notification = formatTaskRunNotification(run) - notifyDesktop({ + const sent = await notifyDesktop({ dedupeKey: `scheduled-task:${run.id}`, title: notification.title, body: notification.body, }) - notifiedRunIds.add(run.id) + if (sent) notifiedRunIds.add(run.id) } writeNotifiedRunIds(notifiedRunIds) } catch (err) { diff --git a/desktop/src/lib/desktopNotifications.test.ts b/desktop/src/lib/desktopNotifications.test.ts index c64b6336..dfe5a83c 100644 --- a/desktop/src/lib/desktopNotifications.test.ts +++ b/desktop/src/lib/desktopNotifications.test.ts @@ -89,6 +89,20 @@ describe('desktopNotifications', () => { await vi.waitFor(() => expect(sender).toHaveBeenCalledTimes(1)) }) + it('does not consume dedupe keys when native notification delivery fails', async () => { + const sender = vi.fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + setNativeNotificationSenderForTests(sender) + + await expect(notifyDesktop({ dedupeKey: 'permission:retry', title: 'Permission required' })).resolves.toBe(false) + await expect(notifyDesktop({ dedupeKey: 'permission:retry', title: 'Permission required' })).resolves.toBe(true) + + expect(sender).toHaveBeenCalledTimes(2) + warnSpy.mockRestore() + }) + it('reports and requests native notification permission', async () => { notificationPluginMock.isPermissionGranted.mockResolvedValueOnce(false).mockResolvedValueOnce(false) notificationPluginMock.requestPermission.mockResolvedValue('granted') @@ -106,12 +120,12 @@ describe('desktopNotifications', () => { const sender = vi.fn(async () => true) setNativeNotificationSenderForTests(sender) - notifyDesktop({ + void notifyDesktop({ dedupeKey: 'permission:1', title: 'Permission required', body: 'Approve command execution', }) - notifyDesktop({ + void notifyDesktop({ dedupeKey: 'permission:1', title: 'Permission required', body: 'Approve command execution', diff --git a/desktop/src/lib/desktopNotifications.ts b/desktop/src/lib/desktopNotifications.ts index 8a2fa5a6..ce267edc 100644 --- a/desktop/src/lib/desktopNotifications.ts +++ b/desktop/src/lib/desktopNotifications.ts @@ -15,7 +15,9 @@ type NativeNotificationSender = (options: { title: string; body?: string }) => P export type DesktopNotificationPermission = NotificationPermission | 'unsupported' const notifiedKeys = new Set() +const pendingKeys = new Set() const lastNotificationAtByScope = new Map() +const pendingCooldownScopes = new Set() let overrideNativeNotificationSender: NativeNotificationSender | null = null function readBrowserNotificationPermission(): DesktopNotificationPermission { @@ -119,27 +121,27 @@ async function requestWindowAttention(): Promise { } } -export function notifyDesktop(options: DesktopNotificationOptions): void { +export async function notifyDesktop(options: DesktopNotificationOptions): Promise { if (!useSettingsStore.getState().desktopNotificationsEnabled) { - return + return false } - if (options.dedupeKey && notifiedKeys.has(options.dedupeKey)) { - return + if (options.dedupeKey && (notifiedKeys.has(options.dedupeKey) || pendingKeys.has(options.dedupeKey))) { + return false } const cooldownScope = options.cooldownScope if (cooldownScope) { const now = Date.now() const lastNotificationAt = lastNotificationAtByScope.get(cooldownScope) ?? 0 - if (now - lastNotificationAt < (options.cooldownMs ?? DEFAULT_COOLDOWN_MS)) { - return + if (pendingCooldownScopes.has(cooldownScope) || now - lastNotificationAt < (options.cooldownMs ?? DEFAULT_COOLDOWN_MS)) { + return false } - lastNotificationAtByScope.set(cooldownScope, now) + pendingCooldownScopes.add(cooldownScope) } if (options.dedupeKey) { - notifiedKeys.add(options.dedupeKey) + pendingKeys.add(options.dedupeKey) } if (options.requestAttention) { @@ -147,20 +149,35 @@ export function notifyDesktop(options: DesktopNotificationOptions): void { } const sender = overrideNativeNotificationSender ?? sendNativeNotification - void Promise.resolve(sender({ title: options.title, body: options.body })).then((sent) => { + try { + const sent = await Promise.resolve(sender({ title: options.title, body: options.body })) + if (options.dedupeKey) { + pendingKeys.delete(options.dedupeKey) + if (sent) notifiedKeys.add(options.dedupeKey) + } + if (sent && cooldownScope) { + lastNotificationAtByScope.set(cooldownScope, Date.now()) + } + if (cooldownScope) pendingCooldownScopes.delete(cooldownScope) if (!sent && typeof console !== 'undefined') { console.warn('[desktopNotifications] native notification permission was not granted') } - }).catch((err) => { + return sent + } catch (err) { + if (options.dedupeKey) pendingKeys.delete(options.dedupeKey) + if (cooldownScope) pendingCooldownScopes.delete(cooldownScope) if (typeof console !== 'undefined') { console.warn('[desktopNotifications] failed to send native notification:', err) } - }) + return false + } } export function resetDesktopNotificationsForTests(): void { notifiedKeys.clear() + pendingKeys.clear() lastNotificationAtByScope.clear() + pendingCooldownScopes.clear() overrideNativeNotificationSender = null } diff --git a/desktop/src/pages/AdapterSettings.tsx b/desktop/src/pages/AdapterSettings.tsx index 3a6005e1..0738f83b 100644 --- a/desktop/src/pages/AdapterSettings.tsx +++ b/desktop/src/pages/AdapterSettings.tsx @@ -125,6 +125,12 @@ export function AdapterSettings() { if (result.message) { setWechatStatus(result.message) } + if (result.status === 'expired' || result.status === 'not_started') { + setWechatQrUrl(null) + setWechatSessionKey(null) + setIsWechatBinding(false) + return + } } catch (err) { if (!cancelled) setWechatStatus(err instanceof Error ? err.message : 'WeChat bind failed') } diff --git a/desktop/src/stores/adapterStore.ts b/desktop/src/stores/adapterStore.ts index e43b70ea..47ff82b5 100644 --- a/desktop/src/stores/adapterStore.ts +++ b/desktop/src/stores/adapterStore.ts @@ -50,7 +50,7 @@ type AdapterStore = { updateConfig: (patch: Partial) => Promise generatePairingCode: () => Promise startWechatLogin: () => Promise<{ qrcodeUrl?: string; message: string; sessionKey: string }> - pollWechatLogin: (sessionKey: string) => Promise<{ connected: boolean; message?: string }> + pollWechatLogin: (sessionKey: string) => Promise<{ connected: boolean; status?: string; message?: string }> removePairedUser: (platform: 'telegram' | 'feishu' | 'wechat' | 'dingtalk', userId: string | number) => Promise beginDingtalkRegistration: () => Promise pollDingtalkRegistration: (deviceCode: string) => Promise @@ -104,7 +104,7 @@ export const useAdapterStore = create((set, get) => ({ pollWechatLogin: async (sessionKey) => { const result = await adaptersApi.pollWechatLogin(sessionKey) if ('connected' in result && result.connected === false) { - return { connected: false, message: result.message } + return { connected: false, status: result.status, message: result.message } } if ('wechat' in result || 'telegram' in result || 'feishu' in result || 'dingtalk' in result) { set({ config: result }) diff --git a/package-lock.json b/package-lock.json index 18d78c53..4752450d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5582,9 +5582,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", diff --git a/package.json b/package.json index 84e03205..f9f8c724 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,9 @@ "yaml": "^2.8.3", "zod": "^4.3.6" }, + "overrides": { + "follow-redirects": "^1.16.0" + }, "devDependencies": { "mermaid": "^11.14.0", "vitepress": "^1.6.3", diff --git a/src/server/__tests__/adapters.test.ts b/src/server/__tests__/adapters.test.ts index 9f792886..7229a7bb 100644 --- a/src/server/__tests__/adapters.test.ts +++ b/src/server/__tests__/adapters.test.ts @@ -57,6 +57,19 @@ describe('Adapters API', () => { expect(json.wechat.accountId).toBe('bot-1') }) + it('writes adapter credentials with owner-only permissions', async () => { + const put = makeRequest('PUT', '/api/adapters', { + telegram: { + botToken: 'telegram-secret-token', + }, + }) + expect((await handleAdaptersApi(put.req, put.url, put.segments)).status).toBe(200) + + const configPath = path.join(tmpDir, 'adapters.json') + const stat = await fs.stat(configPath) + expect(stat.mode & 0o777).toBe(0o600) + }) + it('masks and preserves DingTalk client secrets', async () => { const put = makeRequest('PUT', '/api/adapters', { dingtalk: { diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index 48f770ec..1cbc2a05 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -486,6 +486,47 @@ describe('ProviderService', () => { expect(clearedEnv.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS).toBeUndefined() }) + test('auth status treats preset default auth as active provider auth', async () => { + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput({ + presetId: 'lmstudio', + apiKey: '', + authStrategy: 'auth_token_empty_api_key', + models: { + main: 'lmstudio-model', + haiku: 'lmstudio-model', + sonnet: 'lmstudio-model', + opus: 'lmstudio-model', + }, + })) + await svc.activateProvider(provider.id) + + const status = await svc.checkAuthStatus() + + expect(status).toEqual({ + hasAuth: true, + source: 'cc-haha-provider', + activeProvider: provider.name, + }) + }) + + test('auth status treats dummy proxy auth as active provider auth', async () => { + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput({ + apiKey: '', + apiFormat: 'openai_chat', + })) + await svc.activateProvider(provider.id) + + const status = await svc.checkAuthStatus() + + expect(status).toEqual({ + hasAuth: true, + source: 'cc-haha-provider', + activeProvider: provider.name, + }) + }) + test('provider auto compact window should override preset default env on activation and runtime env', async () => { const svc = new ProviderService() const provider = await svc.addProvider(sampleInput({ diff --git a/src/server/services/adapterService.ts b/src/server/services/adapterService.ts index d2c8bfa0..6d727fd1 100644 --- a/src/server/services/adapterService.ts +++ b/src/server/services/adapterService.ts @@ -153,12 +153,16 @@ class AdapterService { private async writeConfig(data: AdapterFileConfig): Promise { const filePath = getConfigPath() const dir = path.dirname(filePath) - await fs.mkdir(dir, { recursive: true }) + await fs.mkdir(dir, { recursive: true, mode: 0o700 }) const tmpFile = `${filePath}.tmp.${Date.now()}` try { - await fs.writeFile(tmpFile, JSON.stringify(data, null, 2) + '\n', 'utf-8') + await fs.writeFile(tmpFile, JSON.stringify(data, null, 2) + '\n', { + encoding: 'utf-8', + mode: 0o600, + }) await fs.rename(tmpFile, filePath) + await fs.chmod(filePath, 0o600).catch(() => {}) } catch (err) { await fs.unlink(tmpFile).catch(() => {}) throw ApiError.internal(`Failed to write adapter config: ${err}`) diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index 03a16ec0..fb5739a3 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -393,8 +393,13 @@ export class ProviderService { const index = await this.readIndex() if (index.activeId) { const provider = index.providers.find(p => p.id === index.activeId) - if (provider?.apiKey) { - return { hasAuth: true, source: 'cc-haha-provider', activeProvider: provider.name } + if (provider) { + const presetDefaultEnv = getPresetDefaultEnv(provider.presetId) + const needsProxy = provider.apiFormat != null && provider.apiFormat !== 'anthropic' + const authEnv = buildProviderAuthEnv(provider, presetDefaultEnv, needsProxy) + if (Object.values(authEnv).some(value => value.length > 0)) { + return { hasAuth: true, source: 'cc-haha-provider', activeProvider: provider.name } + } } } diff --git a/src/services/api/azureOpenAI.ts b/src/services/api/azureOpenAI.ts index 5b3e2071..cf48c8a7 100644 --- a/src/services/api/azureOpenAI.ts +++ b/src/services/api/azureOpenAI.ts @@ -47,6 +47,7 @@ type OpenAIResponse = { id?: string output?: OpenAIResponseOutputItem[] output_text?: string + status?: string usage?: { input_tokens?: number output_tokens?: number @@ -67,7 +68,11 @@ export function resolveAzureOpenAIEndpoint(): string { const apiVersion = process.env.AZURE_OPENAI_API_VERSION || DEFAULT_API_VERSION const url = new URL(baseUrl) const path = url.pathname.replace(/\/$/, '') - if (!/\/openai\//i.test(path)) { + if (/\/openai\/responses$/i.test(path)) { + url.pathname = path + } else if (/\/openai(?:\/.*)?$/i.test(path)) { + url.pathname = path.replace(/\/openai(?:\/.*)?$/i, '/openai/responses') + } else { url.pathname = `${path}/openai/responses` } @@ -312,6 +317,7 @@ export function parseAzureOpenAIResponse(response: OpenAIResponse): { content: BetaContentBlock[] usage: BetaUsage responseId?: string + stopReason: 'end_turn' | 'tool_use' | 'max_tokens' } { const contentBlocks: BetaContentBlock[] = [] @@ -332,7 +338,14 @@ export function parseAzureOpenAIResponse(response: OpenAIResponse): { cache_creation_input_tokens: 0, } as BetaUsage - return { content: contentBlocks, usage, responseId: response.id } + const stopReason = + response.status === 'incomplete' + ? 'max_tokens' + : contentBlocks.some(block => block.type === 'tool_use') + ? 'tool_use' + : 'end_turn' + + return { content: contentBlocks, usage, responseId: response.id, stopReason } } export async function requestAzureOpenAI(params: { @@ -347,7 +360,7 @@ export async function requestAzureOpenAI(params: { agents: AgentDefinition[] allowedAgentTypes?: string[] signal: AbortSignal -}): Promise<{ content: BetaContentBlock[]; usage: BetaUsage; responseId?: string }>{ +}): Promise<{ content: BetaContentBlock[]; usage: BetaUsage; responseId?: string; stopReason: 'end_turn' | 'tool_use' | 'max_tokens' }>{ const deployment = resolveAzureOpenAIDeployment(params.model) const endpoint = resolveAzureOpenAIEndpoint() const headers = getAzureOpenAIHeaders() diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index e85db0dd..c71d0103 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -1058,7 +1058,7 @@ async function* queryModel( model: options.model, role: "assistant", content: result.content, - stop_reason: "end_turn", + stop_reason: result.stopReason, usage: result.usage, }, requestId: result.responseId ?? undefined, diff --git a/tests/azureOpenAI.test.ts b/tests/azureOpenAI.test.ts index fb570dc2..dede1fc1 100644 --- a/tests/azureOpenAI.test.ts +++ b/tests/azureOpenAI.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test" import { buildAzureOpenAIInput, + parseAzureOpenAIResponse, resolveAzureOpenAIEndpoint, resolveAzureOpenAIDeployment, } from "../src/services/api/azureOpenAI.js" @@ -20,6 +21,28 @@ test("resolveAzureOpenAIEndpoint appends responses path and api-version", () => process.env.AZURE_OPENAI_API_VERSION = prevVersion }) +test("resolveAzureOpenAIEndpoint normalizes existing Azure OpenAI paths", () => { + const prevBase = process.env.AZURE_OPENAI_BASE_URL + const prevVersion = process.env.AZURE_OPENAI_API_VERSION + process.env.AZURE_OPENAI_API_VERSION = "2025-04-01-preview" + + process.env.AZURE_OPENAI_BASE_URL = + "https://example.cognitiveservices.azure.com/openai/v1/?foo=bar" + let url = new URL(resolveAzureOpenAIEndpoint()) + expect(url.pathname).toBe("/openai/responses") + expect(url.searchParams.get("foo")).toBe("bar") + expect(url.searchParams.get("api-version")).toBe("2025-04-01-preview") + + process.env.AZURE_OPENAI_BASE_URL = + "https://example.cognitiveservices.azure.com/openai/responses?api-version=custom" + url = new URL(resolveAzureOpenAIEndpoint()) + expect(url.pathname).toBe("/openai/responses") + expect(url.searchParams.get("api-version")).toBe("2025-04-01-preview") + + process.env.AZURE_OPENAI_BASE_URL = prevBase + process.env.AZURE_OPENAI_API_VERSION = prevVersion +}) + test("buildAzureOpenAIInput maps tool_use and tool_result", () => { const input = buildAzureOpenAIInput([ { @@ -54,6 +77,23 @@ test("buildAzureOpenAIInput maps tool_use and tool_result", () => { expect(input.some(msg => msg.role === "tool")).toBe(true) }) +test("parseAzureOpenAIResponse derives tool stop reason from function calls", () => { + const result = parseAzureOpenAIResponse({ + id: "resp-1", + output: [ + { + type: "function_call", + id: "call-1", + name: "my_tool", + arguments: "{\"foo\":\"bar\"}", + }, + ], + }) + + expect(result.stopReason).toBe("tool_use") + expect(result.content[0]?.type).toBe("tool_use") +}) + test("resolveAzureOpenAIDeployment throws when codex mapping is missing", () => { const prevBase = process.env.AZURE_OPENAI_BASE_URL const prevEnv = process.env.AZURE_OPENAI_CODEX_DEPLOYMENT