mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix: verify Cursor chat store before reopen (#1037)
* test: reproduce issue #841 * test: cover Cursor chat store discovery * fix: verify Cursor chat store before resume (closes #841) * test: preserve non-Cursor resume behavior * test: cover conservative Cursor resume gating * fix: gate Cursor reopen until store verification * test: cover legacy Cursor drawer fallback * fix: scan unique legacy Cursor store drawer * test: preserve raw Cursor workspace path hashing * fix: hash raw Cursor workspace path * test: pin Cursor probe owner and machine * fix: probe Cursor store on recorded owner * test: normalize Cursor probe owner home * fix: normalize Cursor probe owner home
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, mkdirSync, realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const ioMock = vi.hoisted(() => vi.fn())
|
||||
const listOpencodeModelsForCwdMock = vi.hoisted(() => vi.fn())
|
||||
const listGrokModelsForCwdMock = vi.hoisted(() => vi.fn())
|
||||
const inspectCursorChatStoreMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('socket.io-client', () => ({
|
||||
io: ioMock
|
||||
@@ -23,6 +24,10 @@ vi.mock('../modules/common/grokModels', () => ({
|
||||
listGrokModelsForCwd: listGrokModelsForCwdMock
|
||||
}))
|
||||
|
||||
vi.mock('@/cursor/cursorChatStoreStatus', () => ({
|
||||
inspectCursorChatStore: inspectCursorChatStoreMock
|
||||
}))
|
||||
|
||||
import { ApiMachineClient, normalizeWindowsDriveRoot } from './apiMachine'
|
||||
import type { Machine } from './types'
|
||||
|
||||
@@ -74,6 +79,77 @@ async function callListGrokModels(client: ApiMachineClient, machineId: string, c
|
||||
return JSON.parse(raw) as unknown
|
||||
}
|
||||
|
||||
async function callCursorChatStoreStatus(
|
||||
client: ApiMachineClient,
|
||||
machineId: string,
|
||||
params: { workspacePath: string; cursorSessionId: string; homeDir?: string }
|
||||
): Promise<unknown> {
|
||||
const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise<string> } }).rpcHandlerManager
|
||||
const raw = await manager.handleRequest({
|
||||
method: `${machineId}:cursor-chat-store-status`,
|
||||
params: JSON.stringify(params)
|
||||
})
|
||||
return JSON.parse(raw) as unknown
|
||||
}
|
||||
|
||||
describe('ApiMachineClient cursor-chat-store-status handler', () => {
|
||||
beforeEach(() => {
|
||||
inspectCursorChatStoreMock.mockReset()
|
||||
inspectCursorChatStoreMock.mockResolvedValue({ onDisk: false, store: null })
|
||||
})
|
||||
|
||||
it('inspects stores under the recorded session owner home', async () => {
|
||||
const machine = makeMachine('cursor-store-machine')
|
||||
const client = new ApiMachineClient('cli-token', machine)
|
||||
|
||||
try {
|
||||
await callCursorChatStoreStatus(client, machine.id, {
|
||||
workspacePath: '/work/project',
|
||||
cursorSessionId: 'cursor-session',
|
||||
homeDir: ' /home/recorded-owner '
|
||||
})
|
||||
|
||||
expect(inspectCursorChatStoreMock).toHaveBeenCalledWith({
|
||||
home: '/home/recorded-owner',
|
||||
workspacePath: '/work/project',
|
||||
cursorSessionId: 'cursor-session'
|
||||
})
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the CLI process home for old or whitespace-only homeDir metadata', async () => {
|
||||
const machine = makeMachine('cursor-store-fallback-machine')
|
||||
const client = new ApiMachineClient('cli-token', machine)
|
||||
|
||||
try {
|
||||
await callCursorChatStoreStatus(client, machine.id, {
|
||||
workspacePath: '/work/project',
|
||||
cursorSessionId: 'cursor-session-old'
|
||||
})
|
||||
await callCursorChatStoreStatus(client, machine.id, {
|
||||
workspacePath: '/work/project',
|
||||
cursorSessionId: 'cursor-session-empty',
|
||||
homeDir: ' '
|
||||
})
|
||||
|
||||
expect(inspectCursorChatStoreMock).toHaveBeenNthCalledWith(1, {
|
||||
home: homedir(),
|
||||
workspacePath: '/work/project',
|
||||
cursorSessionId: 'cursor-session-old'
|
||||
})
|
||||
expect(inspectCursorChatStoreMock).toHaveBeenNthCalledWith(2, {
|
||||
home: homedir(),
|
||||
workspacePath: '/work/project',
|
||||
cursorSessionId: 'cursor-session-empty'
|
||||
})
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApiMachineClient listOpencodeModelsForCwd handler', () => {
|
||||
let workspaceRoot: string
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/
|
||||
import { applyVersionedAck } from './versionedUpdate'
|
||||
import { buildSocketIoExtraHeaderOptions } from './hubExtraHeaders'
|
||||
import { collectMachineHealth } from '@/utils/machineHealth'
|
||||
import { inspectCursorChatStore } from '@/cursor/cursorChatStoreStatus'
|
||||
import { homedir } from 'node:os'
|
||||
import type { CursorChatStoreStatus } from '@hapi/protocol/apiTypes'
|
||||
|
||||
type MachineRpcHandlers = {
|
||||
spawnSession: (options: SpawnSessionOptions) => Promise<SpawnSessionResult>
|
||||
@@ -46,6 +49,12 @@ interface ListMachineDirectoryRequest {
|
||||
path: string
|
||||
}
|
||||
|
||||
interface CursorChatStoreStatusRequest {
|
||||
workspacePath: string
|
||||
cursorSessionId: string
|
||||
homeDir?: string
|
||||
}
|
||||
|
||||
export function normalizeWindowsDriveRoot(path: string): string {
|
||||
return /^[A-Za-z]:$/.test(path) ? `${path}\\` : path
|
||||
}
|
||||
@@ -128,6 +137,18 @@ export class ApiMachineClient {
|
||||
return { exists }
|
||||
})
|
||||
|
||||
this.rpcHandlerManager.registerHandler<CursorChatStoreStatusRequest, CursorChatStoreStatus>(
|
||||
RPC_METHODS.CursorChatStoreStatus,
|
||||
async (params) => {
|
||||
const recordedHome = typeof params?.homeDir === 'string' ? params.homeDir.trim() : ''
|
||||
return await inspectCursorChatStore({
|
||||
home: recordedHome || homedir(),
|
||||
workspacePath: typeof params?.workspacePath === 'string' ? params.workspacePath : '',
|
||||
cursorSessionId: typeof params?.cursorSessionId === 'string' ? params.cursorSessionId : ''
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
this.rpcHandlerManager.registerHandler<ListMachineDirectoryRequest, MachineListDirectoryResponse>(RPC_METHODS.ListMachineDirectory, async (params) => {
|
||||
if (!this.normalizedWorkspaceRoots?.length) {
|
||||
return { success: false, error: 'Workspace browsing is not enabled for this machine' }
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { inspectCursorChatStore } from './cursorChatStoreStatus'
|
||||
|
||||
const homes: string[] = []
|
||||
|
||||
async function makeHome(): Promise<string> {
|
||||
const home = join(tmpdir(), `hapi-cursor-store-${randomUUID()}`)
|
||||
homes.push(home)
|
||||
await mkdir(home, { recursive: true })
|
||||
return home
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('inspectCursorChatStore', () => {
|
||||
it('finds ACP store.db under the runner user home', async () => {
|
||||
const home = await makeHome()
|
||||
const store = join(home, '.cursor', 'acp-sessions', 'cursor-1', 'store.db')
|
||||
await mkdir(join(store, '..'), { recursive: true })
|
||||
await writeFile(store, 'db')
|
||||
|
||||
await expect(inspectCursorChatStore({
|
||||
home,
|
||||
workspacePath: '/work/project',
|
||||
cursorSessionId: 'cursor-1'
|
||||
})).resolves.toEqual({ onDisk: true, store: 'acp' })
|
||||
})
|
||||
|
||||
it('finds legacy store.db by Cursor workspace hash', async () => {
|
||||
const home = await makeHome()
|
||||
const workspacePath = '/work/project'
|
||||
const workspaceHash = createHash('md5').update(workspacePath).digest('hex')
|
||||
const store = join(home, '.cursor', 'chats', workspaceHash, 'cursor-2', 'store.db')
|
||||
await mkdir(join(store, '..'), { recursive: true })
|
||||
await writeFile(store, 'db')
|
||||
|
||||
await expect(inspectCursorChatStore({
|
||||
home,
|
||||
workspacePath,
|
||||
cursorSessionId: 'cursor-2'
|
||||
})).resolves.toEqual({ onDisk: true, store: 'legacy' })
|
||||
})
|
||||
|
||||
it('hashes the raw workspace path without trimming valid path bytes', async () => {
|
||||
const home = await makeHome()
|
||||
const workspacePath = '/work/project '
|
||||
const workspaceHash = createHash('md5').update(workspacePath).digest('hex')
|
||||
const stores = [
|
||||
join(home, '.cursor', 'chats', workspaceHash, 'cursor-spaced-path', 'store.db'),
|
||||
join(home, '.cursor', 'chats', 'stale-workspace-hash', 'cursor-spaced-path', 'store.db')
|
||||
]
|
||||
for (const store of stores) {
|
||||
await mkdir(join(store, '..'), { recursive: true })
|
||||
await writeFile(store, 'db')
|
||||
}
|
||||
|
||||
await expect(inspectCursorChatStore({
|
||||
home,
|
||||
workspacePath,
|
||||
cursorSessionId: 'cursor-spaced-path'
|
||||
})).resolves.toEqual({ onDisk: true, store: 'legacy' })
|
||||
})
|
||||
|
||||
it('finds a unique legacy store when the canonical workspace drawer is missing', async () => {
|
||||
const home = await makeHome()
|
||||
const store = join(home, '.cursor', 'chats', 'legacy-workspace-hash', 'cursor-3', 'store.db')
|
||||
await mkdir(join(store, '..'), { recursive: true })
|
||||
await writeFile(store, 'db')
|
||||
|
||||
await expect(inspectCursorChatStore({
|
||||
home,
|
||||
workspacePath: '/work/project-moved-since-chat-was-created',
|
||||
cursorSessionId: 'cursor-3'
|
||||
})).resolves.toEqual({ onDisk: true, store: 'legacy' })
|
||||
})
|
||||
|
||||
it('reports missing when multiple non-canonical legacy stores are present', async () => {
|
||||
const home = await makeHome()
|
||||
const stores = [
|
||||
join(home, '.cursor', 'chats', 'workspace-hash-a', 'cursor-4', 'store.db'),
|
||||
join(home, '.cursor', 'chats', 'workspace-hash-b', 'cursor-4', 'store.db')
|
||||
]
|
||||
for (const store of stores) {
|
||||
await mkdir(join(store, '..'), { recursive: true })
|
||||
await writeFile(store, 'db')
|
||||
}
|
||||
|
||||
await expect(inspectCursorChatStore({
|
||||
home,
|
||||
workspacePath: '/work/unrelated-project',
|
||||
cursorSessionId: 'cursor-4'
|
||||
})).resolves.toEqual({ onDisk: false, store: null })
|
||||
})
|
||||
|
||||
it('reports missing without allowing cursorSessionId path traversal', async () => {
|
||||
const home = await makeHome()
|
||||
|
||||
await expect(inspectCursorChatStore({
|
||||
home,
|
||||
workspacePath: '/work/project',
|
||||
cursorSessionId: '../../outside'
|
||||
})).resolves.toEqual({ onDisk: false, store: null })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { CursorChatStoreStatus } from '@hapi/protocol/apiTypes'
|
||||
|
||||
type InspectCursorChatStoreOptions = {
|
||||
home: string
|
||||
workspacePath: string
|
||||
cursorSessionId: string
|
||||
}
|
||||
|
||||
function isSafeCursorSessionId(value: string): boolean {
|
||||
return value !== '.'
|
||||
&& value !== '..'
|
||||
&& /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)
|
||||
}
|
||||
|
||||
async function isFile(path: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(path)).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function hasUniqueLegacyStore(home: string, cursorSessionId: string): Promise<boolean> {
|
||||
const chatsRoot = join(home, '.cursor', 'chats')
|
||||
let workspaceDrawers: string[]
|
||||
try {
|
||||
workspaceDrawers = await readdir(chatsRoot)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
let matches = 0
|
||||
for (const workspaceDrawer of workspaceDrawers) {
|
||||
const candidate = join(chatsRoot, workspaceDrawer, cursorSessionId, 'store.db')
|
||||
if (await isFile(candidate)) {
|
||||
matches += 1
|
||||
if (matches > 1) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches === 1
|
||||
}
|
||||
|
||||
export async function inspectCursorChatStore(
|
||||
options: InspectCursorChatStoreOptions
|
||||
): Promise<CursorChatStoreStatus> {
|
||||
const cursorSessionId = options.cursorSessionId.trim()
|
||||
const workspacePath = options.workspacePath
|
||||
if (!isSafeCursorSessionId(cursorSessionId) || workspacePath.length === 0) {
|
||||
return { onDisk: false, store: null }
|
||||
}
|
||||
|
||||
const acpStore = join(
|
||||
options.home,
|
||||
'.cursor',
|
||||
'acp-sessions',
|
||||
cursorSessionId,
|
||||
'store.db'
|
||||
)
|
||||
if (await isFile(acpStore)) {
|
||||
return { onDisk: true, store: 'acp' }
|
||||
}
|
||||
|
||||
const workspaceHash = createHash('md5').update(workspacePath).digest('hex')
|
||||
const legacyStore = join(
|
||||
options.home,
|
||||
'.cursor',
|
||||
'chats',
|
||||
workspaceHash,
|
||||
cursorSessionId,
|
||||
'store.db'
|
||||
)
|
||||
if (await isFile(legacyStore)) {
|
||||
return { onDisk: true, store: 'legacy' }
|
||||
}
|
||||
|
||||
if (await hasUniqueLegacyStore(options.home, cursorSessionId)) {
|
||||
return { onDisk: true, store: 'legacy' }
|
||||
}
|
||||
|
||||
return { onDisk: false, store: null }
|
||||
}
|
||||
Reference in New Issue
Block a user