mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +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": {
|
"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=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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>()
|
||||||
|
|||||||
5
bun.lock
5
bun.lock
@ -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=="],
|
||||||
|
|
||||||
|
|||||||
@ -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))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
6
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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}`)
|
||||||
|
|||||||
@ -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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user