mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
37845e5534
commit
2459488703
@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>()
|
||||
|
||||
5
bun.lock
5
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=="],
|
||||
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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
6
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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}`)
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user