diff --git a/desktop/scripts/dev-launcher.test.ts b/desktop/scripts/dev-launcher.test.ts index df853ce0..2a1badea 100644 --- a/desktop/scripts/dev-launcher.test.ts +++ b/desktop/scripts/dev-launcher.test.ts @@ -26,6 +26,7 @@ describe('desktop dev launcher environment', () => { }) expect(env.ELECTRON_RENDERER_URL).toBe(DEFAULT_RENDERER_URL) + expect(env.CC_HAHA_TRUSTED_RENDERER_ORIGIN).toBe(DEFAULT_RENDERER_URL) expect(env.NO_PROXY).toBe('example.com,localhost,127.0.0.1,::1') expect(env.no_proxy).toBe(env.NO_PROXY) }) @@ -41,6 +42,15 @@ describe('desktop dev launcher environment', () => { expect(env.no_proxy).toBe(env.NO_PROXY) }) + it('derives the exact trusted origin from the validated renderer URL', () => { + const env = createElectronDevEnv({ + ELECTRON_RENDERER_URL: ' http://localhost:1777/app?dev=1 ', + CC_HAHA_TRUSTED_RENDERER_ORIGIN: 'https://untrusted.example', + }) + expect(env.CC_HAHA_TRUSTED_RENDERER_ORIGIN).toBe('http://localhost:1777') + expect(() => createElectronDevEnv({ ELECTRON_RENDERER_URL: 'https://untrusted.example' })).toThrow('Refusing non-local') + }) + it('deduplicates no_proxy entries', () => { expect(mergeNoProxy('localhost,127.0.0.1')).toBe('localhost,127.0.0.1,::1') }) diff --git a/desktop/scripts/electron-dev.ts b/desktop/scripts/electron-dev.ts index 64eeab34..8471ba85 100644 --- a/desktop/scripts/electron-dev.ts +++ b/desktop/scripts/electron-dev.ts @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { createServer, type ViteDevServer } from 'vite' +import { resolveRendererEntry } from '../electron/services/rendererEntry' export const DEFAULT_RENDERER_URL = 'http://localhost:1420' export const LOCAL_NO_PROXY_ENTRIES = ['localhost', '127.0.0.1', '::1'] @@ -19,11 +20,16 @@ export function mergeNoProxy(existing: string | undefined, required = LOCAL_NO_P } export function createElectronDevEnv(env: NodeJS.ProcessEnv = process.env) { - const rendererUrl = env.ELECTRON_RENDERER_URL ?? DEFAULT_RENDERER_URL + const rendererUrl = resolveRendererEntry({ + isPackaged: false, + appRoot: '', + env: { ELECTRON_RENDERER_URL: env.ELECTRON_RENDERER_URL?.trim() || DEFAULT_RENDERER_URL }, + }) const noProxy = mergeNoProxy(env.NO_PROXY ?? env.no_proxy) return { ...env, ELECTRON_RENDERER_URL: rendererUrl, + CC_HAHA_TRUSTED_RENDERER_ORIGIN: new URL(rendererUrl).origin, NO_PROXY: noProxy, no_proxy: noProxy, } diff --git a/desktop/src/components/workbench/WorkspaceFileTreePane.test.tsx b/desktop/src/components/workbench/WorkspaceFileTreePane.test.tsx index ae0d34b9..045d5b09 100644 --- a/desktop/src/components/workbench/WorkspaceFileTreePane.test.tsx +++ b/desktop/src/components/workbench/WorkspaceFileTreePane.test.tsx @@ -19,6 +19,7 @@ vi.mock('../../api/sessions', () => ({ import { useWorkspaceContentStore } from '../../stores/workspaceContentStore' import { WorkspaceFileTreePane } from './WorkspaceFileTreePane' +import { useWorkspaceChatContextStore } from '@/stores/workspaceChatContextStore' const SESSION = 'session-a' const originalScrollIntoView = HTMLElement.prototype.scrollIntoView @@ -460,3 +461,41 @@ describe('filter field', () => { expect(screen.getByTestId('workspace-file-tree-filter')).toHaveValue('') }) }) + +// #1322: a whole file must be attachable without opening it and selecting lines. +describe('file chat references', () => { + beforeEach(() => useWorkspaceChatContextStore.setState({ referencesBySession: {} })) + + it('adds a right-clicked file to its own session without opening it', async () => { + const { onOpen } = await renderPane() + fireEvent.contextMenu(screen.getByTestId('workspace-tree-row-README.md'), { clientX: 30, clientY: 40 }) + fireEvent.click(screen.getByRole('menuitem', { name: 'Add to chat' })) + expect(useWorkspaceChatContextStore.getState().referencesBySession[SESSION]).toEqual([ + expect.objectContaining({ kind: 'file', path: 'README.md', name: 'README.md' }), + ]) + expect(onOpen).not.toHaveBeenCalled() + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('supports keyboard invocation and dismisses without adding', async () => { + await renderPane() + const file = screen.getByTestId('workspace-tree-row-README.md') + fireEvent.keyDown(file, { key: 'F10', shiftKey: true }) + expect(screen.getByRole('menuitem', { name: 'Add to chat' })).toHaveFocus() + fireEvent.keyDown(screen.getByRole('menuitem'), { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + expect(useWorkspaceChatContextStore.getState().referencesBySession[SESSION]).toBeUndefined() + }) + + it('closes an old session menu when switching sessions', async () => { + const view = await renderPane() + fireEvent.contextMenu(screen.getByTestId('workspace-tree-row-README.md')) + expect(screen.getByRole('menuitem', { name: 'Add to chat' })).toBeInTheDocument() + await act(async () => { + view.rerender() + await Promise.resolve() + }) + expect(screen.queryByRole('menu')).toBeNull() + expect(useWorkspaceChatContextStore.getState().referencesBySession).toEqual({}) + }) +}) diff --git a/desktop/src/components/workbench/WorkspaceFileTreePane.tsx b/desktop/src/components/workbench/WorkspaceFileTreePane.tsx index 625056d4..74e1485b 100644 --- a/desktop/src/components/workbench/WorkspaceFileTreePane.tsx +++ b/desktop/src/components/workbench/WorkspaceFileTreePane.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { ChevronDown, ChevronRight } from 'lucide-react' import { SearchField } from '@/components/ui/SearchField' import { Button } from '@/components/ui/Button' @@ -9,6 +9,10 @@ import { useTranslation } from '../../i18n' import { EMPTY_WORKSPACE_TREE_VIEW, useWorkspaceContentStore } from '../../stores/workspaceContentStore' import { basenameOf } from '../../lib/workspace/types' import { useRovingTree } from './treeKeyboard' +import { useWorkspaceChatContextStore } from '@/stores/workspaceChatContextStore' +import { useDismissable } from '@/hooks/useDismissable' +import { useAnchoredPosition } from '@/hooks/useAnchoredPosition' +import { useMenuKeyboard } from '@/components/workbench/menuKeyboard' export type WorkspaceFileTreePaneProps = { sessionId: string @@ -42,6 +46,21 @@ export function WorkspaceFileTreePane({ autoFocus = false, }: WorkspaceFileTreePaneProps) { const t = useTranslation() + const [contextMenu, setContextMenu] = useState<{ sessionId: string; row: TreeRow; x: number; y: number } | null>(null) + const menu = contextMenu?.sessionId === sessionId ? contextMenu : null + const menuRef = useRef(null) + const menuTriggerRef = useRef(null) + const closeMenu = useCallback(() => setContextMenu(null), []) + useEffect(closeMenu, [closeMenu, sessionId]) + useDismissable({ open: menu !== null, refs: [menuRef], onDismiss: closeMenu }) + const handleMenuKeyDown = useMenuKeyboard({ open: menu !== null, menuRef, triggerRef: menuTriggerRef, onClose: closeMenu }) + const menuPosition = useAnchoredPosition({ + open: menu !== null, + anchorRect: { top: menu?.y ?? 0, bottom: menu?.y ?? 0, left: menu?.x ?? 0, right: menu?.x ?? 0 }, + floatingRef: menuRef, + offset: 0, + clampHeight: true, + }) const treeView = useWorkspaceContentStore((state) => state.treeViewBySession[sessionId] ?? EMPTY_WORKSPACE_TREE_VIEW) const setTreeView = useWorkspaceContentStore((state) => state.setTreeView) const { filter } = treeView @@ -312,7 +331,21 @@ export function WorkspaceFileTreePane({ handleActivate(row) }} onFocus={() => setFocusedPath(row.path)} - onKeyDown={(event) => handleKeyDown(event, row)} + onContextMenu={(event) => { + event.preventDefault() + menuTriggerRef.current = event.currentTarget + setContextMenu({ sessionId, row, x: event.clientX, y: event.clientY }) + }} + onKeyDown={(event) => { + if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) { + event.preventDefault() + const rect = event.currentTarget.getBoundingClientRect() + menuTriggerRef.current = event.currentTarget + setContextMenu({ sessionId, row, x: rect.left, y: rect.bottom }) + return + } + handleKeyDown(event, row) + }} style={{ paddingLeft: 6 + row.depth * 16 }} className={[ 'relative flex h-[34px] cursor-default items-center gap-1.5 rounded-[var(--radius-sm)] pr-2 text-[14px] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]', @@ -339,6 +372,26 @@ export function WorkspaceFileTreePane({ }) )} + {menu ? ( +
+ +
+ ) : null} ) } diff --git a/src/goals/goalState.test.ts b/src/goals/goalState.test.ts index 49fcbd1b..afa6694d 100644 --- a/src/goals/goalState.test.ts +++ b/src/goals/goalState.test.ts @@ -128,6 +128,22 @@ describe('goalState', () => { expect(cleared).toBeNull() }) + test('does not resurrect a cleared goal from an in-flight turn transcript (#1339)', () => { + const { appState, context } = hookContext() + const transcript = [ + createCommandInputMessage('Goal set: ship it'), + ] + setThreadGoalHook(context, 'thread-cleared-in-flight', 'ship it') + clearThreadGoalHook(context, 'thread-cleared-in-flight') + + expect(ensureThreadGoalHookFromTranscript(context, 'thread-cleared-in-flight', transcript)).toBeNull() + expect(appState.sessionHooks.get('thread-cleared-in-flight')?.hooks.Stop).toBeUndefined() + + setThreadGoalHook(context, 'thread-cleared-in-flight', 'new goal') + expect(getThreadGoal('thread-cleared-in-flight')?.objective).toBe('new goal') + clearThreadGoalHook(context, 'thread-cleared-in-flight') + }) + test('identifies standalone goal local command output for SDK forwarding', () => { expect( isGoalLocalCommandOutputContent( diff --git a/src/goals/goalState.ts b/src/goals/goalState.ts index 0a95e239..fcd0922a 100644 --- a/src/goals/goalState.ts +++ b/src/goals/goalState.ts @@ -31,6 +31,9 @@ const GOAL_HOOK_MARKER = '' const GOAL_HOOK_TIMEOUT_SECONDS = 45 const RESERVED_GOAL_ARGS = new Set(['status', 'pause', 'resume', 'complete']) const goalsByThread = new Map() +// A running turn can still hold a transcript from before /goal clear. +// Explicit session state must win over those stale transcript anchors. +const clearedGoalThreads = new Set() export function parseGoalCommand(args: string): ParsedGoalCommand { const trimmed = args.trim() @@ -62,6 +65,7 @@ export function setThreadGoalHook( now = Date.now(), ): ThreadGoal { clearThreadGoalHook(context, threadId) + clearedGoalThreads.delete(threadId) const hook = createGoalPromptHook(objective) const goal: ThreadGoal = { @@ -97,6 +101,7 @@ export function clearThreadGoalHook( context: Pick, threadId: string, ): ThreadGoal | null { + clearedGoalThreads.add(threadId) const goal = goalsByThread.get(threadId) ?? null if (goal) { removeSessionHook(context.setAppState, threadId, 'Stop', goal.hook) @@ -113,6 +118,7 @@ export function ensureThreadGoalHookFromTranscript( ): ThreadGoal | null { const current = goalsByThread.get(threadId) if (current) return current + if (clearedGoalThreads.has(threadId)) return null const restored = findActiveGoalObjective(messages) if (!restored) return null diff --git a/src/server/__tests__/h5-access-auth.test.ts b/src/server/__tests__/h5-access-auth.test.ts index 2c18c908..8bb25df7 100644 --- a/src/server/__tests__/h5-access-auth.test.ts +++ b/src/server/__tests__/h5-access-auth.test.ts @@ -23,6 +23,7 @@ let originalH5DistDir: string | undefined let originalClaudeAppRoot: string | undefined let originalServerAuthRequired: string | undefined let originalLocalAccessToken: string | undefined +let originalTrustedRendererOrigin: string | undefined let originalPetAccessToken: string | undefined let originalServerPort = 3456 const PHONE_ORIGIN = 'https://phone.example' @@ -221,6 +222,8 @@ beforeEach(async () => { originalClaudeAppRoot = process.env.CLAUDE_APP_ROOT originalServerAuthRequired = process.env.SERVER_AUTH_REQUIRED originalLocalAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN + originalTrustedRendererOrigin = process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN + delete process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN originalPetAccessToken = process.env.CC_HAHA_PET_ACCESS_TOKEN originalServerPort = ProviderService.getServerPort() process.env.CLAUDE_CONFIG_DIR = tmpDir @@ -257,6 +260,8 @@ afterEach(async () => { else process.env.SERVER_AUTH_REQUIRED = originalServerAuthRequired if (originalLocalAccessToken === undefined) delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN else process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = originalLocalAccessToken + if (originalTrustedRendererOrigin === undefined) delete process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN + else process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN = originalTrustedRendererOrigin if (originalPetAccessToken === undefined) delete process.env.CC_HAHA_PET_ACCESS_TOKEN else process.env.CC_HAHA_PET_ACCESS_TOKEN = originalPetAccessToken @@ -269,6 +274,34 @@ afterEach(async () => { }) describe('remote H5 auth and CORS integration', () => { + test('allows only the configured dev renderer preflight with a desktop process token', async () => { + process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'fixture-desktop-token' + process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN = 'http://localhost:1420' + await restartRemoteServer() + const headers = { + Origin: 'http://localhost:1420', + 'Access-Control-Request-Method': 'GET', + 'Access-Control-Request-Headers': 'authorization,content-type', + } + const preflight = await fetch(`${baseUrl}/api/settings`, { method: 'OPTIONS', headers }) + expect(preflight.status).toBe(204) + expect(preflight.headers.get('Access-Control-Allow-Origin')).toBe(headers.Origin) + const authenticated = await fetch(`${baseUrl}/api/settings`, { + headers: { Origin: headers.Origin, Authorization: 'Bearer fixture-desktop-token' }, + }) + expect(authenticated.status).toBe(200) + const unauthenticated = await fetch(`${baseUrl}/api/settings`, { headers: { Origin: headers.Origin } }) + expect(unauthenticated.status).toBe(403) + for (const origin of ['http://localhost:5173', 'http://127.0.0.1:1420', 'http://localhost:1421', 'http://[::1]:1420']) { + const response = await fetch(`${baseUrl}/api/settings`, { method: 'OPTIONS', headers: { ...headers, Origin: origin } }) + expect(response.status).toBe(403) + } + for (const endpoint of ['/api/h5-access', '/api/h5-access/enable', '/api/settings/session-cleanup']) { + const response = await fetch(`${baseUrl}${endpoint}`, { method: 'OPTIONS', headers }) + expect(response.status).toBe(403) + } + }) + test('serves the packaged H5 shell and static assets from the remote server', async () => { const shellResponse = await fetch(`${baseUrl}/`) expect(shellResponse.status).toBe(200) diff --git a/src/server/__tests__/h5-access-policy.test.ts b/src/server/__tests__/h5-access-policy.test.ts index 7d5310f5..9bc513f3 100644 --- a/src/server/__tests__/h5-access-policy.test.ts +++ b/src/server/__tests__/h5-access-policy.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { classifyH5Request, + resolveTrustedRendererOrigin, isLocalCredentialOnlyPath, isLoopbackHost, requiresLocalAccessCredential, @@ -16,6 +17,22 @@ const localContext = { clientAddress: '127.0.0.1' } const remoteContext = { clientAddress: '192.168.0.44' } describe('h5AccessPolicy', () => { + test('validates the development renderer origin before granting a preflight exception', () => { + expect(resolveTrustedRendererOrigin('http://localhost:1420/app')).toBe('http://localhost:1420') + for (const value of [undefined, '', 'invalid', 'file://', 'https://remote.example', 'http://user:pass@localhost:1420']) { + expect(resolveTrustedRendererOrigin(value)).toBeNull() + } + const request = req('http://127.0.0.1:3456/api/settings', { + method: 'OPTIONS', + headers: { Origin: 'http://localhost:1420', 'Access-Control-Request-Method': 'GET' }, + }) + const context = { ...localContext, localAccessTokenConfigured: true, trustedRendererOrigin: 'http://localhost:1420' } + expect(classifyH5Request(request, new URL(request.url), context)).toBe('local-trusted') + expect(classifyH5Request(request, new URL(request.url), { ...context, ...remoteContext })).toBe('h5-browser') + request.headers.set('X-Forwarded-For', '192.168.0.44') + expect(classifyH5Request(request, new URL(request.url), context)).toBe('h5-browser') + }) + test('recognizes loopback hosts as local trusted requests', () => { expect(isLoopbackHost('localhost')).toBe(true) expect(isLoopbackHost('127.0.0.1')).toBe(true) diff --git a/src/server/__tests__/open-target-service.test.ts b/src/server/__tests__/open-target-service.test.ts index 6d8b9cfb..24a73810 100644 --- a/src/server/__tests__/open-target-service.test.ts +++ b/src/server/__tests__/open-target-service.test.ts @@ -152,12 +152,65 @@ describe('openTargetService', () => { .toBe('/api/open-targets/icons/vscode') }) + it.each(['/Applications/Zed.app', join(homedir(), 'Applications', 'Zed.app')])( + 'detects Zed installed at %s and opens projects and files through the app bundle', + async (appPath) => { + const dir = await makeDir('cc-haha zed-') + const file = join(dir, 'hello world.txt') + await writeFile(file, 'fixture') + const state = createService('darwin', { paths: { [appPath]: true } }) + try { + expect((await state.service.listTargets()).targets).toContainEqual({ + id: 'zed', kind: 'ide', label: 'Zed', icon: 'zed', + iconUrl: '/api/open-targets/icons/zed', platform: 'darwin', + }) + await state.service.openTarget({ targetId: 'zed', path: dir }) + await state.service.openTarget({ targetId: 'zed', path: file }) + expect(state.launched).toEqual([ + { command: 'open', args: ['-a', appPath, dir] }, + { command: 'open', args: ['-a', appPath, file] }, + ]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }, + ) + + it.each(['zed', 'zeditor'])('detects and launches Linux Zed using %s', async (command) => { + const dir = await makeDir('cc-haha zed-') + const state = createService('linux', { commands: { [command]: true } }) + try { + expect((await state.service.listTargets()).targets.map((target) => target.id)).toEqual(['zed']) + await state.service.openTarget({ targetId: 'zed', path: dir }) + expect(state.launched).toEqual([{ command, args: [dir] }]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('detects and launches the resolved Windows Zed executable', async () => { + const dir = await makeDir('cc-haha zed-') + const executablePath = 'C:/fixture/Zed/zed.exe' + const state = createService('win32', { + commandPaths: { 'zed.exe': executablePath }, + paths: { [executablePath]: true }, + }) + try { + expect((await state.service.listTargets()).targets.map((target) => target.id)).toEqual(['zed', 'explorer']) + await state.service.openTarget({ targetId: 'zed', path: dir }) + expect(state.launched).toEqual([{ command: executablePath, args: [dir] }]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + it('does not treat macOS command shims as installed IDEs without the app bundle', async () => { const { service } = createService('darwin', { commands: { code: true, goland: true, pycharm: true, + zed: true, }, }) diff --git a/src/server/h5AccessPolicy.ts b/src/server/h5AccessPolicy.ts index 00114324..78048d70 100644 --- a/src/server/h5AccessPolicy.ts +++ b/src/server/h5AccessPolicy.ts @@ -1,6 +1,7 @@ export type H5RequestKind = 'local-trusted' | 'internal-sdk' | 'h5-browser' export type H5RequestContext = { clientAddress: string | null + trustedRendererOrigin?: string | null localAccessTokenConfigured?: boolean localAccessAuthorized?: boolean internalSdkAuthorized?: boolean @@ -76,6 +77,14 @@ function isLoopbackBrowserOrigin(origin: string): boolean { return isLoopbackHost(parsed.hostname) } +/** Accept only an explicitly configured loopback web origin from the dev launcher. */ +export function resolveTrustedRendererOrigin(value: string | undefined): string | null { + if (!value || !isLoopbackBrowserOrigin(value)) return null + const parsed = new URL(value) + if (parsed.username || parsed.password) return null + return parsed.origin +} + function pathnameDirectory(pathname: string): string { const slash = pathname.lastIndexOf('/') return slash < 0 ? '/' : pathname.slice(0, slash + 1) @@ -187,6 +196,12 @@ function isLocalDesktopOrNavigationOrigin( ): boolean { if (!origin) return !isCrossSiteSubresource(request.headers) if (LOCAL_DESKTOP_ORIGINS.has(origin)) return true + // Chromium omits Authorization on CORS preflight. Exempt only the dev + // launcher's exact origin and only OPTIONS; real requests still need the + // desktop process credential, including the H5 control plane. + if (request.method === 'OPTIONS' && + origin === context.trustedRendererOrigin && + request.headers.has('Access-Control-Request-Method')) return true // A configured process credential distinguishes the Electron renderer from // arbitrary pages served by another loopback process. Keep tokenless diff --git a/src/server/index.ts b/src/server/index.ts index c4495212..0ef6f8b8 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -29,6 +29,7 @@ import { ensurePersistentStorageUpgraded } from './services/persistentStorageMig import { handleStaticH5Request } from './staticH5.js' import { classifyH5Request, + resolveTrustedRendererOrigin, isH5AccessControlPath, isLocalCredentialOnlyPath, requiresLocalAccessCredential, @@ -221,6 +222,7 @@ function originFromUrl(value: string | null): string | null { export function startServer(port = PORT, host = HOST) { enableConfigs() + const trustedRendererOrigin = resolveTrustedRendererOrigin(process.env.CC_HAHA_TRUSTED_RENDERER_ORIGIN) // Warm the synchronous disconnect-grace cache from managed settings so the // first client disconnect honors the configured value (issue #764). void refreshDisconnectGraceMs() @@ -333,6 +335,7 @@ export function startServer(port = PORT, host = HOST) { const sdkToken = url.searchParams.get('token') const h5RequestContext = { clientAddress, + trustedRendererOrigin, localAccessTokenConfigured: hasConfiguredLocalAccessToken() || hasConfiguredPetAccessToken(), localAccessAuthorized: diff --git a/src/server/proxy/streaming/openaiChatStreamToAnthropic.test.ts b/src/server/proxy/streaming/openaiChatStreamToAnthropic.test.ts index aa635837..9e5330b3 100644 --- a/src/server/proxy/streaming/openaiChatStreamToAnthropic.test.ts +++ b/src/server/proxy/streaming/openaiChatStreamToAnthropic.test.ts @@ -134,3 +134,10 @@ describe('Chat stream protocol boundaries', () => { expect(cancelled).toBe(true) }) }) + +test('nested prompt cache survives zero direct cache creation in streaming usage (#1327)', async () => { + const usage = { prompt_tokens: 149293, completion_tokens: 551, cache_creation_input_tokens: 0, prompt_tokens_details: { cached_tokens: 147840 } } + const events = await collect(chunk({ content: 'Done' }) + chunk({}, 'stop') + + `data: ${JSON.stringify({ choices: [], usage })}\n\ndata: [DONE]\n\n`) + expect(events.find(e => e.type === 'message_delta').usage).toMatchObject({ input_tokens: 1453, output_tokens: 551, cache_read_input_tokens: 147840 }) +}) diff --git a/src/server/proxy/transform/openaiChatToAnthropic.test.ts b/src/server/proxy/transform/openaiChatToAnthropic.test.ts index 7b6ebcdb..710fa19f 100644 --- a/src/server/proxy/transform/openaiChatToAnthropic.test.ts +++ b/src/server/proxy/transform/openaiChatToAnthropic.test.ts @@ -28,3 +28,13 @@ describe('Chat non-streaming response integrity', () => { expect(result.content.some(block => block.type === 'tool_use')).toBe(false) }) }) + +// Issue #1327: zero-valued compatibility cache fields must not hide OpenAI usage. +for (const direct of [{ cache_creation_input_tokens: 0 }, { cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }]) { + test(`nested prompt cache survives zero direct fields ${JSON.stringify(direct)}`, () => { + const upstream = response({}, 'stop') + upstream.choices[0].message.tool_calls = undefined + upstream.usage = { prompt_tokens: 149293, completion_tokens: 551, prompt_tokens_details: { cached_tokens: 147840 }, ...direct } + expect(openaiChatToAnthropic(upstream, 'fixture').usage).toEqual({ input_tokens: 1453, output_tokens: 551, cache_read_input_tokens: 147840 }) + }) +} diff --git a/src/server/proxy/transform/usage.ts b/src/server/proxy/transform/usage.ts index c19617f6..17915ac5 100644 --- a/src/server/proxy/transform/usage.ts +++ b/src/server/proxy/transform/usage.ts @@ -58,12 +58,14 @@ export function openaiUsageToAnthropic(usage: OpenAICompatibleUsage | undefined) usage.cache_creation_input_tokens, ) const hasDirectCacheUsage = - directCacheRead !== undefined || directCacheCreation !== undefined + (directCacheRead ?? 0) > 0 || (directCacheCreation ?? 0) > 0 const nestedCacheRead = validTokenCount( usage.input_tokens_details?.cached_tokens, ) ?? validTokenCount(usage.prompt_tokens_details?.cached_tokens) ?? 0 - // Direct cache_* fields use Anthropic's exclusive-input semantics. Nested + // Positive direct cache_* fields use Anthropic's exclusive-input semantics. + // Some Chat gateways include zero placeholders alongside real nested counts; + // those placeholders must not suppress the nested cache hit accounting. Nested // OpenAI details are inclusive in input/prompt and must be subtracted. Cap // malformed nested cache counts at the reported input to preserve totals. const cacheRead = hasDirectCacheUsage diff --git a/src/server/services/openTargetService.ts b/src/server/services/openTargetService.ts index dbdd6595..c72ffbf1 100644 --- a/src/server/services/openTargetService.ts +++ b/src/server/services/openTargetService.ts @@ -142,6 +142,22 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [ darwin: ['/Applications/Cursor.app', posixPath.join(homedir(), 'Applications', 'Cursor.app')], }, }, + { + id: 'zed', + kind: 'ide', + label: 'Zed', + icon: 'zed', + platforms: ['darwin', 'win32', 'linux'], + commands: { + darwin: ['zed'], + win32: ['zed.exe'], + linux: ['zed', 'zeditor'], + }, + windowsExecutableNames: ['zed.exe'], + appPaths: { + darwin: ['/Applications/Zed.app', posixPath.join(homedir(), 'Applications', 'Zed.app')], + }, + }, { id: 'sublime', kind: 'ide', diff --git a/src/services/autoDream/autoDream.ts b/src/services/autoDream/autoDream.ts index d387d9f6..06307ce5 100644 --- a/src/services/autoDream/autoDream.ts +++ b/src/services/autoDream/autoDream.ts @@ -40,6 +40,7 @@ import { listSessionsTouchedSince, tryAcquireConsolidationLock, rollbackConsolidationLock, + completeConsolidationLock, } from './consolidationLock.js' import { registerDreamTask, @@ -232,6 +233,7 @@ ${sessionIds.map(id => `- ${id}`).join('\n')}` onMessage: makeDreamProgressWatcher(taskId, setAppState), }) + await completeConsolidationLock() completeDreamTask(taskId, setAppState) // Inline completion summary in the main transcript (same surface as // extractMemories's "Saved N memories" message). diff --git a/src/services/autoDream/consolidationLock.test.ts b/src/services/autoDream/consolidationLock.test.ts new file mode 100644 index 00000000..5f53f396 --- /dev/null +++ b/src/services/autoDream/consolidationLock.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { mkdtemp, rm, readFile, writeFile, utimes } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getAutoMemPath } from '../../memdir/paths.js' +import { + readLastConsolidatedAt, + tryAcquireConsolidationLock, + completeConsolidationLock, + rollbackConsolidationLock, + recordConsolidation, +} from './consolidationLock.js' + +let directory: string +let previousOverride: string | undefined +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'consolidation-lock-')) + previousOverride = process.env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE + process.env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE = directory + getAutoMemPath.cache.clear?.() +}) +afterEach(async () => { + if (previousOverride === undefined) delete process.env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE + else process.env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE = previousOverride + getAutoMemPath.cache.clear?.() + await rm(directory, { recursive: true, force: true }) +}) + +for (const ageHours of [0, 2]) { + test(`preserves a legacy successful PID stamp after its process exits (${ageHours}h old, #1349)`, async () => { + const child = Bun.spawn([process.execPath, '--eval', ''], { stdout: 'ignore', stderr: 'ignore' }) + await child.exited + const lock = join(directory, '.consolidate-lock') + await writeFile(lock, String(child.pid)) + const previous = (Date.now() - ageHours * 3_600_000) / 1000 + await utimes(lock, previous, previous) + expect(await readLastConsolidatedAt()).toBeCloseTo(previous * 1000, 0) + }) +} + +test('preserves completed blank-body legacy timestamps', async () => { + const lock = join(directory, '.consolidate-lock') + await writeFile(lock, '') + expect(await readLastConsolidatedAt()).toBeGreaterThan(Date.now() - 5000) +}) + +test('preserves a live holder timestamp', async () => { + await writeFile(join(directory, '.consolidate-lock'), String(process.pid)) + expect(await readLastConsolidatedAt()).toBeGreaterThan(Date.now() - 5000) +}) + +test('successful consolidation releases its holder while preserving the timestamp', async () => { + expect(await tryAcquireConsolidationLock()).toBe(0) + const acquired = await readLastConsolidatedAt() + expect(await tryAcquireConsolidationLock()).toBeNull() + await completeConsolidationLock() + expect(await readLastConsolidatedAt()).toBeCloseTo(acquired, 0) + // A completed attempt no longer excludes its own process from acquiring. + expect(await tryAcquireConsolidationLock()).toBeCloseTo(acquired, 0) +}) + +async function leaveInterruptedAttempt(priorMtime: number): Promise { + const lock = join(directory, '.consolidate-lock') + if (priorMtime > 0) { + await writeFile(lock, '') + await utimes(lock, priorMtime / 1000, priorMtime / 1000) + } + const moduleUrl = new URL('./consolidationLock.ts', import.meta.url).href + const child = Bun.spawn([process.execPath, '--eval', + `const { tryAcquireConsolidationLock } = await import(${JSON.stringify(moduleUrl)}); await tryAcquireConsolidationLock()`, + ], { env: { ...process.env }, stdout: 'ignore', stderr: 'pipe' }) + expect(await child.exited).toBe(0) +} + +for (const ageHours of [0, 2]) { + test(`recovers a recognizably interrupted new attempt before the time gate (${ageHours}h old)`, async () => { + const priorMtime = Date.now() - 48 * 3_600_000 + await leaveInterruptedAttempt(priorMtime) + const attemptTime = (Date.now() - ageHours * 3_600_000) / 1000 + await utimes(join(directory, '.consolidate-lock'), attemptTime, attemptTime) + expect(await readLastConsolidatedAt()).toBeCloseTo(priorMtime, 0) + const prior = await tryAcquireConsolidationLock() + expect(prior).toBeCloseTo(priorMtime, 0) + await rollbackConsolidationLock(prior!) + expect(await readLastConsolidatedAt()).toBeCloseTo(priorMtime, 0) + }) +} + +test('recovers an interrupted first attempt with no prior timestamp', async () => { + await leaveInterruptedAttempt(0) + expect(await readLastConsolidatedAt()).toBe(0) + const prior = await tryAcquireConsolidationLock() + expect(prior).toBe(0) + await rollbackConsolidationLock(prior!) + expect(await readLastConsolidatedAt()).toBe(0) +}) + +test('migrates a legacy successful PID stamp only when a new attempt is acquired', async () => { + const child = Bun.spawn([process.execPath, '--eval', ''], { stdout: 'ignore', stderr: 'ignore' }) + await child.exited + const lock = join(directory, '.consolidate-lock') + const legacy = String(child.pid) + const previous = Date.now() - 48 * 3_600_000 + await writeFile(lock, legacy) + await utimes(lock, previous / 1000, previous / 1000) + expect(await readLastConsolidatedAt()).toBeCloseTo(previous, 0) + expect(await readFile(lock, 'utf8')).toBe(legacy) + expect(await tryAcquireConsolidationLock()).toBeCloseTo(previous, 0) + expect(await readFile(lock, 'utf8')).toContain('auto-dream-v1') + await rollbackConsolidationLock(previous) + expect(await readLastConsolidatedAt()).toBeCloseTo(previous, 0) +}) + +test('manual consolidation records a completed timestamp', async () => { + await recordConsolidation() + expect(await readLastConsolidatedAt()).toBeGreaterThan(Date.now() - 5000) + expect(await tryAcquireConsolidationLock()).toBeGreaterThan(Date.now() - 5000) +}) diff --git a/src/services/autoDream/consolidationLock.ts b/src/services/autoDream/consolidationLock.ts index 621232bb..d138cbd7 100644 --- a/src/services/autoDream/consolidationLock.ts +++ b/src/services/autoDream/consolidationLock.ts @@ -1,4 +1,6 @@ -// Lock file whose mtime IS lastConsolidatedAt. Body is the holder's PID. +// Lock file whose mtime is the consolidation timestamp. Legacy PID-only and +// empty bodies retain that meaning. New attempts explicitly record their +// in-progress state and prior timestamp, so crashes can be recovered safely. // // Lives inside the memory dir (getAutoMemPath) so it keys on git-root // like memory does, and so it's writable even when the memory path comes @@ -15,6 +17,18 @@ import { getProjectDir } from '../../utils/sessionStorage.js' const LOCK_FILE = '.consolidate-lock' +type InProgressLock = { pid: number; priorMtime: number } + +function parseInProgressLock(raw: string): InProgressLock | null { + const match = /^(\d+) auto-dream-v1 (\d+(?:\.\d+)?)$/.exec(raw.trim()) + if (!match) return null + const pid = Number(match[1]) + const priorMtime = Number(match[2]) + return Number.isSafeInteger(pid) && pid > 1 && Number.isFinite(priorMtime) + ? { pid, priorMtime } + : null +} + // Stale past this even if the PID is live (PID reuse guard). const HOLDER_STALE_MS = 60 * 60 * 1000 @@ -24,11 +38,14 @@ function lockPath(): string { /** * mtime of the lock file = lastConsolidatedAt. 0 if absent. - * Per-turn cost: one stat. + * Only explicitly marked attempts can be identified as interrupted. A legacy + * dead PID may represent a successful run, so its timestamp stays authoritative. */ export async function readLastConsolidatedAt(): Promise { try { - const s = await stat(lockPath()) + const [s, raw] = await Promise.all([stat(lockPath()), readFile(lockPath(), 'utf8')]) + const attempt = parseInProgressLock(raw) + if (attempt && !isProcessRunning(attempt.pid)) return attempt.priorMtime return s.mtimeMs } catch { return 0 @@ -36,10 +53,10 @@ export async function readLastConsolidatedAt(): Promise { } /** - * Acquire: write PID → mtime = now. Returns the pre-acquire mtime + * Acquire: write a versioned PID + prior timestamp → mtime = now. Returns the pre-acquire mtime * (for rollback), or null if blocked / lost a race. * - * Success → do nothing. mtime stays at now. + * Success → completeConsolidationLock() clears PID, keeping mtime. * Failure → rollbackConsolidationLock(priorMtime) rewinds mtime. * Crash → mtime stuck, dead PID → next process reclaims. */ @@ -48,9 +65,12 @@ export async function tryAcquireConsolidationLock(): Promise { let mtimeMs: number | undefined let holderPid: number | undefined + let priorMtime = 0 try { const [s, raw] = await Promise.all([stat(path), readFile(path, 'utf8')]) mtimeMs = s.mtimeMs + // Upgrade lazily on acquisition; never reinterpret a legacy PID as failure. + priorMtime = parseInProgressLock(raw)?.priorMtime ?? s.mtimeMs const parsed = parseInt(raw.trim(), 10) holderPid = Number.isFinite(parsed) ? parsed : undefined } catch { @@ -69,7 +89,8 @@ export async function tryAcquireConsolidationLock(): Promise { // Memory dir may not exist yet. await mkdir(getAutoMemPath(), { recursive: true }) - await writeFile(path, String(process.pid)) + const record = `${process.pid} auto-dream-v1 ${priorMtime}` + await writeFile(path, record) // Two reclaimers both write → last wins the PID. Loser bails on re-read. let verify: string @@ -78,9 +99,22 @@ export async function tryAcquireConsolidationLock(): Promise { } catch { return null } - if (parseInt(verify.trim(), 10) !== process.pid) return null + if (verify !== record) return null - return mtimeMs ?? 0 + return priorMtime +} + +/** Mark success without leaving a dead PID that looks like an interrupted run. */ +export async function completeConsolidationLock(): Promise { + const path = lockPath() + try { + const [s, raw] = await Promise.all([stat(path), readFile(path, 'utf8')]) + if (parseInProgressLock(raw)?.pid !== process.pid) return + await writeFile(path, '') + await utimes(path, s.atimeMs / 1000, s.mtimeMs / 1000) + } catch (e: unknown) { + logForDebugging(`[autoDream] completion stamp failed: ${(e as Error).message}`) + } } /** @@ -131,7 +165,7 @@ export async function recordConsolidation(): Promise { try { // Memory dir may not exist yet (manual /dream before any auto-trigger). await mkdir(getAutoMemPath(), { recursive: true }) - await writeFile(lockPath(), String(process.pid)) + await writeFile(lockPath(), '') } catch (e: unknown) { logForDebugging( `[autoDream] recordConsolidation write failed: ${(e as Error).message}`,