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