cc-haha/desktop/scripts/build-sidecars.test.ts
程序员阿江(Relakkes) 9b88c5a527 fix(test): probe the surface the desktop process token actually guards
The Windows x64 build job went red on `Verify compiled Windows sidecar
startup`, asserting that a loopback request without
`CC_HAHA_LOCAL_ACCESS_TOKEN` returns 403 while it returned 200. Nothing
about x64 is involved — that step carries
`if: matrix.smoke_platform == 'windows' && matrix.arch == 'x64'`, so it is
the only job in the whole matrix that runs the smoke at all. Any regression
in this area can surface nowhere else.

The 200 is correct. `7d2a8a3cd keep loopback trusted without the desktop
process token` deliberately made the token additive again: gating every
local request behind it turned the Grok OAuth success page, `/preview-fs`
links and plain `curl` into 401s, because none of that traffic can ever
carry the token. Loopback is trusted on its own; the token is demanded only
on the `/api/h5-access` control plane, where another program on the same
box must not be able to publish the user's sessions to the network. The
assertion, written before that change, was still guarding the path that had
been intentionally opened.

So the probe moves to the boundary that is actually enforced, and gains a
positive assertion — loopback without a token must be 200 — so the additive
model is pinned down rather than merely no longer contradicted. Reverting to
the pre-`7d2a8a3cd` behaviour now fails the smoke instead of passing it.

Three copies of the stale assertion existed; all three are updated. Only the
compiled-sidecar smoke runs in CI, but `local-index-benchmark.ts` and its
corpus test were already failing the same way for anyone running them
locally. The benchmark also cancels the probe response bodies now: an unread
body holds its connection open, and that would land in the event-loop delay
and RSS samples taken immediately after.

Verified with the CI parameters — `bun run build:sidecars` then
`CC_HAHA_COMPILED_SIDECAR_SMOKE_STARTS=20 bun run test:compiled-sidecar-smoke`,
8/8 — and by running the benchmark directly, which now reports
`loopbackAuth` as 200/403/403/200 with validation intact.
2026-07-27 05:37:31 +08:00

647 lines
21 KiB
TypeScript

// @vitest-environment node
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path, { join as joinPath } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
reserveLocalPort,
resolveHostTriple,
resolveSidecarExecutable,
} from '../electron/services/sidecarManager'
function readBuildScript() {
return readFileSync(path.resolve(import.meta.dirname, 'build-sidecars.ts'), 'utf8')
}
function readCliLauncher() {
return readFileSync(
path.resolve(import.meta.dirname, '../../bin/claude-haha'),
'utf8',
)
}
function readJson(pathname: string): { scripts?: Record<string, string> } {
return JSON.parse(readFileSync(pathname, 'utf8')) as {
scripts?: Record<string, string>
}
}
function extractWindowsX64BunTarget(source: string) {
const match = source.match(/case 'x86_64-pc-windows-msvc':[\s\S]*?return '([^']+)'/)
return match?.[1] ?? null
}
type SidecarProcess = {
child: ChildProcessWithoutNullStreams
exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>
logs: () => string
}
function controlledSidecarEnvironment(
homeDir: string,
configDir: string,
localAccessToken: string,
): NodeJS.ProcessEnv {
const inheritedKeys = [
'PATH',
'TMPDIR',
'TEMP',
'TMP',
'SystemRoot',
'WINDIR',
'ComSpec',
'PATHEXT',
'LD_LIBRARY_PATH',
'DYLD_LIBRARY_PATH',
] as const
const env: NodeJS.ProcessEnv = {}
for (const key of inheritedKeys) {
if (process.env[key] !== undefined) env[key] = process.env[key]
}
return {
...env,
HOME: homeDir,
CLAUDE_CONFIG_DIR: configDir,
CC_HAHA_LOCAL_ACCESS_TOKEN: localAccessToken,
NODE_ENV: 'test',
NO_PROXY: '127.0.0.1,localhost,::1',
no_proxy: '127.0.0.1,localhost,::1',
}
}
function startCompiledSidecar(options: {
executable: string
repoRoot: string
port: number
env: NodeJS.ProcessEnv
}): SidecarProcess {
const child = spawn(options.executable, [
'server',
'--app-root',
options.repoRoot,
'--host',
'127.0.0.1',
'--port',
String(options.port),
], {
cwd: options.repoRoot,
env: options.env,
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', chunk => {
output += String(chunk)
})
child.stderr.on('data', chunk => {
output += String(chunk)
})
const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
(resolveExit, rejectExit) => {
child.once('error', rejectExit)
child.once('exit', (code, signal) => resolveExit({ code, signal }))
},
)
return { child, exited, logs: () => output }
}
/**
* What the compiled binary must still enforce about the desktop process token.
*
* Loopback is trusted on its own, so reading sessions without the token is the
* expected behaviour — plenty of legitimate local traffic (the OAuth success
* page, `/preview-fs` links, plain `curl`) can never carry it. The token is a
* strictly additive credential, required only on the `/api/h5-access` control
* plane, where another program on the same box must not be able to publish the
* user's sessions to the network.
*/
type CompiledSidecarAuthProof = {
loopbackWithoutTokenStatus: number
controlPlaneMissingTokenStatus: number
controlPlaneWrongTokenStatus: number
controlPlaneCorrectTokenStatus: number
}
async function waitForCompiledSidecar(options: {
baseUrl: string
expectedSessionId: string
deadlineMs: number
exited: SidecarProcess['exited']
logs: () => string
localAccessToken: string
onAuthProbe?: (proof: CompiledSidecarAuthProof) => void
}): Promise<void> {
const deadline = Date.now() + options.deadlineMs
let authProbeComplete = false
let lastFailure = 'no response received'
while (Date.now() < deadline) {
const exited = await Promise.race([
options.exited.then(result => ({ exited: true as const, result })),
new Promise<{ exited: false }>(resolveWait => {
setTimeout(() => resolveWait({ exited: false }), 0)
}),
])
if (exited.exited) {
throw new Error(
`compiled sidecar exited before readiness (${JSON.stringify(exited.result)}):\n${options.logs()}`,
)
}
try {
const authorizedHeaders = {
Authorization: `Bearer ${options.localAccessToken}`,
}
const health = await fetchBeforeCompiledSidecarDeadline(
`${options.baseUrl}/health`,
{ headers: authorizedHeaders },
deadline,
)
if (!health.ok) throw new Error(`health returned ${health.status}`)
let pendingAuthProof: CompiledSidecarAuthProof | undefined
if (!authProbeComplete) {
const controlPlaneUrl = `${options.baseUrl}/api/h5-access`
const [
loopbackWithoutToken,
controlPlaneMissingToken,
controlPlaneWrongToken,
controlPlaneCorrectToken,
] = await Promise.all([
fetchBeforeCompiledSidecarDeadline(
`${options.baseUrl}/api/sessions?limit=1&offset=0`,
{},
deadline,
),
fetchBeforeCompiledSidecarDeadline(controlPlaneUrl, {}, deadline),
fetchBeforeCompiledSidecarDeadline(
controlPlaneUrl,
{ headers: { Authorization: 'Bearer wrong-local-access-token' } },
deadline,
),
fetchBeforeCompiledSidecarDeadline(
controlPlaneUrl,
{ headers: authorizedHeaders },
deadline,
),
])
pendingAuthProof = {
loopbackWithoutTokenStatus: loopbackWithoutToken.status,
controlPlaneMissingTokenStatus: controlPlaneMissingToken.status,
controlPlaneWrongTokenStatus: controlPlaneWrongToken.status,
controlPlaneCorrectTokenStatus: controlPlaneCorrectToken.status,
}
}
const sessionsResponse = await fetchBeforeCompiledSidecarDeadline(
`${options.baseUrl}/api/sessions?limit=400&offset=0`,
{ headers: authorizedHeaders },
deadline,
)
if (!sessionsResponse.ok) {
throw new Error(`sessions returned ${sessionsResponse.status}`)
}
if (pendingAuthProof) {
options.onAuthProbe?.(pendingAuthProof)
authProbeComplete = true
}
const body = await sessionsResponse.json() as {
sessions?: Array<{ id?: string }>
total?: number
index?: { mode?: string; state?: string; indexed?: number }
}
if (
body.index?.mode === 'on' &&
body.index.state === 'ready' &&
body.index.indexed === 1 &&
body.total === 1 &&
body.sessions?.some(session => session.id === options.expectedSessionId)
) {
return
}
lastFailure = `sessions not ready: ${JSON.stringify(body)}`
} catch (error) {
// Startup and backfill are asynchronous; keep polling until the deadline.
lastFailure = error instanceof Error ? error.message : String(error)
}
const remainingMs = deadline - Date.now()
if (remainingMs > 0) {
await new Promise(resolveWait => setTimeout(resolveWait, Math.min(25, remainingMs)))
}
}
throw new Error(
`compiled sidecar did not become ready (${lastFailure}):\n${options.logs()}`,
)
}
async function fetchBeforeCompiledSidecarDeadline(
input: RequestInfo | URL,
init: RequestInit,
deadline: number,
): Promise<Response> {
const remainingMs = deadline - Date.now()
if (remainingMs <= 0) {
throw new Error('compiled sidecar request deadline exceeded')
}
const controller = new AbortController()
let responseAfterHeaders: Response | undefined
let activeReader: ReadableStreamDefaultReader<Uint8Array> | undefined
const operation = (async (): Promise<Response> => {
const response = await fetch(input, { ...init, signal: controller.signal })
responseAfterHeaders = response
if (controller.signal.aborted) {
try {
const cancellation = response.body?.cancel(controller.signal.reason)
if (cancellation) void Promise.resolve(cancellation).catch(() => {})
} catch {
// A fetch that ignored abort may also return a non-cancellable body.
}
throw controller.signal.reason instanceof Error
? controller.signal.reason
: new Error('compiled sidecar request deadline exceeded')
}
if (!response.body) return response
const reader = response.body.getReader()
activeReader = reader
const chunks: Uint8Array[] = []
let byteLength = 0
try {
while (true) {
const result = await reader.read()
if (result.done) break
chunks.push(result.value)
byteLength += result.value.byteLength
}
} finally {
if (activeReader === reader) activeReader = undefined
try {
reader.releaseLock()
} catch {
// A timed-out read may still be pending on a non-cooperative stream.
}
}
const body = new Uint8Array(byteLength)
let offset = 0
for (const chunk of chunks) {
body.set(chunk, offset)
offset += chunk.byteLength
}
return new Response(body.byteLength > 0 ? body.buffer : null, {
status: response.status,
statusText: response.statusText,
headers: new Headers(response.headers),
})
})()
return new Promise<Response>((resolve, reject) => {
let settled = false
const cancelBodyBestEffort = (reason: Error): void => {
try {
const cancellation = activeReader
? activeReader.cancel(reason)
: responseAfterHeaders?.body?.cancel(reason)
if (cancellation) void Promise.resolve(cancellation).catch(() => {})
} catch {
// Cancellation is best-effort; the deadline must not wait for it.
}
}
const timeout = setTimeout(() => {
if (settled) return
settled = true
clearTimeout(timeout)
const error = new Error('compiled sidecar request deadline exceeded')
controller.abort(error)
cancelBodyBestEffort(error)
reject(error)
}, remainingMs)
operation.then(
response => {
if (settled) {
try {
const cancellation = response.body?.cancel()
if (cancellation) void Promise.resolve(cancellation).catch(() => {})
} catch {
// Consume a late response without extending the caller deadline.
}
return
}
settled = true
clearTimeout(timeout)
resolve(response)
},
error => {
if (settled) return
settled = true
clearTimeout(timeout)
reject(error)
},
)
})
}
async function terminateCompiledSidecar(processHandle: SidecarProcess): Promise<void> {
if (processHandle.child.exitCode !== null || processHandle.child.signalCode !== null) {
await processHandle.exited
return
}
processHandle.child.kill('SIGTERM')
const graceful = await Promise.race([
processHandle.exited.then(() => true),
new Promise<boolean>(resolveWait => setTimeout(() => resolveWait(false), 10_000)),
])
if (graceful) return
processHandle.child.kill('SIGKILL')
await processHandle.exited
}
describe('build-sidecars Windows x64 target mapping', () => {
it('uses the baseline Bun runtime so older CPUs do not crash with Illegal Instruction', () => {
expect(extractWindowsX64BunTarget(readBuildScript())).toBe('bun-windows-x64-baseline')
})
it('compiles the sidecar with the transcript classifier feature', () => {
expect(readBuildScript()).toContain("features: ['TRANSCRIPT_CLASSIFIER']")
})
it('starts the development CLI with the transcript classifier feature', () => {
expect(readCliLauncher()).toContain('--feature=TRANSCRIPT_CLASSIFIER')
})
it('wires the opt-in compiled sidecar smoke into the native gate', () => {
const desktopPackage = readJson(path.resolve(import.meta.dirname, '../package.json'))
const rootPackage = readJson(path.resolve(import.meta.dirname, '../../package.json'))
expect(desktopPackage.scripts?.['test:compiled-sidecar-smoke']).toContain(
'CC_HAHA_RUN_COMPILED_SIDECAR_SMOKE=1',
)
expect(rootPackage.scripts?.['check:native']).toContain(
'test:compiled-sidecar-smoke',
)
})
it('keeps the request deadline active until a delayed response body is consumable', async () => {
const originalFetch = globalThis.fetch
let bodyClosed = false
let bodyTimer: ReturnType<typeof setTimeout> | undefined
globalThis.fetch = (async () => new Response(new ReadableStream<Uint8Array>({
start(controller) {
bodyTimer = setTimeout(() => {
controller.enqueue(new TextEncoder().encode('{"ready":true}'))
bodyClosed = true
controller.close()
}, 20)
},
}), {
status: 200,
headers: { 'content-type': 'application/json' },
})) as typeof fetch
try {
const response = await fetchBeforeCompiledSidecarDeadline(
'http://127.0.0.1:1/delayed-body',
{},
Date.now() + 250,
)
expect(bodyClosed).toBe(true)
await expect(response.json()).resolves.toEqual({ ready: true })
} finally {
if (bodyTimer) clearTimeout(bodyTimer)
globalThis.fetch = originalFetch
}
})
it('rejects at the deadline and consumes non-cooperative late fetch settlements', async () => {
const originalFetch = globalThis.fetch
try {
for (const outcome of ['resolve', 'reject'] as const) {
let lateBodyCancelCount = 0
let resolveFetch!: (response: Response) => void
let rejectFetch!: (error: Error) => void
globalThis.fetch = (() => new Promise<Response>((resolve, reject) => {
resolveFetch = resolve
rejectFetch = reject
})) as typeof fetch
const request = fetchBeforeCompiledSidecarDeadline(
`http://127.0.0.1:1/late-${outcome}`,
{},
Date.now() + 10,
)
const lateTimer = setTimeout(() => {
if (outcome === 'resolve') {
resolveFetch(new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"late":'))
},
cancel() {
lateBodyCancelCount += 1
},
}), { status: 200 }))
} else {
rejectFetch(new Error('late fetch rejection'))
}
}, 40)
await expect(request).rejects.toThrow('compiled sidecar request deadline exceeded')
await new Promise(resolveWait => setTimeout(resolveWait, 50))
clearTimeout(lateTimer)
if (outcome === 'resolve') expect(lateBodyCancelCount).toBe(1)
}
} finally {
globalThis.fetch = originalFetch
}
})
it('bounds a sidecar response with headers but a never-ending body and still cleans up', async () => {
const originalFetch = globalThis.fetch
const originalEnvironment = {
HOME: process.env.HOME,
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
CC_HAHA_LOCAL_INDEX: process.env.CC_HAHA_LOCAL_INDEX,
CC_HAHA_LOCAL_ACCESS_TOKEN: process.env.CC_HAHA_LOCAL_ACCESS_TOKEN,
}
const rootDir = await mkdtemp(joinPath(tmpdir(), 'cc-haha-hung-sidecar-smoke-'))
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
stdio: ['pipe', 'pipe', 'pipe'],
})
const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
(resolveExit, rejectExit) => {
child.once('error', rejectExit)
child.once('exit', (code, signal) => resolveExit({ code, signal }))
},
)
const processHandle: SidecarProcess = {
child,
exited,
logs: () => '',
}
let bodyCancelCount = 0
let rejectCancellation: ((error: Error) => void) | undefined
globalThis.fetch = (async () => new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"partial":'))
},
cancel() {
bodyCancelCount += 1
return new Promise<void>((_resolve, reject) => {
rejectCancellation = reject
})
},
}), {
status: 200,
headers: { 'content-type': 'application/json' },
})) as typeof fetch
const startedAt = performance.now()
let guardTimer: ReturnType<typeof setTimeout> | undefined
try {
await expect(Promise.race([
waitForCompiledSidecar({
baseUrl: 'http://127.0.0.1:1',
expectedSessionId: 'never-ready',
deadlineMs: 25,
exited,
logs: processHandle.logs,
localAccessToken: 'hung-sidecar-local-access-token',
}),
new Promise<never>((_resolve, reject) => {
guardTimer = setTimeout(
() => reject(new Error('never-ending response body guard fired')),
250,
)
}),
])).rejects.toThrow('compiled sidecar did not become ready')
} finally {
if (guardTimer) clearTimeout(guardTimer)
globalThis.fetch = originalFetch
try {
await terminateCompiledSidecar(processHandle)
} finally {
await rm(rootDir, { recursive: true, force: true })
}
}
expect(performance.now() - startedAt).toBeLessThan(150)
expect(bodyCancelCount).toBeGreaterThan(0)
rejectCancellation?.(new Error('late body cancellation rejection'))
await new Promise(resolveWait => setTimeout(resolveWait, 0))
expect(child.exitCode !== null || child.signalCode !== null).toBe(true)
expect(await stat(rootDir).then(() => true, () => false)).toBe(false)
expect(process.env.HOME).toBe(originalEnvironment.HOME)
expect(process.env.CLAUDE_CONFIG_DIR).toBe(originalEnvironment.CLAUDE_CONFIG_DIR)
expect(process.env.CC_HAHA_LOCAL_INDEX).toBe(originalEnvironment.CC_HAHA_LOCAL_INDEX)
expect(process.env.CC_HAHA_LOCAL_ACCESS_TOKEN).toBe(
originalEnvironment.CC_HAHA_LOCAL_ACCESS_TOKEN,
)
})
})
const compiledSidecarSmokeEnabled =
process.env.CC_HAHA_RUN_COMPILED_SIDECAR_SMOKE === '1'
const configuredCompiledSidecarStarts = Number.parseInt(
process.env.CC_HAHA_COMPILED_SIDECAR_SMOKE_STARTS ?? '',
10,
)
const compiledSidecarSmokeStarts = Number.isInteger(configuredCompiledSidecarStarts)
&& configuredCompiledSidecarStarts >= 2
&& configuredCompiledSidecarStarts <= 50
? configuredCompiledSidecarStarts
: 2
describe.skipIf(!compiledSidecarSmokeEnabled)('compiled sidecar local-index smoke', () => {
it('uses SQLite by default, serves one indexed session, and reopens the database', async () => {
const repoRoot = path.resolve(import.meta.dirname, '../..')
const desktopRoot = path.resolve(import.meta.dirname, '..')
const executable = resolveSidecarExecutable(
desktopRoot,
resolveHostTriple(),
)
await stat(executable)
const rootDir = await mkdtemp(joinPath(tmpdir(), 'cc-haha-compiled-sidecar-smoke-'))
const homeDir = joinPath(rootDir, 'home')
const configDir = joinPath(homeDir, '.claude')
const projectDir = joinPath(configDir, 'projects', '-tmp-compiled-sidecar-smoke')
const sessionId = 'compiled-sidecar-smoke-session'
const localAccessToken = 'compiled-sidecar-smoke-local-access-token'
const authenticationProofs: CompiledSidecarAuthProof[] = []
const databasePath = joinPath(
configDir,
'cc-haha',
'db',
'index-v1.sqlite',
)
let activeProcess: SidecarProcess | undefined
const startAndVerify = async (): Promise<void> => {
const port = await reserveLocalPort('127.0.0.1')
activeProcess = startCompiledSidecar({
executable,
repoRoot,
port,
env: controlledSidecarEnvironment(homeDir, configDir, localAccessToken),
})
try {
await waitForCompiledSidecar({
baseUrl: `http://127.0.0.1:${port}`,
expectedSessionId: sessionId,
deadlineMs: 30_000,
exited: activeProcess.exited,
logs: activeProcess.logs,
localAccessToken,
onAuthProbe: proof => authenticationProofs.push(proof),
})
expect((await stat(databasePath)).isFile()).toBe(true)
} finally {
await terminateCompiledSidecar(activeProcess)
activeProcess = undefined
}
}
try {
await mkdir(projectDir, { recursive: true })
await writeFile(
joinPath(projectDir, `${sessionId}.jsonl`),
`${JSON.stringify({
parentUuid: null,
isSidechain: false,
type: 'user',
message: {
role: 'user',
content: 'Compiled sidecar smoke prompt',
},
uuid: 'compiled-sidecar-smoke-user-entry',
timestamp: '2026-07-15T00:00:00.000Z',
})}\n`,
'utf8',
)
for (let start = 0; start < compiledSidecarSmokeStarts; start += 1) {
await startAndVerify()
}
expect(authenticationProofs).toHaveLength(compiledSidecarSmokeStarts)
for (const proof of authenticationProofs) {
expect(proof).toEqual({
loopbackWithoutTokenStatus: 200,
controlPlaneMissingTokenStatus: 403,
controlPlaneWrongTokenStatus: 403,
controlPlaneCorrectTokenStatus: 200,
})
}
} finally {
try {
if (activeProcess) await terminateCompiledSidecar(activeProcess)
} finally {
await rm(rootDir, { recursive: true, force: true })
}
}
expect(await stat(rootDir).then(() => true, () => false)).toBe(false)
}, Math.max(90_000, compiledSidecarSmokeStarts * 10_000))
})