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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-04 21:37:00 +08:00
parent 37845e5534
commit 2459488703
24 changed files with 294 additions and 42 deletions

View File

@ -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=="],
}
}

View File

@ -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 })
}
})

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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"

View File

@ -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) => {

View File

@ -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<string, boolean>()

View File

@ -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=="],

View File

@ -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(<Harness />)
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))
})
})

View File

@ -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) {

View File

@ -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',

View File

@ -15,7 +15,9 @@ type NativeNotificationSender = (options: { title: string; body?: string }) => P
export type DesktopNotificationPermission = NotificationPermission | 'unsupported'
const notifiedKeys = new Set<string>()
const pendingKeys = new Set<string>()
const lastNotificationAtByScope = new Map<string, number>()
const pendingCooldownScopes = new Set<string>()
let overrideNativeNotificationSender: NativeNotificationSender | null = null
function readBrowserNotificationPermission(): DesktopNotificationPermission {
@ -119,27 +121,27 @@ async function requestWindowAttention(): Promise<boolean> {
}
}
export function notifyDesktop(options: DesktopNotificationOptions): void {
export async function notifyDesktop(options: DesktopNotificationOptions): Promise<boolean> {
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
}

View File

@ -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')
}

View File

@ -50,7 +50,7 @@ type AdapterStore = {
updateConfig: (patch: Partial<AdapterFileConfig>) => Promise<void>
generatePairingCode: () => Promise<string>
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<void>
beginDingtalkRegistration: () => Promise<DingtalkRegistrationBegin>
pollDingtalkRegistration: (deviceCode: string) => Promise<DingtalkRegistrationPoll>
@ -104,7 +104,7 @@ export const useAdapterStore = create<AdapterStore>((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 })

6
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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: {

View File

@ -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({

View File

@ -153,12 +153,16 @@ class AdapterService {
private async writeConfig(data: AdapterFileConfig): Promise<void> {
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}`)

View File

@ -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 }
}
}
}

View File

@ -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()

View File

@ -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,

View File

@ -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