diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index ff462c39..8ea16603 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -96,6 +96,9 @@ jobs: with: bun-version: latest + - name: Install ripgrep + run: sudo apt-get update && sudo apt-get install -y ripgrep + - name: Install root dependencies run: bun install diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 1a73f287..bf6ac6eb 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -31,10 +31,19 @@ describe('ConversationService', () => { let originalPath: string | undefined let originalShell: string | undefined let originalZdotdir: string | undefined + let originalNvmDir: string | undefined let originalDisableTerminalShellEnv: string | undefined + let originalServerPort: number | undefined beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-')) + // ProviderService.serverPort is a process-wide static; other server tests + // (e.g. conversations.test.ts starting a server on an OS-assigned port) can + // leave it set to a random value. Pin it to the default so the managed + // ANTHROPIC_BASE_URL assertions below are deterministic, and restore it + // afterwards. + originalServerPort = ProviderService.getServerPort() + ProviderService.setServerPort(3456) originalConfigDir = process.env.CLAUDE_CONFIG_DIR originalApiKey = process.env.ANTHROPIC_API_KEY originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN @@ -54,6 +63,7 @@ describe('ConversationService', () => { originalPath = process.env.PATH originalShell = process.env.SHELL originalZdotdir = process.env.ZDOTDIR + originalNvmDir = process.env.NVM_DIR originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV process.env.CLAUDE_CONFIG_DIR = tmpDir @@ -74,6 +84,11 @@ describe('ConversationService', () => { delete process.env.CC_HAHA_TRACE_PROVIDER_NAME delete process.env.CC_HAHA_TRACE_PROVIDER_FORMAT process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1' + // The CI runner exports a real NVM_DIR (/home/runner/.nvm). buildChildEnv's + // shell-env merge lets inherited process env win over shell-captured values, + // so a leaked NVM_DIR would shadow the fake .zshrc value the shell-capture + // tests assert. Clear it; the per-test fake shell is the only intended source. + delete process.env.NVM_DIR resetTerminalShellEnvironmentCacheForTests() }) @@ -135,10 +150,14 @@ describe('ConversationService', () => { if (originalZdotdir === undefined) delete process.env.ZDOTDIR else process.env.ZDOTDIR = originalZdotdir + if (originalNvmDir === undefined) delete process.env.NVM_DIR + else process.env.NVM_DIR = originalNvmDir + if (originalDisableTerminalShellEnv === undefined) delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV else process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = originalDisableTerminalShellEnv resetTerminalShellEnvironmentCacheForTests() + if (originalServerPort !== undefined) ProviderService.setServerPort(originalServerPort) await fs.rm(tmpDir, { recursive: true, force: true }) }) diff --git a/src/server/__tests__/cron-scheduler-launcher.test.ts b/src/server/__tests__/cron-scheduler-launcher.test.ts index 364b0495..2f51d482 100644 --- a/src/server/__tests__/cron-scheduler-launcher.test.ts +++ b/src/server/__tests__/cron-scheduler-launcher.test.ts @@ -22,6 +22,7 @@ const originalClaudeCodeEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT const originalHome = process.env.HOME const originalShell = process.env.SHELL const originalZdotdir = process.env.ZDOTDIR +const originalNvmDir = process.env.NVM_DIR const originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV const isWindows = process.platform === 'win32' @@ -101,6 +102,11 @@ function restoreEnv(): void { } else { delete process.env.ZDOTDIR } + if (originalNvmDir) { + process.env.NVM_DIR = originalNvmDir + } else { + delete process.env.NVM_DIR + } if (originalDisableTerminalShellEnv) { process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = originalDisableTerminalShellEnv } else { @@ -111,16 +117,28 @@ function restoreEnv(): void { describe('cron scheduler launcher resolution', () => { let tmpDir: string + let originalServerPort: number | undefined beforeEach(async () => { tmpDir = await createTmpDir() process.env.CLAUDE_CONFIG_DIR = path.join(tmpDir, 'config') process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1' + // The CI runner exports a real NVM_DIR (/home/runner/.nvm); the shell-env + // merge lets inherited process env shadow shell-captured values, which would + // break the shell-capture assertions. Clear it so the fake shell is the only + // source. + delete process.env.NVM_DIR + // ProviderService.serverPort is a process-wide static other tests may leave + // at a random value; pin it so the managed ANTHROPIC_BASE_URL assertions are + // deterministic, restore afterwards. + originalServerPort = ProviderService.getServerPort() + ProviderService.setServerPort(3456) resetTerminalShellEnvironmentCacheForTests() }) afterEach(async () => { restoreEnv() + if (originalServerPort !== undefined) ProviderService.setServerPort(originalServerPort) await cleanupTmpDir(tmpDir) }) diff --git a/src/server/__tests__/e2e/full-flow.test.ts b/src/server/__tests__/e2e/full-flow.test.ts index 7d48965a..1ce3b993 100644 --- a/src/server/__tests__/e2e/full-flow.test.ts +++ b/src/server/__tests__/e2e/full-flow.test.ts @@ -112,7 +112,7 @@ describe('E2E: Full Flow', () => { it('should return diagnostics', async () => { const { data } = await api('GET', '/api/status/diagnostics') - expect(data.platform).toBe('darwin') + expect(data.platform).toBe(process.platform) expect(data.configDir).toBe(tmpDir) }) diff --git a/src/server/__tests__/h5-access-auth.test.ts b/src/server/__tests__/h5-access-auth.test.ts index 03b74a7a..1f532aba 100644 --- a/src/server/__tests__/h5-access-auth.test.ts +++ b/src/server/__tests__/h5-access-auth.test.ts @@ -191,8 +191,13 @@ function expectWebSocketUpgradeThenClose(url: string): Promise { } const settingsSurfaceEndpoints = [ - { path: '/api/mcp', expected: { servers: [] } }, - { path: '/api/plugins', expected: { plugins: [] } }, + // Assert the response *shape* (key present), not an exact empty array: the + // server reads MCP/plugin config from the current working directory, so when + // the suite runs at the repo root (which has a tracked .mcp.json) `servers` + // is non-empty. These cases exist to verify H5-token auth (401/401/200), not + // the discovered config contents. + { path: '/api/mcp', expectedKey: 'servers' }, + { path: '/api/plugins', expectedKey: 'plugins' }, { path: '/api/agents', expectedKey: 'activeAgents' }, ] as const @@ -713,11 +718,7 @@ describe('remote H5 auth and CORS integration', () => { expect(validTokenResponse.status).toBe(200) expect(validTokenResponse.headers.get('Access-Control-Allow-Origin')).toBe(PHONE_ORIGIN) const body = await validTokenResponse.json() - if ('expected' in endpoint) { - expect(body).toMatchObject(endpoint.expected) - } else { - expect(body).toHaveProperty(endpoint.expectedKey) - } + expect(body).toHaveProperty(endpoint.expectedKey) } }) diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 0c6ac6b1..b8a4e2f6 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -119,19 +119,6 @@ const prewarmIdleTimers = new Map>() const DEFAULT_PREWARM_IDLE_TIMEOUT_MS = 5 * 60_000 const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']) -// Track whether a session is currently mid-turn (anything other than 'idle'). -// Updated inside sendMessage by observing outbound `status` events. Used to -// avoid yanking the CLI subprocess out from under an in-progress generation -// when the user changes runtime config (model / effort / thinking) mid-stream. -const sessionGenerationBusy = new Set() - -// When a runtime config change arrives while the session is busy, we record -// the WS that should drive the eventual restart and apply it on the next -// transition to 'idle'. The actual override values are read from -// `runtimeOverrides` at restart time, so a later set_runtime_config naturally -// supersedes an earlier deferred one. -const pendingDeferredRuntimeRestarts = new Map>() - async function sendRepositoryStartupStatus( ws: ServerWebSocket, sessionId: string, @@ -1331,8 +1318,6 @@ function cleanupSessionRuntimeState(sessionId: string) { runtimeTransitionPromises.delete(sessionId) sessionStartupPromises.delete(sessionId) lastResolvedStartupWorkDirs.delete(sessionId) - sessionGenerationBusy.delete(sessionId) - pendingDeferredRuntimeRestarts.delete(sessionId) clearPrewarmState(sessionId) } @@ -2133,49 +2118,21 @@ function toApiRetryServerMessage(cliMsg: any): ServerMessage | null { } function sendMessage(ws: ServerWebSocket, message: ServerMessage) { - // Track per-session generation busy state by observing outbound `status` - // events. This lets handleSetRuntimeConfig defer a restart that would - // otherwise kill the CLI subprocess in the middle of a streaming response. - const sessionId = ws.data.sessionId - if (sessionId && message.type === 'status') { - if (message.state === 'idle') { - const wasBusy = sessionGenerationBusy.delete(sessionId) - if (wasBusy) drainPendingRuntimeRestart(sessionId) - } else { - sessionGenerationBusy.add(sessionId) - } - } ws.send(JSON.stringify(message)) } -// Apply a pending deferred runtime restart that was queued while the session -// was busy. Reads the latest override from `runtimeOverrides` at restart time, -// so multiple toggles during streaming naturally collapse into a single -// restart with the most recent config. -function drainPendingRuntimeRestart(sessionId: string) { - const ws = pendingDeferredRuntimeRestarts.get(sessionId) - if (!ws) return - pendingDeferredRuntimeRestarts.delete(sessionId) - if (!conversationService.hasSession(sessionId)) return - void enqueueRuntimeTransition(sessionId, async () => { - if (!conversationService.hasSession(sessionId)) return - await restartSessionWithRuntimeConfig(ws, sessionId) - }) -} - -// Schedule a runtime-config restart, but defer until the session is idle to -// avoid interrupting an in-progress generation. The override values are -// already in `runtimeOverrides[sessionId]` (and persisted) before this is -// called, so getRuntimeSettings will read them when the deferred restart -// finally runs. +// Restart the CLI subprocess to apply a runtime-config change. The override +// values are already in `runtimeOverrides[sessionId]` (and persisted) before +// this is called, so getRuntimeSettings will read them at restart time. +// +// Mid-turn protection is handled upstream by the active-turn deferral +// (`shouldDeferRuntimeRestartForActiveTurn` + `deferredRuntimeRestarts`, drained +// by the turn's `result` callback in `bindActiveUserTurnCompletion`), which +// gates on the real turn lifecycle rather than on outbound status events. async function scheduleRestartSessionWithRuntimeConfig( ws: ServerWebSocket, sessionId: string, ): Promise { - if (sessionGenerationBusy.has(sessionId)) { - pendingDeferredRuntimeRestarts.set(sessionId, ws) - return - } await restartSessionWithRuntimeConfig(ws, sessionId) } diff --git a/src/utils/mcpStdioEnvironment.test.ts b/src/utils/mcpStdioEnvironment.test.ts index bee53071..54e8b37c 100644 --- a/src/utils/mcpStdioEnvironment.test.ts +++ b/src/utils/mcpStdioEnvironment.test.ts @@ -51,10 +51,16 @@ describe('MCP stdio environment', () => { PATH: process.env.PATH, SHELL: process.env.SHELL, ZDOTDIR: process.env.ZDOTDIR, + NVM_DIR: process.env.NVM_DIR, CC_HAHA_DISABLE_TERMINAL_SHELL_ENV: process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV, } delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV + // The CI runner exports a real NVM_DIR (/home/runner/.nvm). The env merge + // lets inherited process env win over shell-captured values, so a leaked + // NVM_DIR would shadow the fake .zshrc value asserted below. Clear it so the + // only source of NVM_DIR is the fake shell config. + delete process.env.NVM_DIR resetMcpStdioEnvironmentCacheForTests() })