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": { "packages": {
"@grammyjs/types": ["@grammyjs/types@3.26.0", "https://registry.npmmirror.com/@grammyjs/types/-/types-3.26.0.tgz", {}, "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A=="], "@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/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=="], "@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/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/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/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=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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') expect(projects[0].projectName).toBe('my-app')
}) })
it('matchProject accepts an absolute local project path without recent history', async () => { it('matchProject accepts an absolute local project path inside an allowed root without recent history', async () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-project-')) const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
const projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'))
try { try {
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
globalThis.fetch = mock(() => { globalThis.fetch = mock(() => {
throw new Error('recent projects should not be queried for absolute paths') throw new Error('recent projects should not be queried for absolute paths')
}) as any }) as any
@ -69,6 +71,26 @@ describe('AdapterHttpClient', () => {
expect(result.project?.projectName).toBe(path.basename(projectDir)) expect(result.project?.projectName).toBe(path.basename(projectDir))
expect((globalThis.fetch as any).mock.calls).toHaveLength(0) expect((globalThis.fetch as any).mock.calls).toHaveLength(0)
} finally { } 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 }) fs.rmSync(projectDir, { recursive: true, force: true })
} }
}) })

View File

@ -28,14 +28,18 @@ export type SessionTask = {
export class AdapterHttpClient { export class AdapterHttpClient {
readonly httpBaseUrl: string readonly httpBaseUrl: string
private readonly allowedProjectRoots: string[]
/** Default timeout for HTTP requests (30 seconds) */ /** Default timeout for HTTP requests (30 seconds) */
private static readonly DEFAULT_TIMEOUT_MS = 30_000 private static readonly DEFAULT_TIMEOUT_MS = 30_000
constructor(wsUrl: string) { constructor(wsUrl: string, options?: { allowedProjectRoots?: string[] }) {
this.httpBaseUrl = wsUrl this.httpBaseUrl = wsUrl
.replace(/^ws:/, 'http:') .replace(/^ws:/, 'http:')
.replace(/^wss:/, 'https:') .replace(/^wss:/, 'https:')
.replace(/\/$/, '') .replace(/\/$/, '')
this.allowedProjectRoots = (options?.allowedProjectRoots ?? [])
.map(resolveExistingProjectPath)
.filter((value): value is string => Boolean(value))
} }
/** Create an AbortController with timeout */ /** Create an AbortController with timeout */
@ -91,6 +95,10 @@ export class AdapterHttpClient {
async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> { async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> {
const directPath = resolveExistingProjectPath(query) const directPath = resolveExistingProjectPath(query)
if (directPath) { if (directPath) {
if (!isPathWithinAllowedRoots(directPath, this.allowedProjectRoots)) {
return {}
}
return { return {
project: { project: {
projectPath: directPath, 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 { function resolveExistingProjectPath(query: string): string | null {
const trimmed = query.trim() const trimmed = query.trim()
if (!trimmed) return null if (!trimmed) return null

View File

@ -57,7 +57,7 @@ const defaultWorkDir = getConfiguredWorkDir(config, config.dingtalk)
const bridge = new WsBridge(config.serverUrl, 'dingtalk') const bridge = new WsBridge(config.serverUrl, 'dingtalk')
const dedup = new MessageDedup() const dedup = new MessageDedup()
const sessionStore = new SessionStore() const sessionStore = new SessionStore()
const httpClient = new AdapterHttpClient(config.serverUrl) const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] })
const attachmentStore = new AttachmentStore() const attachmentStore = new AttachmentStore()
const media = new DingTalkMediaService(attachmentStore) const media = new DingTalkMediaService(attachmentStore)
const aiCards = new DingTalkAiCardService(getAccessToken, config.dingtalk.clientId) 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 bridge = new WsBridge(config.serverUrl, 'feishu')
const dedup = new MessageDedup() const dedup = new MessageDedup()
const sessionStore = new SessionStore() const sessionStore = new SessionStore()
const httpClient = new AdapterHttpClient(config.serverUrl)
const defaultWorkDir = getConfiguredWorkDir(config, config.feishu) const defaultWorkDir = getConfiguredWorkDir(config, config.feishu)
const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] })
// Attachment plumbing — shared by inbound (download) and outbound (upload) paths. // Attachment plumbing — shared by inbound (download) and outbound (upload) paths.
const attachmentStore = new AttachmentStore() const attachmentStore = new AttachmentStore()

View File

@ -16,11 +16,15 @@
"test:dingtalk": "bun test dingtalk/" "test:dingtalk": "bun test dingtalk/"
}, },
"dependencies": { "dependencies": {
"@larksuiteoapi/node-sdk": "^1.60.0",
"dingtalk-stream": "2.1.4", "dingtalk-stream": "2.1.4",
"grammy": "^1.42.0", "grammy": "^1.42.0",
"@larksuiteoapi/node-sdk": "^1.60.0",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"overrides": {
"follow-redirects": "^1.16.0",
"protobufjs": "^7.5.5"
},
"devDependencies": { "devDependencies": {
"@types/ws": "^8.5.0", "@types/ws": "^8.5.0",
"bun-types": "latest" "bun-types": "latest"

View File

@ -47,8 +47,8 @@ const bot = new Bot(config.telegram.botToken)
const bridge = new WsBridge(config.serverUrl, 'tg') const bridge = new WsBridge(config.serverUrl, 'tg')
const dedup = new MessageDedup() const dedup = new MessageDedup()
const sessionStore = new SessionStore() const sessionStore = new SessionStore()
const httpClient = new AdapterHttpClient(config.serverUrl)
const defaultWorkDir = getConfiguredWorkDir(config, config.telegram) const defaultWorkDir = getConfiguredWorkDir(config, config.telegram)
const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] })
const attachmentStore = new AttachmentStore() const attachmentStore = new AttachmentStore()
const media = new TelegramMediaService(bot, attachmentStore) const media = new TelegramMediaService(bot, attachmentStore)
attachmentStore.gc().catch((err) => { attachmentStore.gc().catch((err) => {

View File

@ -46,8 +46,8 @@ const botToken = config.wechat.botToken
const bridge = new WsBridge(config.serverUrl, 'wechat') const bridge = new WsBridge(config.serverUrl, 'wechat')
const dedup = new MessageDedup() const dedup = new MessageDedup()
const sessionStore = new SessionStore() const sessionStore = new SessionStore()
const httpClient = new AdapterHttpClient(config.serverUrl)
const defaultWorkDir = getConfiguredWorkDir(config, config.wechat) const defaultWorkDir = getConfiguredWorkDir(config, config.wechat)
const httpClient = new AdapterHttpClient(config.serverUrl, { allowedProjectRoots: [defaultWorkDir] })
const attachmentStore = new AttachmentStore() const attachmentStore = new AttachmentStore()
const media = new WechatMediaService(attachmentStore) const media = new WechatMediaService(attachmentStore)
const pendingProjectSelection = new Map<string, boolean>() const pendingProjectSelection = new Map<string, boolean>()

View File

@ -78,6 +78,9 @@
}, },
}, },
}, },
"overrides": {
"follow-redirects": "^1.16.0",
},
"packages": { "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=="], "@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=="], "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=="], "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() listMock.mockReset()
getRecentRunsMock.mockReset() getRecentRunsMock.mockReset()
notifyDesktopMock.mockReset() notifyDesktopMock.mockReset()
notifyDesktopMock.mockResolvedValue(true)
}) })
it('does not notify old runs on first poll and notifies new desktop-enabled task runs later', async () => { 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() 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) { for (const run of pendingRuns) {
const notification = formatTaskRunNotification(run) const notification = formatTaskRunNotification(run)
notifyDesktop({ const sent = await notifyDesktop({
dedupeKey: `scheduled-task:${run.id}`, dedupeKey: `scheduled-task:${run.id}`,
title: notification.title, title: notification.title,
body: notification.body, body: notification.body,
}) })
notifiedRunIds.add(run.id) if (sent) notifiedRunIds.add(run.id)
} }
writeNotifiedRunIds(notifiedRunIds) writeNotifiedRunIds(notifiedRunIds)
} catch (err) { } catch (err) {

View File

@ -89,6 +89,20 @@ describe('desktopNotifications', () => {
await vi.waitFor(() => expect(sender).toHaveBeenCalledTimes(1)) 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 () => { it('reports and requests native notification permission', async () => {
notificationPluginMock.isPermissionGranted.mockResolvedValueOnce(false).mockResolvedValueOnce(false) notificationPluginMock.isPermissionGranted.mockResolvedValueOnce(false).mockResolvedValueOnce(false)
notificationPluginMock.requestPermission.mockResolvedValue('granted') notificationPluginMock.requestPermission.mockResolvedValue('granted')
@ -106,12 +120,12 @@ describe('desktopNotifications', () => {
const sender = vi.fn(async () => true) const sender = vi.fn(async () => true)
setNativeNotificationSenderForTests(sender) setNativeNotificationSenderForTests(sender)
notifyDesktop({ void notifyDesktop({
dedupeKey: 'permission:1', dedupeKey: 'permission:1',
title: 'Permission required', title: 'Permission required',
body: 'Approve command execution', body: 'Approve command execution',
}) })
notifyDesktop({ void notifyDesktop({
dedupeKey: 'permission:1', dedupeKey: 'permission:1',
title: 'Permission required', title: 'Permission required',
body: 'Approve command execution', body: 'Approve command execution',

View File

@ -15,7 +15,9 @@ type NativeNotificationSender = (options: { title: string; body?: string }) => P
export type DesktopNotificationPermission = NotificationPermission | 'unsupported' export type DesktopNotificationPermission = NotificationPermission | 'unsupported'
const notifiedKeys = new Set<string>() const notifiedKeys = new Set<string>()
const pendingKeys = new Set<string>()
const lastNotificationAtByScope = new Map<string, number>() const lastNotificationAtByScope = new Map<string, number>()
const pendingCooldownScopes = new Set<string>()
let overrideNativeNotificationSender: NativeNotificationSender | null = null let overrideNativeNotificationSender: NativeNotificationSender | null = null
function readBrowserNotificationPermission(): DesktopNotificationPermission { 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) { if (!useSettingsStore.getState().desktopNotificationsEnabled) {
return return false
} }
if (options.dedupeKey && notifiedKeys.has(options.dedupeKey)) { if (options.dedupeKey && (notifiedKeys.has(options.dedupeKey) || pendingKeys.has(options.dedupeKey))) {
return return false
} }
const cooldownScope = options.cooldownScope const cooldownScope = options.cooldownScope
if (cooldownScope) { if (cooldownScope) {
const now = Date.now() const now = Date.now()
const lastNotificationAt = lastNotificationAtByScope.get(cooldownScope) ?? 0 const lastNotificationAt = lastNotificationAtByScope.get(cooldownScope) ?? 0
if (now - lastNotificationAt < (options.cooldownMs ?? DEFAULT_COOLDOWN_MS)) { if (pendingCooldownScopes.has(cooldownScope) || now - lastNotificationAt < (options.cooldownMs ?? DEFAULT_COOLDOWN_MS)) {
return return false
} }
lastNotificationAtByScope.set(cooldownScope, now) pendingCooldownScopes.add(cooldownScope)
} }
if (options.dedupeKey) { if (options.dedupeKey) {
notifiedKeys.add(options.dedupeKey) pendingKeys.add(options.dedupeKey)
} }
if (options.requestAttention) { if (options.requestAttention) {
@ -147,20 +149,35 @@ export function notifyDesktop(options: DesktopNotificationOptions): void {
} }
const sender = overrideNativeNotificationSender ?? sendNativeNotification 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') { if (!sent && typeof console !== 'undefined') {
console.warn('[desktopNotifications] native notification permission was not granted') 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') { if (typeof console !== 'undefined') {
console.warn('[desktopNotifications] failed to send native notification:', err) console.warn('[desktopNotifications] failed to send native notification:', err)
} }
}) return false
}
} }
export function resetDesktopNotificationsForTests(): void { export function resetDesktopNotificationsForTests(): void {
notifiedKeys.clear() notifiedKeys.clear()
pendingKeys.clear()
lastNotificationAtByScope.clear() lastNotificationAtByScope.clear()
pendingCooldownScopes.clear()
overrideNativeNotificationSender = null overrideNativeNotificationSender = null
} }

View File

@ -125,6 +125,12 @@ export function AdapterSettings() {
if (result.message) { if (result.message) {
setWechatStatus(result.message) setWechatStatus(result.message)
} }
if (result.status === 'expired' || result.status === 'not_started') {
setWechatQrUrl(null)
setWechatSessionKey(null)
setIsWechatBinding(false)
return
}
} catch (err) { } catch (err) {
if (!cancelled) setWechatStatus(err instanceof Error ? err.message : 'WeChat bind failed') 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> updateConfig: (patch: Partial<AdapterFileConfig>) => Promise<void>
generatePairingCode: () => Promise<string> generatePairingCode: () => Promise<string>
startWechatLogin: () => Promise<{ qrcodeUrl?: string; message: string; sessionKey: 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> removePairedUser: (platform: 'telegram' | 'feishu' | 'wechat' | 'dingtalk', userId: string | number) => Promise<void>
beginDingtalkRegistration: () => Promise<DingtalkRegistrationBegin> beginDingtalkRegistration: () => Promise<DingtalkRegistrationBegin>
pollDingtalkRegistration: (deviceCode: string) => Promise<DingtalkRegistrationPoll> pollDingtalkRegistration: (deviceCode: string) => Promise<DingtalkRegistrationPoll>
@ -104,7 +104,7 @@ export const useAdapterStore = create<AdapterStore>((set, get) => ({
pollWechatLogin: async (sessionKey) => { pollWechatLogin: async (sessionKey) => {
const result = await adaptersApi.pollWechatLogin(sessionKey) const result = await adaptersApi.pollWechatLogin(sessionKey)
if ('connected' in result && result.connected === false) { 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) { if ('wechat' in result || 'telegram' in result || 'feishu' in result || 'dingtalk' in result) {
set({ config: result }) set({ config: result })

6
package-lock.json generated
View File

@ -5582,9 +5582,9 @@
} }
}, },
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.15.11", "version": "1.16.0",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",

View File

@ -92,6 +92,9 @@
"yaml": "^2.8.3", "yaml": "^2.8.3",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"overrides": {
"follow-redirects": "^1.16.0"
},
"devDependencies": { "devDependencies": {
"mermaid": "^11.14.0", "mermaid": "^11.14.0",
"vitepress": "^1.6.3", "vitepress": "^1.6.3",

View File

@ -57,6 +57,19 @@ describe('Adapters API', () => {
expect(json.wechat.accountId).toBe('bot-1') 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 () => { it('masks and preserves DingTalk client secrets', async () => {
const put = makeRequest('PUT', '/api/adapters', { const put = makeRequest('PUT', '/api/adapters', {
dingtalk: { dingtalk: {

View File

@ -486,6 +486,47 @@ describe('ProviderService', () => {
expect(clearedEnv.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS).toBeUndefined() 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 () => { test('provider auto compact window should override preset default env on activation and runtime env', async () => {
const svc = new ProviderService() const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({ const provider = await svc.addProvider(sampleInput({

View File

@ -153,12 +153,16 @@ class AdapterService {
private async writeConfig(data: AdapterFileConfig): Promise<void> { private async writeConfig(data: AdapterFileConfig): Promise<void> {
const filePath = getConfigPath() const filePath = getConfigPath()
const dir = path.dirname(filePath) 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()}` const tmpFile = `${filePath}.tmp.${Date.now()}`
try { 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.rename(tmpFile, filePath)
await fs.chmod(filePath, 0o600).catch(() => {})
} catch (err) { } catch (err) {
await fs.unlink(tmpFile).catch(() => {}) await fs.unlink(tmpFile).catch(() => {})
throw ApiError.internal(`Failed to write adapter config: ${err}`) throw ApiError.internal(`Failed to write adapter config: ${err}`)

View File

@ -393,8 +393,13 @@ export class ProviderService {
const index = await this.readIndex() const index = await this.readIndex()
if (index.activeId) { if (index.activeId) {
const provider = index.providers.find(p => p.id === index.activeId) const provider = index.providers.find(p => p.id === index.activeId)
if (provider?.apiKey) { if (provider) {
return { hasAuth: true, source: 'cc-haha-provider', activeProvider: provider.name } 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 id?: string
output?: OpenAIResponseOutputItem[] output?: OpenAIResponseOutputItem[]
output_text?: string output_text?: string
status?: string
usage?: { usage?: {
input_tokens?: number input_tokens?: number
output_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 apiVersion = process.env.AZURE_OPENAI_API_VERSION || DEFAULT_API_VERSION
const url = new URL(baseUrl) const url = new URL(baseUrl)
const path = url.pathname.replace(/\/$/, '') 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` url.pathname = `${path}/openai/responses`
} }
@ -312,6 +317,7 @@ export function parseAzureOpenAIResponse(response: OpenAIResponse): {
content: BetaContentBlock[] content: BetaContentBlock[]
usage: BetaUsage usage: BetaUsage
responseId?: string responseId?: string
stopReason: 'end_turn' | 'tool_use' | 'max_tokens'
} { } {
const contentBlocks: BetaContentBlock[] = [] const contentBlocks: BetaContentBlock[] = []
@ -332,7 +338,14 @@ export function parseAzureOpenAIResponse(response: OpenAIResponse): {
cache_creation_input_tokens: 0, cache_creation_input_tokens: 0,
} as BetaUsage } 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: { export async function requestAzureOpenAI(params: {
@ -347,7 +360,7 @@ export async function requestAzureOpenAI(params: {
agents: AgentDefinition[] agents: AgentDefinition[]
allowedAgentTypes?: string[] allowedAgentTypes?: string[]
signal: AbortSignal 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 deployment = resolveAzureOpenAIDeployment(params.model)
const endpoint = resolveAzureOpenAIEndpoint() const endpoint = resolveAzureOpenAIEndpoint()
const headers = getAzureOpenAIHeaders() const headers = getAzureOpenAIHeaders()

View File

@ -1058,7 +1058,7 @@ async function* queryModel(
model: options.model, model: options.model,
role: "assistant", role: "assistant",
content: result.content, content: result.content,
stop_reason: "end_turn", stop_reason: result.stopReason,
usage: result.usage, usage: result.usage,
}, },
requestId: result.responseId ?? undefined, requestId: result.responseId ?? undefined,

View File

@ -1,6 +1,7 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { import {
buildAzureOpenAIInput, buildAzureOpenAIInput,
parseAzureOpenAIResponse,
resolveAzureOpenAIEndpoint, resolveAzureOpenAIEndpoint,
resolveAzureOpenAIDeployment, resolveAzureOpenAIDeployment,
} from "../src/services/api/azureOpenAI.js" } 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 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", () => { test("buildAzureOpenAIInput maps tool_use and tool_result", () => {
const input = buildAzureOpenAIInput([ const input = buildAzureOpenAIInput([
{ {
@ -54,6 +77,23 @@ test("buildAzureOpenAIInput maps tool_use and tool_result", () => {
expect(input.some(msg => msg.role === "tool")).toBe(true) 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", () => { test("resolveAzureOpenAIDeployment throws when codex mapping is missing", () => {
const prevBase = process.env.AZURE_OPENAI_BASE_URL const prevBase = process.env.AZURE_OPENAI_BASE_URL
const prevEnv = process.env.AZURE_OPENAI_CODEX_DEPLOYMENT const prevEnv = process.env.AZURE_OPENAI_CODEX_DEPLOYMENT