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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user