mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fix: resolve reproduced issue regressions and add Zed support
- Restore file-tree Add to chat and keyboard menu behavior (#1322). - Preserve nested OpenAI cache hits beside zero-valued cache fields (#1327). - Allow exact development renderer CORS preflights while retaining auth (#1335). - Prevent stale transcripts from restoring explicitly cleared goals (#1339). - Recover interrupted auto-dream attempts while preserving legacy stamps (#1349). - Add Zed to the existing editor discovery and launch menu (#1346). Refs #1322, #1327, #1335, #1339, #1349, #1346 Validation: local before/after reproductions, browser smoke, server 5537 and desktop 6086 tests passed; provider/chat/agent-flow/persistence checks passed. Changed-line coverage is 98.23%. The full coverage gate remains blocked by the unchanged IM slow-drip test; native signing/package validation is incomplete.
This commit is contained in:
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(<WorkspaceFileTreePane sessionId="session-b" selectedPath={null} onOpen={vi.fn()} />)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(useWorkspaceChatContextStore.getState().referencesBySession).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
const menuTriggerRef = useRef<HTMLElement | null>(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({
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{menu ? (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
aria-label={t('workspace.addSelectionToChat')}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
style={menuPosition.style}
|
||||
className="fixed z-[var(--z-dropdown)] min-w-[160px] overflow-y-auto rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-1 shadow-[var(--shadow-dropdown)]"
|
||||
>
|
||||
<Button role="menuitem" variant="ghost" size="sm" onClick={() => {
|
||||
useWorkspaceChatContextStore.getState().addReference(sessionId, {
|
||||
kind: 'file', path: menu.row.path, name: menu.row.name,
|
||||
isDirectory: menu.row.isDirectory,
|
||||
})
|
||||
closeMenu()
|
||||
}}>
|
||||
{t('workspace.addSelectionToChat')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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('<local-command-stdout>Goal set: ship it</local-command-stdout>'),
|
||||
]
|
||||
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(
|
||||
|
||||
@@ -31,6 +31,9 @@ const GOAL_HOOK_MARKER = '<cc-haha-goal-hook>'
|
||||
const GOAL_HOOK_TIMEOUT_SECONDS = 45
|
||||
const RESERVED_GOAL_ARGS = new Set(['status', 'pause', 'resume', 'complete'])
|
||||
const goalsByThread = new Map<string, ThreadGoal>()
|
||||
// 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<string>()
|
||||
|
||||
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<ToolUseContext, 'setAppState'>,
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<void> {
|
||||
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)
|
||||
})
|
||||
@@ -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<number> {
|
||||
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<number> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<number | null> {
|
||||
|
||||
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<number | null> {
|
||||
|
||||
// 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<number | null> {
|
||||
} 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<void> {
|
||||
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<void> {
|
||||
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}`,
|
||||
|
||||
Reference in New Issue
Block a user