mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(ssh): don't refuse a host's move over a pane that saved no terminal history (#33681)
* fix(ssh): don't refuse a host's move over a pane that saved no terminal history A pane with an empty inline buffer, a deleted snapshot, or a zero-byte snapshot has no history to lose, but the move's projection filed it under "saved terminal history Orca could not read" and refused the whole host. Tell absent snapshots apart from unreadable ones and move the former without history; unreadable history still blocks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ssh): keep looking in the fallback root when a scrollback snapshot is empty A zero-byte snapshot in the primary root returned 'absent' without checking the fallback root, so a pane whose history lived there moved without it. Also check ENOENT inline rather than importing a window staging helper. * test(e2e): seed unreadable relay scrollback as an over-limit snapshot A missing snapshot file now means the pane saved no history, so the leave-behind spec needs history that exists but cannot be read. --------- Co-authored-by: Younghwan Chae <chyohw97@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Younghwan Chae
Claude Opus 5.5
parent
478ad9109c
commit
c0a51bcd88
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, mkdirSync, rmSync, truncateSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -24,7 +24,12 @@ import { reconcileManagedOrcadSshTargets, visibleRepos } from '../../ssh/orcad-r
|
||||
import { SshConnectionStore } from '../../ssh/connection/ssh-connection-store'
|
||||
import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import { toSshExecutionHostId } from '../../../shared/execution-host'
|
||||
import { makeTerminalScrollbackSnapshotRef } from '../../terminal-scrollback-snapshots'
|
||||
import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../../shared/terminal-scrollback-limits'
|
||||
import {
|
||||
getProfileTerminalScrollbackSnapshotRoot,
|
||||
getTerminalScrollbackSnapshotPath,
|
||||
makeTerminalScrollbackSnapshotRef
|
||||
} from '../../terminal-scrollback-snapshots'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state: { targetStore: unknown } = { targetStore: null }
|
||||
@@ -183,6 +188,11 @@ describe('converting an SSH host into a managed server', () => {
|
||||
mkdirSync(reportDirectory)
|
||||
const dataFile = join(reportDirectory, 'orca-data.json')
|
||||
writeFileSync(dataFile, JSON.stringify(profile))
|
||||
const snapshotRoot = getProfileTerminalScrollbackSnapshotRoot(dataFile)
|
||||
mkdirSync(snapshotRoot)
|
||||
const snapshotPath = getTerminalScrollbackSnapshotPath(ref, { snapshotRoot })!
|
||||
writeFileSync(snapshotPath, '')
|
||||
truncateSync(snapshotPath, TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1)
|
||||
store = createSqliteTestStore(Store, { dataFile })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry wraps this real test store.
|
||||
mocks.state.targetStore = new SshConnectionStore(store as never)
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import type { OrcadMigrationTerminalScrollbackSnapshot } from '../../../shared/orcad-migration/orcad-migration-scrollback'
|
||||
import { hasDuplicateOrcadMigrationScrollbackDescriptors } from './orcad-source-scrollback-state'
|
||||
import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../../shared/terminal-scrollback-limits'
|
||||
import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types'
|
||||
import {
|
||||
makeTerminalScrollbackSnapshotRef,
|
||||
type TerminalScrollbackSnapshotStorage
|
||||
} from '../../terminal-scrollback-snapshots'
|
||||
import {
|
||||
hasDuplicateOrcadMigrationScrollbackDescriptors,
|
||||
projectOrcadMigrationSessionScrollback
|
||||
} from './orcad-source-scrollback-state'
|
||||
|
||||
const FIRST: OrcadMigrationTerminalScrollbackSnapshot = {
|
||||
tabId: 'tab-1',
|
||||
@@ -10,7 +23,83 @@ const FIRST: OrcadMigrationTerminalScrollbackSnapshot = {
|
||||
byteLength: 1
|
||||
}
|
||||
|
||||
let root: string
|
||||
let storage: TerminalScrollbackSnapshotStorage
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'orcad-source-scrollback-'))
|
||||
storage = { snapshotRoot: join(root, 'primary'), fallbackSnapshotRoot: join(root, 'fallback') }
|
||||
mkdirSync(join(root, 'primary'))
|
||||
mkdirSync(join(root, 'fallback'))
|
||||
})
|
||||
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
function project(layout: Partial<TerminalLayoutSnapshot>) {
|
||||
return projectOrcadMigrationSessionScrollback(
|
||||
{
|
||||
...getDefaultWorkspaceSession(),
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': { root: null, activeLeafId: null, expandedLeafId: null, ...layout }
|
||||
}
|
||||
},
|
||||
storage
|
||||
)
|
||||
}
|
||||
|
||||
function storeSnapshot(leafId: string, contents: string | number, dir = 'primary'): string {
|
||||
const ref = makeTerminalScrollbackSnapshotRef('tab-1', leafId)
|
||||
const path = join(root, dir, `${ref}.bin`)
|
||||
writeFileSync(path, typeof contents === 'string' ? contents : '')
|
||||
if (typeof contents === 'number') {
|
||||
truncateSync(path, contents)
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
describe('orcad source scrollback projection', () => {
|
||||
it('moves panes that saved no terminal history without blocking the move', () => {
|
||||
const projected = project({
|
||||
buffersByLeafId: { 'leaf-empty': '' },
|
||||
scrollbackRefsByLeafId: {
|
||||
'leaf-deleted': makeTerminalScrollbackSnapshotRef('tab-1', 'leaf-deleted'),
|
||||
'leaf-zero': storeSnapshot('leaf-zero', 0)
|
||||
}
|
||||
})
|
||||
|
||||
expect(projected.blockedItems).toEqual([])
|
||||
expect(projected.snapshots).toEqual([])
|
||||
expect(projected.session.terminalLayoutsByTabId['tab-1'].scrollbackRefsByLeafId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries history from the fallback root when the primary copy is empty or missing', () => {
|
||||
const zeroRef = storeSnapshot('leaf-zero', 0)
|
||||
storeSnapshot('leaf-zero', 'zero', 'fallback')
|
||||
const missingRef = storeSnapshot('leaf-missing', 'missing', 'fallback')
|
||||
|
||||
const projected = project({
|
||||
scrollbackRefsByLeafId: { 'leaf-zero': zeroRef, 'leaf-missing': missingRef }
|
||||
})
|
||||
|
||||
expect(projected.blockedItems).toEqual([])
|
||||
expect(projected.snapshots.map((snapshot) => [snapshot.leafId, snapshot.byteLength])).toEqual([
|
||||
['leaf-missing', 7],
|
||||
['leaf-zero', 4]
|
||||
])
|
||||
})
|
||||
|
||||
it('still blocks the move on saved history it cannot read', () => {
|
||||
const projected = project({
|
||||
scrollbackRefsByLeafId: {
|
||||
'leaf-big': storeSnapshot('leaf-big', TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1)
|
||||
}
|
||||
})
|
||||
|
||||
expect(projected.blockedItems).toEqual([
|
||||
'saved terminal history Orca could not read for tab tab-1'
|
||||
])
|
||||
})
|
||||
|
||||
it('refuses duplicate refs or tab/leaf identities across merged fragments', () => {
|
||||
expect(
|
||||
hasDuplicateOrcadMigrationScrollbackDescriptors([
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st
|
||||
import {
|
||||
makeTerminalScrollbackSnapshotRef,
|
||||
readTerminalScrollbackStoredBytesSync,
|
||||
readTerminalScrollbackStoredSnapshotSync,
|
||||
type TerminalScrollbackSnapshotStorage
|
||||
} from '../../terminal-scrollback-snapshots'
|
||||
import { compareKeys } from './orcad-source-key-order'
|
||||
@@ -64,14 +65,13 @@ export function projectOrcadMigrationSessionScrollback(
|
||||
const bytes = buffer
|
||||
? Buffer.from(buffer, 'utf8')
|
||||
: sourceRef
|
||||
? readTerminalScrollbackStoredBytesSync(sourceRef, storage)
|
||||
: null
|
||||
if (
|
||||
!ref ||
|
||||
!bytes ||
|
||||
bytes.length === 0 ||
|
||||
bytes.length > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT
|
||||
) {
|
||||
? readTerminalScrollbackStoredSnapshotSync(sourceRef, storage)
|
||||
: 'absent'
|
||||
if (bytes === 'absent') {
|
||||
// Nothing was saved, so the pane moves with no history to lose.
|
||||
continue
|
||||
}
|
||||
if (!ref || !bytes || bytes.length > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) {
|
||||
;(leaveBehind ? leftBehindItems : blockedItems).push(
|
||||
`saved terminal history Orca could not read for tab ${tabId}`
|
||||
)
|
||||
|
||||
@@ -79,18 +79,32 @@ export function readTerminalScrollbackStoredBytesSync(
|
||||
ref: string,
|
||||
storage?: TerminalScrollbackSnapshotStorage
|
||||
): Buffer | null {
|
||||
for (const path of snapshotReadPaths(ref, storage)) {
|
||||
const bytes = readTerminalScrollbackStoredSnapshotSync(ref, storage)
|
||||
return bytes === 'absent' ? null : bytes
|
||||
}
|
||||
|
||||
/** `'absent'` when no root holds saved bytes; `null` when a snapshot exists but can't be read. */
|
||||
export function readTerminalScrollbackStoredSnapshotSync(
|
||||
ref: string,
|
||||
storage?: TerminalScrollbackSnapshotStorage
|
||||
): Buffer | 'absent' | null {
|
||||
const paths = snapshotReadPaths(ref, storage)
|
||||
let unreadable = paths.length === 0
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const size = statSync(path).size
|
||||
if (size <= 0 || size > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) {
|
||||
if (statSync(path).size > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) {
|
||||
return null
|
||||
}
|
||||
return readFileSync(path)
|
||||
} catch {
|
||||
// Try the next snapshot root when this read fails.
|
||||
const bytes = readFileSync(path)
|
||||
// An empty file holds no history, but another root may.
|
||||
if (bytes.length > 0) {
|
||||
return bytes
|
||||
}
|
||||
} catch (error) {
|
||||
unreadable ||= !(error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
||||
}
|
||||
}
|
||||
return null
|
||||
return unreadable ? null : 'absent'
|
||||
}
|
||||
|
||||
function trailingUtf8Bytes(value: string, maxBytes: number): Buffer {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Upgrade a live detached 1.4.224 relay to managed orcad while preserving its original tab/shell across SSH loss and explicit disconnect.
|
||||
// Retire the pane/lease only on exit; never touch another desktop’s relay. If its project already moved, keep connected without moving until the shell exits.
|
||||
// COMPAT(legacy-relay-bridge): revisit 2027-01-15, with src/main/ssh/legacy-relay/.
|
||||
import { mkdirSync, truncateSync, writeFileSync } from 'node:fs'
|
||||
import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { z } from 'zod'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
@@ -13,7 +14,14 @@ import {
|
||||
readPersistedProfileState
|
||||
} from './helpers/persisted-profile-state'
|
||||
import { workspaceSessionStateSchema } from '../../src/shared/workspace-session-schema'
|
||||
import { makeTerminalScrollbackSnapshotRef } from '../../src/main/terminal-scrollback-snapshots'
|
||||
import {
|
||||
getProfileTerminalScrollbackSnapshotRoot,
|
||||
getTerminalScrollbackSnapshotPath,
|
||||
makeTerminalScrollbackSnapshotRef
|
||||
} from '../../src/main/terminal-scrollback-snapshots'
|
||||
import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../src/shared/terminal-scrollback-limits'
|
||||
import { getOrcaProfileDataFile } from '../../src/shared/profile-state-storage-paths'
|
||||
import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles'
|
||||
import { getOrcadSshDeployment } from '../../src/shared/runtime-environments'
|
||||
import { toSshExecutionHostId } from '../../src/shared/execution-host'
|
||||
import { toAppSshPtyId } from '../../src/shared/ssh-pty-id'
|
||||
@@ -102,10 +110,23 @@ function launchUpgraded(session: ReturnType<typeof createRestartSession>) {
|
||||
})
|
||||
}
|
||||
|
||||
function writeOverLimitScrollbackSnapshot(userDataDir: string, ref: string): void {
|
||||
const snapshotRoot = getProfileTerminalScrollbackSnapshotRoot(
|
||||
getOrcaProfileDataFile(DEFAULT_LOCAL_ORCA_PROFILE_ID, userDataDir)
|
||||
)
|
||||
const path = getTerminalScrollbackSnapshotPath(ref, { snapshotRoot })
|
||||
if (!path) {
|
||||
throw new Error(`Expected a valid scrollback ref, got ${ref}`)
|
||||
}
|
||||
mkdirSync(snapshotRoot, { recursive: true })
|
||||
writeFileSync(path, '')
|
||||
truncateSync(path, TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1)
|
||||
}
|
||||
|
||||
async function verifyRunningRelayUpgrade(
|
||||
testRepoPath: string,
|
||||
testInfo: TestInfo,
|
||||
missingScrollback = false
|
||||
unreadableScrollback = false
|
||||
): Promise<void> {
|
||||
test.setTimeout(25 * 60_000)
|
||||
host = startDockerSshHostTarget(testInfo)
|
||||
@@ -140,7 +161,10 @@ async function verifyRunningRelayUpgrade(
|
||||
{ targetId, relayPtyId: ours.relayPtyId }
|
||||
)
|
||||
const terminalTabId = seeded.terminalTabId!
|
||||
if (missingScrollback) {
|
||||
if (unreadableScrollback) {
|
||||
const ref = makeTerminalScrollbackSnapshotRef(terminalTabId, seeded.terminalLeafId)
|
||||
// A missing file is no history at all; an over-limit one is saved history Orca cannot read.
|
||||
writeOverLimitScrollbackSnapshot(session.userDataDir, ref)
|
||||
mutateStoppedProfileState(session.userDataDir, (state) => {
|
||||
const partitions = z
|
||||
.record(z.string(), workspaceSessionStateSchema)
|
||||
@@ -150,12 +174,7 @@ async function verifyRunningRelayUpgrade(
|
||||
if (!layout) {
|
||||
throw new Error('Expected the released terminal layout')
|
||||
}
|
||||
layout.scrollbackRefsByLeafId = {
|
||||
[seeded.terminalLeafId]: makeTerminalScrollbackSnapshotRef(
|
||||
terminalTabId,
|
||||
seeded.terminalLeafId
|
||||
)
|
||||
}
|
||||
layout.scrollbackRefsByLeafId = { [seeded.terminalLeafId]: ref }
|
||||
state.workspaceSessionsByHostId = partitions
|
||||
state.sshRemotePtyLeases = [
|
||||
...z.array(z.unknown()).parse(state.sshRemotePtyLeases),
|
||||
@@ -193,7 +212,7 @@ async function verifyRunningRelayUpgrade(
|
||||
}
|
||||
)
|
||||
|
||||
if (missingScrollback) {
|
||||
if (unreadableScrollback) {
|
||||
await expect
|
||||
.poll(() => managedServer(page, targetId), { timeout: 120_000 })
|
||||
.toMatchObject({ kind: 'relay', reason: 'refused', canLeaveBehind: true })
|
||||
|
||||
Reference in New Issue
Block a user