mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(native-chat): harden remote transcript recovery
This commit is contained in:
@@ -4666,11 +4666,11 @@
|
||||
"https://github.com/stablyai/orca/pull/15515",
|
||||
"https://github.com/stablyai/orca/pull/15517"
|
||||
],
|
||||
"invariant": "A located provider session resolves and reads transcript bytes only through its stamped execution owner. Unknown or unavailable ownership is unverifiable and never falls back to the desktop filesystem. SSH snapshots and tails use bounded positional reads and reject stale provider generations; locator-free legacy requests retain host-local behavior.",
|
||||
"oracle": "The focused oracle gives the desktop and SSH providers identical session ids and transcript paths but different bytes, then requires the exact SSH owner for snapshot and live append. It also proves unknown ownership cannot install a local watcher, provider loss and replacement rearm once, throwing advisory callbacks cannot kill retry, and large appends never call whole-file read or reread the prefix. The identical baseline/candidate/disabled/revert comparison and a real Docker SSH plus Electron/CDP journey supply independent red/green and rendered evidence.",
|
||||
"invariant": "A located provider session resolves and reads transcript bytes only through its stamped execution owner. Unknown or unavailable ownership is unverifiable and never falls back to the desktop filesystem. SSH snapshots and tails use bounded positional reads, forward cancellation, and reject stale provider generations; locator-free legacy requests retain host-local behavior.",
|
||||
"oracle": "The focused oracle gives the desktop and SSH providers identical session ids and transcript paths but different bytes, then requires the exact SSH owner for snapshot and live append. It also proves unknown ownership cannot install a local watcher, repeated provider loss rearms one advisory per outage, a restored-but-missing remote path replaces stale unverifiable text, throwing advisory callbacks cannot kill retry, cancellation reaches remote stat, and large appends never call whole-file read or reread the prefix. The identical baseline/candidate/disabled/revert comparison and a real Docker SSH plus Electron/CDP journey supply independent red/green and rendered evidence.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/native-chat/ssh-transcript-host-routing.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/agent-hooks/server-hook-http-ingest.test.ts src/main/agent-hooks/server-ingest-remote.test.ts src/main/agent-hooks/server-ingest-terminal-status.test.ts src/main/agent-hooks/server-last-status-hydrate-validation.test.ts src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts src/main/ipc/native-chat.test.ts src/main/native-chat/transcript-watch-error.test.ts src/main/native-chat/transcript-watch-resolve-poll.test.ts src/main/native-chat/transcript-watch-unresolved-notice.test.ts src/main/providers/ssh-filesystem-dispatch.test.ts src/main/runtime/rpc/methods/native-chat.test.ts src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts src/renderer/src/components/native-chat/native-chat-session-transport.test.ts src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts src/shared/agent-session-resume.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/agent-hooks/server-hook-http-ingest.test.ts src/main/agent-hooks/server-ingest-remote.test.ts src/main/agent-hooks/server-ingest-terminal-status.test.ts src/main/agent-hooks/server-last-status-hydrate-validation.test.ts src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts src/main/ipc/native-chat.test.ts src/main/native-chat/transcript-watch-error.test.ts src/main/native-chat/transcript-watch-resolve-poll.test.ts src/main/native-chat/transcript-watch-unresolved-notice.test.ts src/main/providers/ssh-filesystem-dispatch.test.ts src/main/providers/ssh-filesystem-provider-range.test.ts src/main/runtime/rpc/methods/native-chat.test.ts src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts src/renderer/src/components/native-chat/native-chat-session-transport.test.ts src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts src/shared/agent-session-resume.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/native-chat/transcript-watch-wsl-exact-path.test.ts --reporter=dot",
|
||||
"pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-eligibility.test.ts mobile/src/session/use-mobile-native-chat-session.test.ts --reporter=dot",
|
||||
"pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-native-chat-runtime-routing.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
@@ -4689,6 +4689,7 @@
|
||||
"src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts",
|
||||
"src/main/ipc/native-chat.test.ts",
|
||||
"src/main/providers/ssh-filesystem-dispatch.test.ts",
|
||||
"src/main/providers/ssh-filesystem-provider-range.test.ts",
|
||||
"src/main/runtime/rpc/methods/native-chat.test.ts",
|
||||
"src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts",
|
||||
"src/renderer/src/components/native-chat/native-chat-session-transport.test.ts",
|
||||
@@ -4723,6 +4724,10 @@
|
||||
"WSL relay hook ingress retains local transcript ownership for host-side UNC translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/providers/ssh-filesystem-provider-range.test.ts",
|
||||
"assertions": ["remote stat forwards cancellation to the SSH multiplexer"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/native-chat.test.ts",
|
||||
"assertions": [
|
||||
@@ -4745,7 +4750,7 @@
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/native-chat/ssh-transcript-host-routing.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 3,
|
||||
"summary": "The finalized candidate passed all 16 deterministic ownership, reconnect, replacement-race, advisory, legacy, binding, and bounded-range cases; disabling exact SSH-owner recognition failed 11/16 and restoration returned to 16/16. The byte-identical earlier 13-case oracle failed 11/13 on latest origin/main and the advisory-only PR head, passed 13/13 on the candidate, failed 9/13 with the owner fix disabled, and returned to 13/13 after restoration."
|
||||
"summary": "The post-review candidate passed all 17 deterministic ownership, reconnect, repeated-outage, replacement-race, advisory, legacy, cancellation, binding, and bounded-range cases. Disabling remote stat cancellation, outage rearming, and remote-specific unresolved copy failed 3/17; restoration returned to 17/17. The prior 16-case candidate passed 16/16, failed 11/16 with exact SSH-owner recognition disabled, and returned to 16/16. The byte-identical earlier 13-case oracle failed 11/13 on latest origin/main and the advisory-only PR head, passed 13/13 on the candidate, failed 9/13 with the owner fix disabled, and returned to 13/13 after restoration."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-21",
|
||||
@@ -4768,7 +4773,7 @@
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 15,
|
||||
"scope": "16-case deterministic routing oracle plus one compiled headless runtime journey; Docker/headed Electron remains manual"
|
||||
"scope": "17-case deterministic routing oracle plus one compiled headless runtime journey; Docker/headed Electron remains manual"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "soaking",
|
||||
@@ -4776,7 +4781,7 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The byte-identical 13-case oracle failed 11/13 on latest origin/main 59892a2f13 and advisory-only PR head aba8aafafa, passed 13/13 on the candidate, failed 9/13 when only exact SSH-owner recognition was disabled, and passed 13/13 after restoration. The finalized 16-case candidate passed 16/16, failed 11/16 under the same mutation, and returned to 16/16 after restoration. Baseline headed Electron visibly selected desktop lookalike bytes in both SSH workspace kinds; the candidate rendered only remote snapshot and tail bytes."
|
||||
"evidence": "The byte-identical 13-case oracle failed 11/13 on latest origin/main 59892a2f13 and advisory-only PR head aba8aafafa, passed 13/13 on the candidate, failed 9/13 when only exact SSH-owner recognition was disabled, and passed 13/13 after restoration. The prior 16-case candidate passed 16/16, failed 11/16 under the same owner mutation, and returned to 16/16. The post-review 17-case candidate passed 17/17, failed 3/17 when remote stat cancellation, outage rearming, and remote-specific unresolved copy were disabled together, and returned to 17/17. Baseline headed Electron visibly selected desktop lookalike bytes in both SSH workspace kinds; the candidate rendered only remote snapshot and tail bytes."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
|
||||
@@ -62,23 +62,24 @@ function memoryProvider(files: Map<string, Buffer>) {
|
||||
return { bytes, bytesRead: bytes.length }
|
||||
}
|
||||
)
|
||||
const stat = vi.fn(async (filePath: string): Promise<FileStat> => {
|
||||
const bytes = files.get(filePath)
|
||||
if (!bytes) {
|
||||
throw Object.assign(new Error(`ENOENT: no such file or directory, stat '${filePath}'`), {
|
||||
code: 'ENOENT'
|
||||
})
|
||||
}
|
||||
return { size: bytes.length, type: 'file', mtime: 1, mtimeMs: 1, dev: 7, ino: 11 }
|
||||
})
|
||||
const provider = {
|
||||
async stat(filePath: string): Promise<FileStat> {
|
||||
const bytes = files.get(filePath)
|
||||
if (!bytes) {
|
||||
throw Object.assign(new Error(`ENOENT: no such file or directory, stat '${filePath}'`), {
|
||||
code: 'ENOENT'
|
||||
})
|
||||
}
|
||||
return { size: bytes.length, type: 'file', mtime: 1, mtimeMs: 1, dev: 7, ino: 11 }
|
||||
},
|
||||
stat,
|
||||
readFile,
|
||||
readFileRange,
|
||||
async supportsFileRangeRead() {
|
||||
return true
|
||||
}
|
||||
} as unknown as IFilesystemProvider
|
||||
return { provider, readFile, readFileRange }
|
||||
return { provider, readFile, readFileRange, stat }
|
||||
}
|
||||
|
||||
function routedArgs(transcriptPath: string): {
|
||||
@@ -128,19 +129,24 @@ describe('native chat SSH transcript host routing (#13663)', () => {
|
||||
const transcriptPath = join(root, 'session.jsonl')
|
||||
await writeFile(transcriptPath, claudeLine('desktop-poison', 'wrong host'))
|
||||
const files = new Map([[transcriptPath, Buffer.from(claudeLine('remote-real', 'right host'))]])
|
||||
const { provider, readFile, readFileRange } = memoryProvider(files)
|
||||
const { provider, readFile, readFileRange, stat } = memoryProvider(files)
|
||||
mocks.getProvider.mockReturnValue(provider)
|
||||
const controller = new AbortController()
|
||||
|
||||
const result = await readNativeChatTranscriptTail({
|
||||
...routedArgs(transcriptPath),
|
||||
limit: 40
|
||||
} as Parameters<typeof readNativeChatTranscriptTail>[0])
|
||||
const result = await readNativeChatTranscriptTail(
|
||||
{
|
||||
...routedArgs(transcriptPath),
|
||||
limit: 40
|
||||
} as Parameters<typeof readNativeChatTranscriptTail>[0],
|
||||
controller.signal
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ messages: [{ id: 'remote-real' }], hasMore: false })
|
||||
expect(result).not.toMatchObject({ messages: [{ id: 'desktop-poison' }] })
|
||||
expect(mocks.getProvider).toHaveBeenCalledWith('ssh-owner')
|
||||
expect(readFile).not.toHaveBeenCalled()
|
||||
expect(readFileRange).toHaveBeenCalled()
|
||||
expect(stat).toHaveBeenCalledWith(transcriptPath, { signal: controller.signal })
|
||||
})
|
||||
|
||||
it('retries consecutive ranged snapshots when the remote file is replaced mid-read', async () => {
|
||||
@@ -509,6 +515,19 @@ describe('native chat SSH transcript host routing (#13663)', () => {
|
||||
expect(getActiveNativeChatWatcherCount()).toBe(before + 1)
|
||||
expect(snapshots.some((snapshot) => snapshot.ids.includes('before-loss'))).toBe(true)
|
||||
expect(snapshots.at(-1)?.ids).toEqual(['after-restore'])
|
||||
|
||||
const firstOutageFrames = snapshots.filter(
|
||||
(snapshot) => snapshot.error === 'Transcript unverifiable on the remote host'
|
||||
).length
|
||||
changeProvider(undefined)
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
snapshots.filter(
|
||||
(snapshot) => snapshot.error === 'Transcript unverifiable on the remote host'
|
||||
)
|
||||
).toHaveLength(firstOutageFrames + 1)
|
||||
)
|
||||
expect(getActiveNativeChatWatcherCount()).toBe(before)
|
||||
} finally {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
@@ -544,10 +563,31 @@ describe('native chat SSH transcript host routing (#13663)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('emits one unresolved advisory while an exact remote path is still missing', async () => {
|
||||
it('emits one advisory while a registered provider remains unreachable', async () => {
|
||||
const transcriptPath = '/tmp/unreachable-provider-session.jsonl'
|
||||
const { provider, stat } = memoryProvider(new Map())
|
||||
stat.mockRejectedValue(Object.assign(new Error('connection lost'), { code: 'CONNECTION_LOST' }))
|
||||
mocks.getProvider.mockReturnValue(provider)
|
||||
const errors: (string | undefined)[] = []
|
||||
const subscription = await subscribeNativeChatTranscript({
|
||||
...routedArgs(transcriptPath),
|
||||
onInitialSnapshot: (_messages, _hasMore, _beforeOffset, error) => errors.push(error),
|
||||
onAppend: () => {},
|
||||
resolvePollIntervalMs: 5
|
||||
})
|
||||
|
||||
try {
|
||||
await vi.waitFor(() => expect(stat.mock.calls.length).toBeGreaterThanOrEqual(3))
|
||||
expect(errors).toEqual(['Transcript unverifiable on the remote host'])
|
||||
} finally {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces an unverifiable advisory after the remote host becomes reachable', async () => {
|
||||
const transcriptPath = '/tmp/not-yet-written.jsonl'
|
||||
const { provider } = memoryProvider(new Map())
|
||||
mocks.getProvider.mockReturnValue(provider)
|
||||
mocks.getProvider.mockReturnValue(undefined)
|
||||
const errors: (string | undefined)[] = []
|
||||
const subscription = await subscribeNativeChatTranscript({
|
||||
...routedArgs(transcriptPath),
|
||||
@@ -558,9 +598,12 @@ describe('native chat SSH transcript host routing (#13663)', () => {
|
||||
})
|
||||
|
||||
try {
|
||||
await vi.waitFor(() => expect(errors).toEqual(['Transcript unverifiable on the remote host']))
|
||||
changeProvider(provider)
|
||||
await vi.waitFor(() =>
|
||||
expect(errors).toEqual([
|
||||
'No transcript found for this session on this machine. If the agent runs on a remote host its transcript lives there; otherwise it may not have been written yet.'
|
||||
'Transcript unverifiable on the remote host',
|
||||
'No transcript found for this session on its remote host. It may not have been written yet.'
|
||||
])
|
||||
)
|
||||
} finally {
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function createSshTranscriptRangeFs(
|
||||
async function stat(filePath: string, statSignal?: AbortSignal): Promise<TranscriptFileStamp> {
|
||||
statSignal?.throwIfAborted()
|
||||
requireSameProvider(connectionId, providerSnapshot)
|
||||
const stamp = await provider.stat(filePath)
|
||||
const stamp = await provider.stat(filePath, { signal: statSignal })
|
||||
requireSameProvider(connectionId, providerSnapshot)
|
||||
const mtimeMs = stamp.mtimeMs ?? stamp.mtime
|
||||
return {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { createSshTranscriptRangeFs } from './ssh-transcript-range-fs'
|
||||
import {
|
||||
DEFAULT_TRANSCRIPT_UNRESOLVED_NOTICE_MS,
|
||||
isTranscriptHostUnverifiableError,
|
||||
TRANSCRIPT_UNVERIFIABLE_MESSAGE,
|
||||
UNRESOLVED_TRANSCRIPT_MESSAGE
|
||||
REMOTE_UNRESOLVED_TRANSCRIPT_MESSAGE,
|
||||
TRANSCRIPT_UNVERIFIABLE_MESSAGE
|
||||
} from './transcript-host-verdict'
|
||||
import { nativeChatLineDecoderForAgent } from './transcript-tail-reader'
|
||||
import { installTranscriptWatcher } from './transcript-watch-engine'
|
||||
@@ -87,7 +87,7 @@ export function subscribeSshNativeChatTranscript(
|
||||
}
|
||||
unresolvedNoticeEmitted = true
|
||||
try {
|
||||
args.onInitialSnapshot([], false, 0, UNRESOLVED_TRANSCRIPT_MESSAGE)
|
||||
args.onInitialSnapshot([], false, 0, REMOTE_UNRESOLVED_TRANSCRIPT_MESSAGE)
|
||||
} catch {
|
||||
// The advisory cannot own retry liveness when its subscriber is closing.
|
||||
}
|
||||
@@ -147,6 +147,7 @@ export function subscribeSshNativeChatTranscript(
|
||||
result?.unsubscribe()
|
||||
return
|
||||
}
|
||||
unverifiableEmitted = false
|
||||
if (result) {
|
||||
installed = result
|
||||
retryMs = args.resolvePollIntervalMs ?? INITIAL_RETRY_MS
|
||||
|
||||
@@ -6,6 +6,8 @@ export const TRANSCRIPT_UNVERIFIABLE_MESSAGE = 'Transcript unverifiable on the r
|
||||
export const DEFAULT_TRANSCRIPT_UNRESOLVED_NOTICE_MS = 60_000
|
||||
export const UNRESOLVED_TRANSCRIPT_MESSAGE =
|
||||
'No transcript found for this session on this machine. If the agent runs on a remote host its transcript lives there; otherwise it may not have been written yet.'
|
||||
export const REMOTE_UNRESOLVED_TRANSCRIPT_MESSAGE =
|
||||
'No transcript found for this session on its remote host. It may not have been written yet.'
|
||||
|
||||
export class TranscriptHostUnverifiableError extends Error {
|
||||
readonly verdict = 'unverifiable' as const
|
||||
|
||||
@@ -81,7 +81,7 @@ export type IFilesystemProvider = {
|
||||
): Promise<FileStat>
|
||||
writeFileBase64(filePath: string, contentBase64: string): Promise<void>
|
||||
writeFileBase64Chunk(filePath: string, contentBase64: string, append: boolean): Promise<void>
|
||||
stat(filePath: string): Promise<FileStat>
|
||||
stat(filePath: string, options?: { signal?: AbortSignal }): Promise<FileStat>
|
||||
lstat?(filePath: string): Promise<FileStat>
|
||||
deletePath(targetPath: string, recursive?: boolean): Promise<void>
|
||||
createFile(filePath: string): Promise<void>
|
||||
|
||||
@@ -15,6 +15,17 @@ function providerWith(request: ReturnType<typeof vi.fn>): SshFilesystemProvider
|
||||
return new SshFilesystemProvider('conn-1', mux)
|
||||
}
|
||||
|
||||
describe('SshFilesystemProvider.stat', () => {
|
||||
it('forwards an abort signal to the multiplexer', async () => {
|
||||
const controller = new AbortController()
|
||||
const request = vi.fn().mockResolvedValue({ size: 0, type: 'file', mtime: 1 })
|
||||
await providerWith(request).stat('/x.jsonl', { signal: controller.signal })
|
||||
expect(request).toHaveBeenCalledWith('fs.stat', expect.anything(), {
|
||||
signal: controller.signal
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SshFilesystemProvider.readFileRange', () => {
|
||||
it('decodes the base64 window the relay returned', async () => {
|
||||
const payload = Buffer.from('hello')
|
||||
|
||||
@@ -209,8 +209,11 @@ export class SshFilesystemProvider implements IFilesystemProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async stat(filePath: string): Promise<FileStat> {
|
||||
return (await this.mux.request('fs.stat', { filePath })) as FileStat
|
||||
async stat(filePath: string, options?: { signal?: AbortSignal }): Promise<FileStat> {
|
||||
const request = options?.signal
|
||||
? this.mux.request('fs.stat', { filePath }, options)
|
||||
: this.mux.request('fs.stat', { filePath })
|
||||
return (await request) as FileStat
|
||||
}
|
||||
|
||||
async lstat(filePath: string): Promise<FileStat> {
|
||||
|
||||
Reference in New Issue
Block a user