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:
SSU-WEI HUANG
2026-07-16 12:34:41 +08:00
committed by GitHub
parent adb6f41858
commit 520c3f511a
27 changed files with 955 additions and 34 deletions
+27 -2
View File
@@ -5,11 +5,16 @@ import { RpcGateway, RpcTargetMissingError } from './rpcGateway'
function createGateway() {
const timeouts: number[] = []
const calls: Array<{ method: string; params: string }> = []
const socket = {
timeout(timeoutMs: number) {
timeouts.push(timeoutMs)
return {
async emitWithAck(_event: string, payload: { method: string; params: string }) {
calls.push(payload)
if (payload.method.endsWith(':cursor-chat-store-status')) {
return JSON.stringify({ onDisk: false, store: null })
}
return JSON.stringify({
success: true,
method: payload.method,
@@ -40,7 +45,8 @@ function createGateway() {
return {
gateway: new RpcGateway(io, rpcRegistry),
timeouts
timeouts,
calls
}
}
@@ -68,6 +74,26 @@ describe('RpcGateway RPC timeouts', () => {
expect(timeouts).toEqual([120_000])
})
it('forwards the recorded session owner home to the Cursor store probe', async () => {
const { gateway, calls } = createGateway()
await gateway.getCursorChatStoreStatus(
'machine-1',
'/workspace/project',
'cursor-session',
'/home/recorded-owner'
)
expect(calls).toEqual([{
method: 'machine-1:cursor-chat-store-status',
params: JSON.stringify({
workspacePath: '/workspace/project',
cursorSessionId: 'cursor-session',
homeDir: '/home/recorded-owner'
})
}])
})
})
// tiann/hapi#916: rpcCall throws a typed `RpcTargetMissingError` when the
@@ -114,4 +140,3 @@ describe('RpcGateway no-target diagnostics (tiann/hapi#916)', () => {
expect((error as RpcTargetMissingError).code).toBe('socket-disconnected')
})
})
+17
View File
@@ -1,11 +1,13 @@
import type { AgentFlavor, CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types'
import { RPC_METHODS } from '@hapi/protocol/rpcMethods'
import { CursorChatStoreStatusSchema } from '@hapi/protocol/apiTypes'
import type {
CodexModelSummary,
CodexModelsResponse,
CommandResponse,
CursorModelSummary,
CursorModelsResponse,
CursorChatStoreStatus,
DeleteUploadResponse,
DirectoryEntry,
FileReadResponse,
@@ -59,6 +61,7 @@ export type RpcCodexModel = CodexModelSummary
export type RpcListCodexModelsResponse = CodexModelsResponse
export type RpcCursorModel = CursorModelSummary
export type RpcListCursorModelsResponse = CursorModelsResponse
export type RpcCursorChatStoreStatus = CursorChatStoreStatus
export type RpcOpencodeModel = OpencodeModelSummary
export type RpcListOpencodeModelsResponse = OpencodeModelsResponse
export type RpcListGrokModelsResponse = GrokModelsResponse
@@ -210,6 +213,20 @@ export class RpcGateway {
return exists
}
async getCursorChatStoreStatus(
machineId: string,
workspacePath: string,
cursorSessionId: string,
homeDir?: string
): Promise<RpcCursorChatStoreStatus> {
const result = await this.machineRpc(
machineId,
RPC_METHODS.CursorChatStoreStatus,
{ workspacePath, cursorSessionId, homeDir }
)
return CursorChatStoreStatusSchema.parse(result)
}
async getGitStatus(sessionId: string, cwd?: string): Promise<RpcCommandResponse> {
return await this.sessionRpc(sessionId, RPC_METHODS.GitStatus, { cwd }) as RpcCommandResponse
}
+237
View File
@@ -1187,6 +1187,7 @@ describe('session model', () => {
engine.handleSessionAlive({ sid: spawnedSessionId, time: Date.now() })
return { type: 'success', sessionId: spawnedSessionId }
}
;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => ({ onDisk: true, store: 'acp' })
;(engine as any).waitForSessionActive = async () => true
;(engine as any).waitForSessionReady = async () => 'ended'
@@ -1319,6 +1320,7 @@ describe('session model', () => {
engine.handleSessionReady({ sid: spawnedSessionId, time: Date.now() })
return { type: 'success', sessionId: spawnedSessionId }
}
;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => ({ onDisk: true, store: 'acp' })
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(oldSession.id, 'default')
@@ -1387,6 +1389,7 @@ describe('session model', () => {
engine.handleSessionAlive({ sid: spawnedSessionId, time: Date.now() })
return { type: 'success', sessionId: spawnedSessionId }
}
;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => ({ onDisk: true, store: 'legacy' })
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(oldSession.id, 'default')
@@ -1598,6 +1601,240 @@ describe('session model', () => {
}
})
it('refuses Cursor resume before spawning when the recorded chat store is missing', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'cursor-missing-store',
{
path: '/tmp/project',
host: 'cursor-host',
machineId: 'cursor-machine',
homeDir: '/home/cursor-owner',
flavor: 'cursor',
cursorSessionId: 'cursor-thread-missing',
cursorSessionProtocol: 'acp'
},
null,
'default'
)
engine.getOrCreateMachine(
'cursor-machine',
{ host: 'cursor-host', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'cursor-machine', time: Date.now() })
let spawnCalled = false
let probeArgs: unknown[] | null = null
;(engine as any).rpcGateway.getCursorChatStoreStatus = async (...args: unknown[]) => {
probeArgs = args
return { onDisk: false, store: null }
}
;(engine as any).rpcGateway.spawnSession = async () => {
spawnCalled = true
return { type: 'success', sessionId: session.id }
}
const result = await engine.resumeSession(session.id, 'default')
expect(result).toEqual({
type: 'error',
message: 'Cursor chat data is no longer available on the recorded machine',
code: 'resume_unavailable'
})
expect(probeArgs as unknown).toEqual([
'cursor-machine',
'/tmp/project',
'cursor-thread-missing',
'/home/cursor-owner'
])
expect(spawnCalled).toBe(false)
} finally {
engine.stop()
}
})
it('probes Cursor chat data on the session recorded machine', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'cursor-machine-scoped-store',
{
path: '/remote/project',
host: 'shared-host-label',
machineId: 'recorded-machine',
homeDir: '/home/recorded-owner',
flavor: 'cursor',
cursorSessionId: 'cursor-thread-remote',
cursorSessionProtocol: 'acp'
},
null,
'default'
)
for (const machineId of ['other-machine', 'recorded-machine']) {
engine.getOrCreateMachine(
machineId,
{ host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId, time: Date.now() })
}
let captured: unknown[] | null = null
;(engine as any).rpcGateway.getCursorChatStoreStatus = async (...args: unknown[]) => {
captured = args
return { onDisk: true, store: 'acp' }
}
const result = await engine.getCursorChatStoreStatus(session.id, 'default')
expect(result).toEqual({
type: 'success',
status: { onDisk: true, store: 'acp' }
})
expect(captured as unknown).toEqual([
'recorded-machine',
'/remote/project',
'cursor-thread-remote',
'/home/recorded-owner'
])
} finally {
engine.stop()
}
})
it('does not probe a same-host machine when the recorded Cursor machine is offline', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'cursor-offline-recorded-machine-status',
{
path: '/remote/project',
host: 'shared-host-label',
machineId: 'recorded-machine-offline',
flavor: 'cursor',
cursorSessionId: 'cursor-thread-offline',
cursorSessionProtocol: 'acp'
},
null,
'default'
)
engine.getOrCreateMachine(
'recorded-machine-offline',
{ host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.getOrCreateMachine(
'wrong-same-host-machine',
{ host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'wrong-same-host-machine', time: Date.now() })
let probeCalled = false
;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => {
probeCalled = true
return { onDisk: true, store: 'acp' }
}
expect(await engine.getCursorChatStoreStatus(session.id, 'default')).toEqual({
type: 'error',
message: 'No machine online',
code: 'no_machine_online'
})
expect(probeCalled).toBe(false)
} finally {
engine.stop()
}
})
it('does not probe or spawn on a same-host machine when the recorded Cursor machine is offline', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'cursor-offline-recorded-machine-resume',
{
path: '/remote/project',
host: 'shared-host-label',
machineId: 'recorded-machine-offline',
flavor: 'cursor',
cursorSessionId: 'cursor-thread-offline',
cursorSessionProtocol: 'acp'
},
null,
'default'
)
engine.getOrCreateMachine(
'recorded-machine-offline',
{ host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.getOrCreateMachine(
'wrong-same-host-machine',
{ host: 'shared-host-label', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'wrong-same-host-machine', time: Date.now() })
let probeCalled = false
let spawnCalled = false
;(engine as any).rpcGateway.getCursorChatStoreStatus = async () => {
probeCalled = true
return { onDisk: true, store: 'acp' }
}
;(engine as any).rpcGateway.spawnSession = async () => {
spawnCalled = true
return { type: 'success', sessionId: session.id }
}
;(engine as any).waitForSessionActive = async () => true
expect(await engine.resumeSession(session.id, 'default')).toEqual({
type: 'error',
message: 'No machine online',
code: 'no_machine_online'
})
expect(probeCalled).toBe(false)
expect(spawnCalled).toBe(false)
} finally {
engine.stop()
}
})
it('resumeSession fresh-spawns when inactive cursor session has no agent id and no user messages', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
+97 -16
View File
@@ -8,7 +8,7 @@
*/
import { isKnownFlavor, type LocalResumeTarget, type ResumableSession } from '@hapi/protocol'
import type { CursorMigrateOutcome, CursorMigrateToAcpRequest, SlashCommandsResponse } from '@hapi/protocol/apiTypes'
import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, SlashCommandsResponse } from '@hapi/protocol/apiTypes'
import type { AgentFlavor, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types'
import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages'
import type { Server } from 'socket.io'
@@ -36,6 +36,7 @@ import {
type RpcListGrokReasoningEffortOptionsResponse,
type RpcListOpencodeReasoningEffortOptionsResponse,
type RpcCursorModel,
type RpcCursorChatStoreStatus,
type RpcOpencodeModel,
type RpcPathExistsResponse,
type RpcReadFileResponse,
@@ -59,6 +60,7 @@ export type {
RpcListGrokReasoningEffortOptionsResponse,
RpcListOpencodeReasoningEffortOptionsResponse,
RpcCursorModel,
RpcCursorChatStoreStatus,
RpcOpencodeModel,
RpcPathExistsResponse,
RpcReadFileResponse,
@@ -82,6 +84,10 @@ export type LocalHandoffResult =
| { type: 'success' }
| { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'already_local' | 'handoff_failed' }
export type CursorChatStoreStatusResult =
| { type: 'success'; status: CursorChatStoreStatus }
| { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'resume_unavailable' | 'no_machine_online' | 'probe_failed' }
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
@@ -189,6 +195,69 @@ export class SyncEngine {
return this.sessionCache.getSessions()
}
private resolveOnlineMachineForSession(
session: Session,
namespace: string,
options?: { strictMachineId?: boolean }
): Machine | null {
const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace)
if (session.metadata?.machineId) {
const exact = onlineMachines.find((machine) => machine.id === session.metadata?.machineId)
if (exact) return exact
if (options?.strictMachineId) return null
}
if (session.metadata?.host) {
const hostMatch = onlineMachines.find((machine) => machine.metadata?.host === session.metadata?.host)
if (hostMatch) return hostMatch
}
return null
}
async getCursorChatStoreStatus(sessionId: string, namespace: string): Promise<CursorChatStoreStatusResult> {
const access = this.sessionCache.resolveSessionAccess(sessionId, namespace)
if (!access.ok) {
return {
type: 'error',
message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found',
code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found'
}
}
const metadata = access.session.metadata
if (metadata?.flavor !== 'cursor' || !metadata.path || !metadata.cursorSessionId) {
return {
type: 'error',
message: 'Cursor resume metadata is unavailable',
code: 'resume_unavailable'
}
}
const targetMachine = this.resolveOnlineMachineForSession(
access.session,
namespace,
{ strictMachineId: true }
)
if (!targetMachine) {
return { type: 'error', message: 'No machine online', code: 'no_machine_online' }
}
try {
const status = await this.rpcGateway.getCursorChatStoreStatus(
targetMachine.id,
metadata.path,
metadata.cursorSessionId,
metadata.homeDir
)
return { type: 'success', status }
} catch (error) {
return {
type: 'error',
message: error instanceof Error ? error.message : 'Failed to inspect Cursor chat store',
code: 'probe_failed'
}
}
}
getSessionsByNamespace(namespace: string): Session[] {
return this.sessionCache.getSessionsByNamespace(namespace)
}
@@ -1168,25 +1237,37 @@ export class SyncEngine {
const metadata = session.metadata!
const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace)
if (onlineMachines.length === 0) {
const targetMachine = this.resolveOnlineMachineForSession(
session,
namespace,
{ strictMachineId: flavor === 'cursor' }
)
if (!targetMachine) {
return { type: 'error', message: 'No machine online', code: 'no_machine_online' }
}
const targetMachine = (() => {
if (metadata.machineId) {
const exact = onlineMachines.find((machine) => machine.id === metadata.machineId)
if (exact) return exact
if (flavor === 'cursor' && resumeToken) {
try {
const chatStatus = await this.rpcGateway.getCursorChatStoreStatus(
targetMachine.id,
directory,
resumeToken,
metadata.homeDir
)
if (!chatStatus.onDisk) {
return {
type: 'error',
message: 'Cursor chat data is no longer available on the recorded machine',
code: 'resume_unavailable'
}
}
} catch (error) {
return {
type: 'error',
message: error instanceof Error ? error.message : 'Failed to inspect Cursor chat store',
code: 'resume_failed'
}
}
if (metadata.host) {
const hostMatch = onlineMachines.find((machine) => machine.metadata?.host === metadata.host)
if (hostMatch) return hostMatch
}
return null
})()
if (!targetMachine) {
return { type: 'error', message: 'No machine online', code: 'no_machine_online' }
}
const preferredPermissionMode = opts?.permissionMode
+28
View File
@@ -63,6 +63,7 @@ function createApp(session: Session, opts?: {
getSessionExport?: (sessionId: string, session: Session) => unknown
sessionExists?: boolean
archiveSession?: (sessionId: string) => Promise<void>
getCursorChatStoreStatus?: SyncEngine['getCursorChatStoreStatus']
}) {
const applySessionConfigCalls: Array<[string, Record<string, unknown>]> = []
const applySessionConfig = async (sessionId: string, config: Record<string, unknown>) => {
@@ -135,6 +136,10 @@ function createApp(session: Session, opts?: {
listGrokReasoningEffortOptionsForSession,
resumeSession,
reopenSession,
getCursorChatStoreStatus: opts?.getCursorChatStoreStatus ?? (async () => ({
type: 'success' as const,
status: { onDisk: true, store: 'acp' as const }
})),
archiveSession: archiveSessionMock,
getSessionExport: opts?.getSessionExport ?? (() => ({
type: 'success',
@@ -162,6 +167,29 @@ function createApp(session: Session, opts?: {
}
describe('sessions routes', () => {
it('returns the machine-scoped Cursor chat store status', async () => {
const session = createSession({
active: false,
metadata: {
path: '/tmp/project',
host: 'cursor-host',
flavor: 'cursor',
cursorSessionId: 'cursor-thread-1'
}
})
const { app } = createApp(session, {
getCursorChatStoreStatus: async () => ({
type: 'success',
status: { onDisk: false, store: null }
})
})
const response = await app.request('/api/sessions/session-1/cursor-chat-store')
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ onDisk: false, store: null })
})
it('exports an empty session conversation payload', async () => {
const session = createSession()
const { app } = createApp(session)
+27
View File
@@ -134,6 +134,33 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
return c.json({ session: sessionResult.session })
})
app.get('/sessions/:id/cursor-chat-store', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
return engine
}
const sessionResult = requireSessionFromParam(c, engine)
if (sessionResult instanceof Response) {
return sessionResult
}
const result = await engine.getCursorChatStoreStatus(
sessionResult.sessionId,
c.get('namespace')
)
if (result.type === 'error') {
const status = result.code === 'session_not_found' ? 404
: result.code === 'access_denied' ? 403
: result.code === 'resume_unavailable' ? 409
: result.code === 'no_machine_online' ? 503
: 502
return c.json({ error: result.message, code: result.code }, status)
}
return c.json(result.status)
})
app.post('/sessions/:id/resume', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {