cc-haha/adapters/common/http-client.ts
程序员阿江(Relakkes) 2459488703 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
2026-05-04 21:37:30 +08:00

208 lines
6.2 KiB
TypeScript

import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
export type RecentProject = {
projectPath: string
realPath: string
projectName: string
isGit: boolean
repoName: string | null
branch: string | null
modifiedAt: string
sessionCount: number
}
export type GitInfo = {
branch: string | null
repoName: string | null
workDir: string
changedFiles: number
}
export type SessionTask = {
id: string
subject: string
status: 'pending' | 'in_progress' | 'completed'
}
export class AdapterHttpClient {
readonly httpBaseUrl: string
private readonly allowedProjectRoots: string[]
/** Default timeout for HTTP requests (30 seconds) */
private static readonly DEFAULT_TIMEOUT_MS = 30_000
constructor(wsUrl: string, options?: { allowedProjectRoots?: string[] }) {
this.httpBaseUrl = wsUrl
.replace(/^ws:/, 'http:')
.replace(/^wss:/, 'https:')
.replace(/\/$/, '')
this.allowedProjectRoots = (options?.allowedProjectRoots ?? [])
.map(resolveExistingProjectPath)
.filter((value): value is string => Boolean(value))
}
/** Create an AbortController with timeout */
private createTimeoutController(timeoutMs = AdapterHttpClient.DEFAULT_TIMEOUT_MS): {
controller: AbortController
timer: ReturnType<typeof setTimeout>
} {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
return { controller, timer }
}
async createSession(workDir: string): Promise<string> {
const { controller, timer } = this.createTimeoutController()
try {
const res = await fetch(`${this.httpBaseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir }),
signal: controller.signal,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }))
throw new Error(`Failed to create session: ${(err as any).message}`)
}
const data = (await res.json()) as { sessionId: string }
return data.sessionId
} finally {
clearTimeout(timer)
}
}
async listRecentProjects(): Promise<RecentProject[]> {
const { controller, timer } = this.createTimeoutController()
try {
const res = await fetch(`${this.httpBaseUrl}/api/sessions/recent-projects`, {
signal: controller.signal,
})
if (!res.ok) {
throw new Error(`Failed to list projects: ${res.statusText}`)
}
const data = (await res.json()) as { projects: RecentProject[] }
return data.projects
} finally {
clearTimeout(timer)
}
}
/**
* Match a project by index (1-based) or fuzzy name from recent projects.
* Returns { project, ambiguous[] } — ambiguous is set when multiple projects match.
*/
async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> {
const directPath = resolveExistingProjectPath(query)
if (directPath) {
if (!isPathWithinAllowedRoots(directPath, this.allowedProjectRoots)) {
return {}
}
return {
project: {
projectPath: directPath,
realPath: directPath,
projectName: path.basename(directPath) || directPath,
isGit: fs.existsSync(path.join(directPath, '.git')),
repoName: null,
branch: null,
modifiedAt: new Date().toISOString(),
sessionCount: 0,
},
}
}
const projects = await this.listRecentProjects()
// Try as 1-based index
const num = parseInt(query, 10)
if (!isNaN(num) && num >= 1 && num <= projects.length && String(num) === query.trim()) {
return { project: projects[num - 1] }
}
const q = query.toLowerCase()
// Exact project name match
const exact = projects.find(p => p.projectName.toLowerCase() === q)
if (exact) return { project: exact }
// Fuzzy: name or path contains query
const matches = projects.filter(p =>
p.projectName.toLowerCase().includes(q) ||
p.realPath.toLowerCase().includes(q)
)
if (matches.length === 1) return { project: matches[0] }
if (matches.length > 1) return { ambiguous: matches }
return {}
}
async getGitInfo(sessionId: string): Promise<GitInfo> {
const { controller, timer } = this.createTimeoutController()
try {
const res = await fetch(`${this.httpBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/git-info`, {
signal: controller.signal,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }))
throw new Error(`Failed to load git info: ${(err as any).message}`)
}
return (await res.json()) as GitInfo
} finally {
clearTimeout(timer)
}
}
async getTasksForSession(sessionId: string): Promise<SessionTask[]> {
const { controller, timer } = this.createTimeoutController()
try {
const res = await fetch(`${this.httpBaseUrl}/api/tasks/lists/${encodeURIComponent(sessionId)}`, {
signal: controller.signal,
})
if (!res.ok) {
if (res.status === 404) return []
const err = await res.json().catch(() => ({ message: res.statusText }))
throw new Error(`Failed to load tasks: ${(err as any).message}`)
}
const data = (await res.json()) as { tasks?: SessionTask[] }
return Array.isArray(data.tasks) ? data.tasks : []
} finally {
clearTimeout(timer)
}
}
}
function isPathWithinAllowedRoots(target: string, roots: string[]): boolean {
if (roots.length === 0) return false
for (const root of roots) {
const relative = path.relative(root, target)
if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
return true
}
}
return false
}
function resolveExistingProjectPath(query: string): string | null {
const trimmed = query.trim()
if (!trimmed) return null
const expanded = trimmed === '~'
? os.homedir()
: trimmed.startsWith('~/')
? path.join(os.homedir(), trimmed.slice(2))
: trimmed
if (!path.isAbsolute(expanded)) return null
try {
const realPath = fs.realpathSync(expanded)
return fs.statSync(realPath).isDirectory() ? realPath : null
} catch {
return null
}
}