mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
Merge remote-tracking branch 'origin/main' into fix/renderer-crash-white-screen
This commit is contained in:
@@ -2036,16 +2036,17 @@
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Deterministic main/renderer tests run on macOS and exercise simulated Windows plus POSIX/Linux process-confirmation behavior. They cover exact local-ConPTY membership, detached-child rejection, unchanged SSH/WSL/remote fallback, process-confirmed Droid routing and launch-triggered confirmation, typed-alias and forged-OSC isolation, shells without OSC 133, split/detach ownership with preserved shell classification, stale PTY exit/rebind rejection, command-generation revocation, daemon v21 warm-reattach identity, unavailable inspection, and lazy subprocess/RPC callback counts. The Electron byte test runs only on Windows; live Windows and Linux PTY evidence remains uncollected. Test failures report exact expected bytes; no product telemetry or raw terminal logging is added.",
|
||||
"coverageNotes": "Deterministic main/renderer tests run on macOS and exercise simulated Windows plus POSIX/Linux process-confirmation behavior. They cover exact local-ConPTY membership, detached-child rejection, SSH and paired-runtime host-platform routing including legacy runtime PTY IDs, active-PTY ownership after worktree host changes, Windows-to-WSL routing, unknown-metadata fallback, production handler composition, KKP authorization on every host, process-confirmed Droid routing and launch-triggered confirmation, typed-alias and forged-OSC isolation, shells without OSC 133, split/detach ownership with preserved shell classification, stale PTY exit/rebind rejection, command-generation revocation, daemon v21 warm-reattach identity, unavailable inspection, and lazy subprocess/RPC callback counts. A live linux-arm64 Docker target additionally proved real relay deployment, SSH PTY host detection, and exact inactive/active KKP bytes. The Electron byte test runs only on Windows; live Windows evidence remains uncollected. Test failures report exact expected bytes; no product telemetry or raw terminal logging is added.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/7620",
|
||||
"https://github.com/stablyai/orca/pull/7668"
|
||||
],
|
||||
"invariant": "Shift+Enter sends CSI-u only when the active pane is a genuine local Windows ConPTY with allowlisted Droid ownership or fresh routing-trusted Droid process evidence. Every new command and PTY replacement revokes stale routing until current evidence settles. All other Windows panes preserve Esc+CR; non-Windows panes remain CSI-u; SSH/WSL/remote behavior is unchanged; split or reused panes cannot inherit sibling or prior-PTY authority; unrelated keys perform no agent or ConPTY lookup.",
|
||||
"oracle": "Main/renderer tests assert exact bytes for local and non-local Windows plus non-Windows policy cells. Fresh Windows process evidence authorizes bytes only after a recognized global candidate is intersected with the exact ConPTY console process list; detached descendants, helper failure/timeout/root-only fallback, stale PTY exit/rebind results, typed text, and hook/OSC output fail closed. A new OSC 133;C immediately publishes a routing-neutral generation, including during D-to-C races. In cmd.exe/Git Bash/custom shells without OSC 133, accepted inferred commands start the same fresh generation; accepted submit/interrupt, title exit, focus, and visibility revoke trusted Droid bytes while one bounded confirmation runs. Detach preserves the live PTY's source shell override so WSL/native classification cannot change with the current default. Ordinary fast shell commands use cached/no-scan paths; routing-relevant confirmation bypasses cached process snapshots and keeps the bounded three-read ladder. Daemon protocol v21 persists only an allowlisted launchAgent; warm reattach uses it as a display/confirmation hint and restores routing only after current process proof. The Windows Electron test focuses the real xterm textarea and records exact renderer-to-main PTY writes.",
|
||||
"invariant": "Shift+Enter key recognition follows the client OS, while emitted bytes follow the active application and PTY host: active Kitty keyboard protocol authorizes CSI-u on every host; otherwise Windows hosts use Esc+CR except when allowlisted Droid ownership or fresh routing-trusted Droid process evidence authorizes CSI-u, and non-Windows hosts use Esc+CR. The active SSH connection, remote-runtime PTY owner, or live local session outranks later worktree ownership changes, and missing host-platform metadata falls back to the client. Every new command and PTY replacement revokes stale agent routing until current evidence settles; split or reused panes cannot inherit sibling or prior-PTY authority; unrelated keys perform no host, agent, protocol, or ConPTY lookup.",
|
||||
"oracle": "Renderer tests assert exact bytes across client/PTY-host combinations, including Kitty keyboard protocol active and inactive on Windows and non-Windows hosts, and prove SSH identity, the environment encoded in an active remote-runtime PTY id, and live local-session identity outrank current worktree ownership, while unavailable platform metadata falls back to the client. A live linux-arm64 SSH PTY independently captured Esc+CR with KKP inactive and CSI-u with KKP active. Fresh Windows process evidence authorizes Droid bytes only after a recognized global candidate is intersected with the exact ConPTY console process list; detached descendants, helper failure/timeout/root-only fallback, stale PTY exit/rebind results, typed text, and hook/OSC output fail closed. A new OSC 133;C immediately publishes a routing-neutral generation, including during D-to-C races. In cmd.exe/Git Bash/custom shells without OSC 133, accepted inferred commands start the same fresh generation; accepted submit/interrupt, title exit, focus, and visibility revoke trusted Droid bytes while one bounded confirmation runs. Detach preserves the live PTY's source shell override so WSL/native classification cannot change with the current default. Ordinary fast shell commands use cached/no-scan paths; routing-relevant confirmation bypasses cached process snapshots and keeps the bounded three-read ladder. Daemon protocol v21 persists only an allowlisted launchAgent; warm reattach uses it as a display/confirmation hint and restores routing only after current process proof. The Windows Electron test focuses the real xterm textarea and records exact renderer-to-main PTY writes.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-cascades.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts",
|
||||
"pnpm run test:e2e -- tests/e2e/terminal-shortcuts.spec.ts"
|
||||
"pnpm run test:e2e -- tests/e2e/terminal-shortcuts.spec.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/daemon/daemon-foreground-confirmation-protocol.test.ts",
|
||||
@@ -2060,9 +2061,12 @@
|
||||
"src/main/providers/local-pty-provider.test.ts",
|
||||
"src/main/providers/windows-conpty-process-membership.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts",
|
||||
"src/renderer/src/store/slices/store-cascades.test.ts",
|
||||
@@ -2132,12 +2136,33 @@
|
||||
"one fresh command-finish scan confirms real shell exit, while unavailable inspection performs exactly three bounded reads and never claims shell"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts",
|
||||
"assertions": [
|
||||
"the production keyboard-policy boundary requires active-host routing and composes macOS Shift+Enter with Windows PTY bytes"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
|
||||
"assertions": [
|
||||
"a legacy remote PTY id retains the transport's captured runtime environment identity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-input-host-platform.test.ts",
|
||||
"assertions": [
|
||||
"SSH terminals use remote-platform metadata and execution-host identity when the active transport has not captured a connection id",
|
||||
"encoded and legacy active remote-runtime PTY identities plus a live local PTY session outrank later worktree host changes",
|
||||
"a live WSL session normalizes to a Linux terminal host",
|
||||
"missing SSH/runtime platform metadata and local terminals fall back to the client platform"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts",
|
||||
"assertions": [
|
||||
"Windows defaults and non-Droid agents preserve Esc+CR while explicit Droid capability selects CSI-u",
|
||||
"SSH, WSL, and remote Windows-client panes preserve Esc+CR without consulting the Droid capability",
|
||||
"non-Windows Shift+Enter remains CSI-u and unrelated keys invoke neither agent nor ConPTY callbacks"
|
||||
"Windows and non-Windows PTYs reached cross-OS use CSI-u only with active Kitty keyboard protocol unless trusted Windows Droid evidence independently authorizes it",
|
||||
"unrelated keys invoke neither host, agent, nor protocol callbacks"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -2238,20 +2263,20 @@
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Callback-count tests prove unrelated ordinary keys invoke neither ConPTY nor agent resolution. Windows Shift+Enter performs two store reads: one local-ConPTY classification with an O(tabs-in-worktree) tab lookup, then two pane-keyed O(1) ownership lookups. Typed text performs no routing write. Ordinary fast OSC 133;C-to-D shell commands perform no fresh confirmation RPC or process scan. An accepted submit/interrupt while trusted Droid is current, a typed allowlisted agent command, title exit, focus, visibility transition, or an idle burst after trusted Droid Shift+Enter can schedule the bounded confirmation ladder; each event cancels/deduplicates prior work and no timer recurs after settling. Windows confirmation performs one fresh process-table scan and forks exact ConPTY membership only after a recognized agent candidate appears; no recognized candidate makes zero helper calls. Same-turn fresh requests share one scan and later arrivals queue at most one successor. Recovery remains three bounded one-shot reads (350 ms, 1.2 s, 6 s). No polling loop, provider fanout, persistent listener, hidden-pane wake loop, or per-printable-key work is added."
|
||||
"evidence": "Callback-count tests prove unrelated ordinary keys invoke neither host, agent, nor Kitty-protocol resolution. Host and agent callbacks are created once per keyboard effect, so printable input adds no per-key allocations or store reads. Shift+Enter performs bounded synchronous active-transport and indexed store lookups, plus at most one pane-keyed protocol-mode lookup when trusted Windows Droid evidence does not already authorize CSI-u, with no IPC, RPC, subprocess, polling, listener, or timer. Typed text performs no routing write. Ordinary fast OSC 133;C-to-D shell commands perform no fresh confirmation RPC or process scan. An accepted submit/interrupt while trusted Droid is current, a typed allowlisted agent command, title exit, focus, visibility transition, or an idle burst after trusted Droid Shift+Enter can schedule the bounded confirmation ladder; each event cancels/deduplicates prior work and no timer recurs after settling. Windows confirmation performs one fresh process-table scan and forks exact ConPTY membership only after a recognized agent candidate appears; no recognized candidate makes zero helper calls. Same-turn fresh requests share one scan and later arrivals queue at most one successor. Recovery remains three bounded one-shot reads (350 ms, 1.2 s, 6 s). No polling loop, provider fanout, persistent listener, hidden-pane wake loop, or per-printable-key work is added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the exact PTY-byte Electron oracle on Windows without silent platform skips.",
|
||||
"Collect stable runtime and flake history for local and daemon Windows ConPTY while retaining deterministic unchanged-provider assertions for SSH, WSL, and remote runtime.",
|
||||
"Collect stable runtime and flake history for local and daemon Windows ConPTY while retaining deterministic host-routing assertions for SSH, WSL, and remote runtime.",
|
||||
"Attach saved intentional-break evidence for Droid capability removal and split-sibling identity leakage."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No Windows runner or live Linux PTY evidence is attached; the Electron byte test skips on macOS and Linux, while Linux behavior is covered only by deterministic POSIX unit paths.",
|
||||
"Droid inside SSH, WSL, or remote-runtime intentionally keeps the pre-existing Windows-client Esc+CR behavior because local foreground confirmation is unavailable; this PR narrows the exception to issue #7620's local ConPTY surface.",
|
||||
"No Windows runner or live Windows PTY evidence is attached; the Electron byte test skips on macOS and Linux, while a live Linux SSH PTY covers only the remote Linux host path.",
|
||||
"Droid agent-specific CSI-u authorization is available only from trusted pane evidence; without it, Windows and non-Windows PTY hosts depend on the application's Kitty keyboard protocol state.",
|
||||
"The Electron test injects allowlisted pane identity rather than launching the real Droid and Antigravity binaries.",
|
||||
"If both Windows process-enumeration backends remain unavailable through the bounded ladder, routing fails closed to the legacy Esc+CR path; no live Droid artifact covers that degraded host configuration.",
|
||||
"Protocol v21 restarts older daemons because the minimal persisted launchAgent is a new contract; sessions preserved by pre-v21 daemons cannot supply warm-reattach launch ownership and fail closed until current process confirmation or restart.",
|
||||
"No live daemon, SSH, WSL, remote-runtime, paired-web, or restore/replay byte artifact has been collected.",
|
||||
"No live daemon, WSL, remote-runtime, paired-web, or restore/replay byte artifact has been collected; SSH is covered only by a macOS-client-to-Linux-host relay run.",
|
||||
"A running PTY keeps its spawn-time ORCA_PANE_KEY after pane-to-tab detach. This change transfers launch/process routing evidence and retires the stale hook snapshot, but does not redesign future hook reattribution across tab IDs."
|
||||
],
|
||||
"demotionRule": "Keep non-blocking or demote to protection none if the Windows byte test silently skips, flakes without an actionable artifact, routes bytes using tab/title/terminal-output heuristics, or adds any ordinary-key lookup, IPC, polling, scan, or lifecycle leak."
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.138-rc.2",
|
||||
"version": "1.4.138-rc.3",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { lstat, readdir, realpath } from 'node:fs/promises'
|
||||
import { isAbsolute, join, relative, sep } from 'node:path'
|
||||
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
|
||||
import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform'
|
||||
|
||||
export async function captureLocalUploadRoot(
|
||||
sourcePath: string,
|
||||
sourceStat: Awaited<ReturnType<typeof lstat>>
|
||||
): Promise<string> {
|
||||
const rootRealPath = await realpath(sourcePath)
|
||||
const rootRealStat = await lstat(rootRealPath)
|
||||
if (
|
||||
statIdentityPartChanged(sourceStat.ino, rootRealStat.ino) ||
|
||||
statIdentityPartChanged(sourceStat.dev, rootRealStat.dev) ||
|
||||
!rootRealStat.isDirectory()
|
||||
) {
|
||||
throw new Error(`Upload source changed while being inspected: ${sourcePath}`)
|
||||
}
|
||||
return rootRealPath
|
||||
}
|
||||
|
||||
export async function preScanSshImportDirectory(
|
||||
dirPath: string,
|
||||
remotePathFlavor: RemotePathFlavor
|
||||
): Promise<boolean> {
|
||||
const entries = await readdir(dirPath, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
assertSafeRemotePathSegment(entry.name, remotePathFlavor)
|
||||
if (entry.isSymbolicLink()) {
|
||||
return true
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
const childPath = join(dirPath, entry.name)
|
||||
if (await preScanSshImportDirectory(childPath, remotePathFlavor)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function uploadSshImportDirectory(
|
||||
provider: IFilesystemProvider,
|
||||
uploadSession: FileUploadSession,
|
||||
localDir: string,
|
||||
remoteDir: string,
|
||||
rootRealPath: string,
|
||||
remotePathFlavor: RemotePathFlavor
|
||||
): Promise<void> {
|
||||
await assertLocalUploadPathInsideRoot(rootRealPath, localDir)
|
||||
const entries = await readdir(localDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
assertSafeRemotePathSegment(entry.name, remotePathFlavor)
|
||||
const localPath = join(localDir, entry.name)
|
||||
const remotePath = `${remoteDir}/${entry.name}`
|
||||
await assertLocalUploadPathInsideRoot(rootRealPath, localPath)
|
||||
const statResult = await lstat(localPath)
|
||||
|
||||
// Why: skip symlinks and special files even after the up-front pre-scan;
|
||||
// this closes the TOCTOU gap if one is created during upload.
|
||||
if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (statResult.isDirectory()) {
|
||||
await provider.createDirNoClobber(remotePath)
|
||||
await uploadSshImportDirectory(
|
||||
provider,
|
||||
uploadSession,
|
||||
localPath,
|
||||
remotePath,
|
||||
rootRealPath,
|
||||
remotePathFlavor
|
||||
)
|
||||
continue
|
||||
}
|
||||
await uploadSession.uploadFile(localPath, remotePath, { exclusive: true })
|
||||
}
|
||||
}
|
||||
|
||||
function statIdentityPartChanged(
|
||||
left: number | bigint | undefined,
|
||||
right: number | bigint | undefined
|
||||
): boolean {
|
||||
const leftKnown = left !== undefined && left !== 0 && left !== 0n
|
||||
const rightKnown = right !== undefined && right !== 0 && right !== 0n
|
||||
return leftKnown && rightKnown && left !== right
|
||||
}
|
||||
|
||||
async function assertLocalUploadPathInsideRoot(
|
||||
rootRealPath: string,
|
||||
candidatePath: string
|
||||
): Promise<void> {
|
||||
const candidateRealPath = await realpath(candidatePath)
|
||||
const relativeToRoot = relative(rootRealPath, candidateRealPath)
|
||||
if (
|
||||
relativeToRoot !== '' &&
|
||||
(relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot))
|
||||
) {
|
||||
throw new Error(`Upload source escapes selected directory: ${candidatePath}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import path from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
|
||||
|
||||
const { getConnMgrMock, lstatMock, providerMock, readdirMock, realpathMock } = vi.hoisted(() => ({
|
||||
getConnMgrMock: vi.fn(),
|
||||
lstatMock: vi.fn(),
|
||||
providerMock: vi.fn(),
|
||||
readdirMock: vi.fn(),
|
||||
realpathMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
lstat: lstatMock,
|
||||
readdir: readdirMock,
|
||||
realpath: realpathMock
|
||||
}))
|
||||
vi.mock('./filesystem-auth', () => ({
|
||||
authorizeExternalPath: vi.fn(),
|
||||
isENOENT: (error: NodeJS.ErrnoException) => error.code === 'ENOENT'
|
||||
}))
|
||||
vi.mock('./ssh', () => ({ getSshConnectionManager: getConnMgrMock }))
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
requireSshFilesystemProvider: providerMock
|
||||
}))
|
||||
|
||||
import { importExternalPathsSsh } from './filesystem-import-ssh'
|
||||
|
||||
function createProvider(uploadSession: FileUploadSession): IFilesystemProvider {
|
||||
const missing = Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
return {
|
||||
stat: vi.fn().mockRejectedValue(missing),
|
||||
createDirNoClobber: vi.fn().mockResolvedValue(undefined),
|
||||
deletePath: vi.fn().mockResolvedValue(undefined),
|
||||
openFileUploadSession: vi.fn().mockResolvedValue(uploadSession)
|
||||
} as unknown as IFilesystemProvider
|
||||
}
|
||||
|
||||
describe('SSH import remote path safety', () => {
|
||||
const connectionId = 'ssh-windows'
|
||||
const destDir = 'C:/Users/me/project/.orca/drops'
|
||||
let provider: IFilesystemProvider
|
||||
let uploadSession: FileUploadSession
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getConnMgrMock.mockReturnValue({
|
||||
getConnection: () => ({ getState: () => ({ status: 'connected' }) })
|
||||
})
|
||||
uploadSession = {
|
||||
uploadFile: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn()
|
||||
}
|
||||
provider = createProvider(uploadSession)
|
||||
providerMock.mockReturnValue(provider)
|
||||
readdirMock.mockResolvedValue([])
|
||||
realpathMock.mockImplementation(async (value: string) => value)
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'rejects a POSIX filename containing Windows traversal before remote stat',
|
||||
async () => {
|
||||
const sourcePath = '/tmp/..\\..\\.ssh\\orca_drop'
|
||||
|
||||
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
reason: expect.stringContaining('Unsafe')
|
||||
})
|
||||
expect(lstatMock).not.toHaveBeenCalled()
|
||||
expect(provider.stat).not.toHaveBeenCalled()
|
||||
expect(provider.createDirNoClobber).not.toHaveBeenCalled()
|
||||
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
|
||||
expect(provider.deletePath).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['report.txt:orca', 'NUL', 'trailing.', 'question?.txt'])(
|
||||
'rejects Windows-special top-level name %j before remote stat',
|
||||
async (name) => {
|
||||
const { results } = await importExternalPathsSsh([`/tmp/${name}`], destDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
reason: expect.stringContaining('Unsafe')
|
||||
})
|
||||
expect(lstatMock).not.toHaveBeenCalled()
|
||||
expect(provider.stat).not.toHaveBeenCalled()
|
||||
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects a nested Windows traversal name before creating the import root', async () => {
|
||||
const sourcePath = path.resolve('/tmp/assets')
|
||||
lstatMock.mockResolvedValue({
|
||||
isFile: () => false,
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false
|
||||
})
|
||||
readdirMock.mockResolvedValue([
|
||||
{
|
||||
name: '..\\..\\.ssh\\orca_drop',
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
])
|
||||
|
||||
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
reason: expect.stringContaining('Unsafe')
|
||||
})
|
||||
expect(provider.stat).not.toHaveBeenCalled()
|
||||
expect(provider.createDirNoClobber).not.toHaveBeenCalled()
|
||||
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
|
||||
expect(provider.deletePath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps exclusive creation for safe file names', async () => {
|
||||
const sourcePath = path.resolve('/tmp/report.txt')
|
||||
lstatMock.mockResolvedValue({
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
})
|
||||
|
||||
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({ status: 'imported', destPath: `${destDir}/report.txt` })
|
||||
expect(uploadSession.uploadFile).toHaveBeenCalledWith(sourcePath, `${destDir}/report.txt`, {
|
||||
exclusive: true
|
||||
})
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'preserves backslashes in valid POSIX names for POSIX remotes',
|
||||
async () => {
|
||||
const sourcePath = path.resolve('/tmp/notes\\2026.txt')
|
||||
const posixDestDir = '/home/me/project'
|
||||
lstatMock.mockResolvedValue({
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
})
|
||||
|
||||
const { results } = await importExternalPathsSsh([sourcePath], posixDestDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'imported',
|
||||
destPath: `${posixDestDir}/notes\\2026.txt`
|
||||
})
|
||||
expect(uploadSession.uploadFile).toHaveBeenCalledWith(
|
||||
sourcePath,
|
||||
`${posixDestDir}/notes\\2026.txt`,
|
||||
{ exclusive: true }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('streams an N-file directory through guarded per-file uploads', async () => {
|
||||
const sourcePath = path.resolve('/tmp/assets')
|
||||
const names = ['one.txt', 'two.txt', 'three.txt']
|
||||
const entries = names.map((name) => ({
|
||||
name,
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}))
|
||||
lstatMock.mockImplementation(async (value: string) =>
|
||||
value === sourcePath
|
||||
? { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
|
||||
: { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
|
||||
)
|
||||
readdirMock.mockResolvedValue(entries)
|
||||
|
||||
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' })
|
||||
expect(uploadSession.uploadFile).toHaveBeenCalledTimes(3)
|
||||
for (const name of names) {
|
||||
expect(uploadSession.uploadFile).toHaveBeenCalledWith(
|
||||
path.join(sourcePath, name),
|
||||
`${destDir}/assets/${name}`,
|
||||
{ exclusive: true }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('skips special entries without failing the directory import', async () => {
|
||||
const sourcePath = path.resolve('/tmp/assets')
|
||||
const regularPath = path.join(sourcePath, 'report.txt')
|
||||
const socketPath = path.join(sourcePath, 'server.sock')
|
||||
const entries = [
|
||||
{
|
||||
name: 'report.txt',
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
},
|
||||
{
|
||||
name: 'server.sock',
|
||||
isFile: () => false,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false
|
||||
}
|
||||
]
|
||||
lstatMock.mockImplementation(async (value: string) => {
|
||||
if (value === sourcePath) {
|
||||
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
|
||||
}
|
||||
if (value === regularPath) {
|
||||
return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
|
||||
}
|
||||
expect(value).toBe(socketPath)
|
||||
return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => false }
|
||||
})
|
||||
readdirMock.mockResolvedValue(entries)
|
||||
|
||||
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' })
|
||||
expect(uploadSession.uploadFile).toHaveBeenCalledOnce()
|
||||
expect(uploadSession.uploadFile).toHaveBeenCalledWith(
|
||||
regularPath,
|
||||
`${destDir}/assets/report.txt`,
|
||||
{ exclusive: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a selected root replaced after its realpath is captured', async () => {
|
||||
const sourcePath = path.resolve('/tmp/assets')
|
||||
lstatMock.mockResolvedValue({
|
||||
isFile: () => false,
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false
|
||||
})
|
||||
realpathMock.mockResolvedValueOnce(sourcePath).mockResolvedValueOnce('/private/replacement')
|
||||
|
||||
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
reason: expect.stringContaining('escapes selected directory')
|
||||
})
|
||||
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
|
||||
expect(provider.deletePath).toHaveBeenCalledWith(`${destDir}/assets`, true)
|
||||
})
|
||||
|
||||
it('rejects a selected root replaced while its realpath is captured', async () => {
|
||||
const sourcePath = path.resolve('/tmp/assets')
|
||||
const directoryStat = (ino: number) => ({
|
||||
dev: 1,
|
||||
ino,
|
||||
isFile: () => false,
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false
|
||||
})
|
||||
lstatMock.mockResolvedValueOnce(directoryStat(1)).mockResolvedValueOnce(directoryStat(2))
|
||||
realpathMock.mockResolvedValue('/private/replacement')
|
||||
|
||||
const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId)
|
||||
|
||||
expect(results[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
reason: expect.stringContaining('changed while being inspected')
|
||||
})
|
||||
expect(provider.createDirNoClobber).not.toHaveBeenCalled()
|
||||
expect(uploadSession.uploadFile).not.toHaveBeenCalled()
|
||||
expect(provider.deletePath).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,17 @@
|
||||
import { lstat, readdir, realpath } from 'node:fs/promises'
|
||||
import { basename, join, posix, resolve } from 'node:path'
|
||||
import { lstat } from 'node:fs/promises'
|
||||
import { basename, posix, resolve } from 'node:path'
|
||||
import { authorizeExternalPath, isENOENT } from './filesystem-auth'
|
||||
import { getSshConnectionManager } from './ssh'
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
|
||||
import type { ImportItemResult } from './filesystem-mutations'
|
||||
import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import {
|
||||
captureLocalUploadRoot,
|
||||
preScanSshImportDirectory,
|
||||
uploadSshImportDirectory
|
||||
} from './filesystem-import-ssh-directory'
|
||||
|
||||
// Why: the SSH import path uses SshFilesystemProvider instead of direct SFTP so
|
||||
// system-SSH transports (ProxyCommand/ProxyJump/FIDO2) get the same workflows.
|
||||
@@ -47,6 +54,10 @@ export async function importExternalPathsSsh(
|
||||
throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.')
|
||||
}
|
||||
const uploadSession = await provider.openFileUploadSession()
|
||||
// Why: filename legality follows the remote filesystem, not the client's OS.
|
||||
const remotePathFlavor: RemotePathFlavor = isWindowsAbsolutePathLike(destDir)
|
||||
? 'windows'
|
||||
: 'posix'
|
||||
try {
|
||||
for (const sourcePath of sourcePaths) {
|
||||
const result = await importOneSourceSsh(
|
||||
@@ -54,7 +65,8 @@ export async function importExternalPathsSsh(
|
||||
uploadSession,
|
||||
sourcePath,
|
||||
destDir,
|
||||
reservedNames
|
||||
reservedNames,
|
||||
remotePathFlavor
|
||||
)
|
||||
results.push(result)
|
||||
if (result.status === 'imported') {
|
||||
@@ -76,12 +88,24 @@ async function importOneSourceSsh(
|
||||
uploadSession: FileUploadSession,
|
||||
sourcePath: string,
|
||||
destDir: string,
|
||||
reservedNames: Set<string>
|
||||
reservedNames: Set<string>,
|
||||
remotePathFlavor: RemotePathFlavor
|
||||
): Promise<ImportItemResult> {
|
||||
const resolvedSource = resolve(sourcePath)
|
||||
|
||||
authorizeExternalPath(resolvedSource)
|
||||
|
||||
const originalName = basename(resolvedSource)
|
||||
try {
|
||||
assertSafeRemotePathSegment(originalName, remotePathFlavor)
|
||||
} catch (error) {
|
||||
return {
|
||||
sourcePath,
|
||||
status: 'failed',
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
let sourceStat: Awaited<ReturnType<typeof lstat>>
|
||||
try {
|
||||
sourceStat = await lstat(resolvedSource)
|
||||
@@ -114,17 +138,13 @@ async function importOneSourceSsh(
|
||||
|
||||
const isDir = sourceStat.isDirectory()
|
||||
|
||||
if (isDir) {
|
||||
const hasSymlink = await preScanForSymlinks(resolvedSource)
|
||||
if (hasSymlink) {
|
||||
return { sourcePath, status: 'skipped', reason: 'symlink' }
|
||||
}
|
||||
}
|
||||
|
||||
const originalName = basename(resolvedSource)
|
||||
|
||||
let createdDestDir: string | null = null
|
||||
try {
|
||||
const rootRealPath = isDir ? await captureLocalUploadRoot(resolvedSource, sourceStat) : null
|
||||
if (isDir && (await preScanSshImportDirectory(resolvedSource, remotePathFlavor))) {
|
||||
return { sourcePath, status: 'skipped', reason: 'symlink' }
|
||||
}
|
||||
|
||||
const finalName = await deconflictName(provider, destDir, originalName, reservedNames)
|
||||
const destPath = `${destDir}/${finalName}`
|
||||
const renamed = finalName !== originalName
|
||||
@@ -132,12 +152,13 @@ async function importOneSourceSsh(
|
||||
if (isDir) {
|
||||
await provider.createDirNoClobber(destPath)
|
||||
createdDestDir = destPath
|
||||
await uploadDirectoryViaProvider(
|
||||
await uploadSshImportDirectory(
|
||||
provider,
|
||||
uploadSession,
|
||||
resolvedSource,
|
||||
destPath,
|
||||
await realpath(resolvedSource)
|
||||
rootRealPath!,
|
||||
remotePathFlavor
|
||||
)
|
||||
} else {
|
||||
await uploadSession.uploadFile(resolvedSource, destPath, { exclusive: true })
|
||||
@@ -217,36 +238,6 @@ async function ensureDropStagingDir(provider: IFilesystemProvider, destDir: stri
|
||||
await provider.createDir(destDir)
|
||||
}
|
||||
|
||||
async function uploadDirectoryViaProvider(
|
||||
provider: IFilesystemProvider,
|
||||
uploadSession: FileUploadSession,
|
||||
localDir: string,
|
||||
remoteDir: string,
|
||||
rootRealPath: string
|
||||
): Promise<void> {
|
||||
await assertLocalUploadPathInsideRoot(rootRealPath, localDir)
|
||||
const entries = await readdir(localDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const localPath = join(localDir, entry.name)
|
||||
const remotePath = `${remoteDir}/${entry.name}`
|
||||
await assertLocalUploadPathInsideRoot(rootRealPath, localPath)
|
||||
const statResult = await lstat(localPath)
|
||||
|
||||
// Why: skip symlinks and special files even after the up-front pre-scan;
|
||||
// this closes the TOCTOU gap if one is created during upload.
|
||||
if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (statResult.isDirectory()) {
|
||||
await provider.createDirNoClobber(remotePath)
|
||||
await uploadDirectoryViaProvider(provider, uploadSession, localPath, remotePath, rootRealPath)
|
||||
continue
|
||||
}
|
||||
await uploadSession.uploadFile(localPath, remotePath, { exclusive: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function remotePathExists(
|
||||
provider: IFilesystemProvider,
|
||||
remotePath: string
|
||||
@@ -274,37 +265,3 @@ function isRemoteMissingError(error: unknown): boolean {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function preScanForSymlinks(dirPath: string): Promise<boolean> {
|
||||
const entries = await readdir(dirPath, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (entry.isSymbolicLink()) {
|
||||
return true
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
const childPath = join(dirPath, entry.name)
|
||||
if (await preScanForSymlinks(childPath)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function assertLocalUploadPathInsideRoot(
|
||||
rootRealPath: string,
|
||||
candidatePath: string
|
||||
): Promise<void> {
|
||||
const candidateRealPath = await realpath(candidatePath)
|
||||
const root = rootRealPath.replace(/[\\/]+$/g, '')
|
||||
const candidate = candidateRealPath.replace(/[\\/]+$/g, '')
|
||||
const rootComparable = process.platform === 'win32' ? root.toLowerCase() : root
|
||||
const candidateComparable = process.platform === 'win32' ? candidate.toLowerCase() : candidate
|
||||
if (candidateComparable === rootComparable) {
|
||||
return
|
||||
}
|
||||
const boundary = process.platform === 'win32' ? '\\' : '/'
|
||||
if (!candidateComparable.startsWith(`${rootComparable}${boundary}`)) {
|
||||
throw new Error(`Upload source escapes selected directory: ${candidatePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getRemoteHostPlatform, joinRemotePath } from './ssh-remote-platform'
|
||||
import {
|
||||
assertSafeRemotePathSegment,
|
||||
getRemoteHostPlatform,
|
||||
joinRemotePath
|
||||
} from './ssh-remote-platform'
|
||||
import { detectRemoteHostPlatform } from './ssh-remote-platform-detection'
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
@@ -33,6 +37,47 @@ describe('joinRemotePath', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertSafeRemotePathSegment', () => {
|
||||
it('accepts ordinary names under both path flavors', () => {
|
||||
expect(() => assertSafeRemotePathSegment('report copy.txt', 'posix')).not.toThrow()
|
||||
expect(() => assertSafeRemotePathSegment('report copy.txt', 'windows')).not.toThrow()
|
||||
})
|
||||
|
||||
it.each(['.', '..', '../secret', 'child/name', 'nul\0byte'])(
|
||||
'rejects invalid segment %j under both path flavors',
|
||||
(segment) => {
|
||||
expect(() => assertSafeRemotePathSegment(segment, 'posix')).toThrow(
|
||||
'Unsafe remote path segment'
|
||||
)
|
||||
expect(() => assertSafeRemotePathSegment(segment, 'windows')).toThrow(
|
||||
'Unsafe remote path segment'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves valid POSIX names that Windows would reinterpret', () => {
|
||||
expect(() => assertSafeRemotePathSegment('notes\\2026\nfinal.txt', 'posix')).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'..\\..\\.ssh\\orca_drop',
|
||||
'report.txt:orca',
|
||||
'question?.txt',
|
||||
'trailing.',
|
||||
'trailing ',
|
||||
'NUL',
|
||||
'con.txt',
|
||||
'CONIN$',
|
||||
'CLOCK$.log',
|
||||
'COM1.log',
|
||||
'LPT¹'
|
||||
])('rejects Win32-special segment %j', (segment) => {
|
||||
expect(() => assertSafeRemotePathSegment(segment, 'windows')).toThrow(
|
||||
'Unsafe remote path segment'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectRemoteHostPlatform', () => {
|
||||
it('uses uname when the remote is POSIX', async () => {
|
||||
vi.mocked(execCommand).mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Darwin arm64')
|
||||
|
||||
@@ -84,6 +84,44 @@ export function normalizeWindowsRemotePath(path: string): string {
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
export function assertSafeRemotePathSegment(segment: string, pathFlavor: RemotePathFlavor): void {
|
||||
if (
|
||||
!segment ||
|
||||
segment === '.' ||
|
||||
segment === '..' ||
|
||||
segment.includes('/') ||
|
||||
segment.includes('\0')
|
||||
) {
|
||||
throw new Error(`Unsafe remote path segment: ${JSON.stringify(segment)}`)
|
||||
}
|
||||
if (pathFlavor === 'posix') {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: Win32 canonicalizes names and exposes devices/NTFS streams through
|
||||
// otherwise ordinary-looking path segments, bypassing no-clobber checks.
|
||||
const normalized = normalizeWindowsRemotePath(segment)
|
||||
const deviceStem = normalized.split('.')[0]?.toUpperCase()
|
||||
if (
|
||||
normalized.includes('/') ||
|
||||
hasUnsafeWindowsPathChar(normalized) ||
|
||||
/[ .]$/u.test(normalized) ||
|
||||
/^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|CLOCK\$|COM[1-9¹²³]|LPT[1-9¹²³])$/u.test(deviceStem ?? '')
|
||||
) {
|
||||
throw new Error(`Unsafe remote path segment: ${JSON.stringify(segment)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnsafeWindowsPathChar(value: string): boolean {
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const char = value[i]
|
||||
if (value.charCodeAt(i) <= 31 || (char !== undefined && '<>:"|?*'.includes(char))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function normalizeRemoteHome(rawHome: string, host: RemoteHostPlatform): string {
|
||||
const home = rawHome.trim()
|
||||
return isWindowsRemoteHost(host) ? normalizeWindowsRemotePath(home).replace(/\/+$/, '') : home
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FIND_QUERY_MAX_BYTES } from '@/lib/find-query-bounds'
|
||||
import {
|
||||
matchFileSearchShortcut,
|
||||
matchSearchNavigate,
|
||||
resolveTerminalKeyboardShortcutAction,
|
||||
runTerminalSearchNavigation
|
||||
} from './keyboard-handlers'
|
||||
|
||||
@@ -91,6 +92,26 @@ describe('matchSearchNavigate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTerminalKeyboardShortcutAction', () => {
|
||||
it('routes macOS Shift+Enter with the active Windows PTY host bytes', () => {
|
||||
expect(
|
||||
resolveTerminalKeyboardShortcutAction(
|
||||
makeKeyEvent({ key: 'Enter', shiftKey: true }),
|
||||
true,
|
||||
'false',
|
||||
0,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
() => 'alt-enter',
|
||||
() => true
|
||||
)
|
||||
).toEqual({ type: 'sendInput', data: '\x1b\r' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('runTerminalSearchNavigation', () => {
|
||||
const searchState = { query: 'hello', caseSensitive: true, regex: false }
|
||||
|
||||
|
||||
@@ -31,12 +31,43 @@ import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
|
||||
import { isLocalWindowsConptyPaneForCtrlArrow } from './terminal-ctrl-arrow-conpty'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter'
|
||||
import { resolveTerminalInputHostPlatform } from './terminal-input-host-platform'
|
||||
import {
|
||||
markTerminalFollowOutput,
|
||||
markTerminalPinnedViewport,
|
||||
syncTerminalScrollIntentFromViewport
|
||||
} from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
|
||||
export function resolveTerminalKeyboardShortcutAction(
|
||||
event: Parameters<typeof resolveTerminalShortcutAction>[0],
|
||||
isMac: Parameters<typeof resolveTerminalShortcutAction>[1],
|
||||
macOptionAsAlt: Parameters<typeof resolveTerminalShortcutAction>[2],
|
||||
optionKeyLocation: Parameters<typeof resolveTerminalShortcutAction>[3],
|
||||
isWindows: Parameters<typeof resolveTerminalShortcutAction>[4],
|
||||
keybindings: Parameters<typeof resolveTerminalShortcutAction>[5],
|
||||
isLocalWindowsConptyPane: Parameters<typeof resolveTerminalShortcutAction>[6],
|
||||
isKittyKeyboardActivePane: Parameters<typeof resolveTerminalShortcutAction>[7],
|
||||
layoutBaseCharacterForCode: Parameters<typeof resolveTerminalShortcutAction>[8],
|
||||
getWindowsShiftEnterEncoding: Parameters<typeof resolveTerminalShortcutAction>[9],
|
||||
isWindowsTerminalHost: NonNullable<Parameters<typeof resolveTerminalShortcutAction>[10]>
|
||||
): ReturnType<typeof resolveTerminalShortcutAction> {
|
||||
// Why: keep the host callback required at the production boundary so a
|
||||
// caller cannot silently fall back to client-OS byte routing.
|
||||
return resolveTerminalShortcutAction(
|
||||
event,
|
||||
isMac,
|
||||
macOptionAsAlt,
|
||||
optionKeyLocation,
|
||||
isWindows,
|
||||
keybindings,
|
||||
isLocalWindowsConptyPane,
|
||||
isKittyKeyboardActivePane,
|
||||
layoutBaseCharacterForCode,
|
||||
getWindowsShiftEnterEncoding,
|
||||
isWindowsTerminalHost
|
||||
)
|
||||
}
|
||||
|
||||
export function recordKeyboardCreatedTerminalPaneSplit(
|
||||
createdPane: unknown,
|
||||
args: {
|
||||
@@ -253,6 +284,21 @@ export function useTerminalKeyboardShortcuts({
|
||||
return resolveWindowsShiftEnterEncodingForPane(state, paneKey)
|
||||
}
|
||||
|
||||
// Why: host metadata is live and can hydrate after the terminal mounts;
|
||||
// resolve it only when Shift+Enter needs to choose a byte protocol.
|
||||
const isActivePaneWindowsTerminalHost = (): boolean => {
|
||||
const manager = managerRef.current
|
||||
const activePane = manager?.getActivePane() ?? manager?.getPanes()[0]
|
||||
return (
|
||||
resolveTerminalInputHostPlatform({
|
||||
clientPlatform: shortcutPlatform,
|
||||
state: useAppStore.getState(),
|
||||
worktreeId,
|
||||
transport: activePane ? (paneTransportsRef.current.get(activePane.id) ?? null) : null
|
||||
}) === 'win32'
|
||||
)
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
const manager = managerRef.current
|
||||
if (!manager) {
|
||||
@@ -335,7 +381,7 @@ export function useTerminalKeyboardShortcuts({
|
||||
return (paneKittyKeyboardModesRef?.current.get(activePane.id)?.flags ?? 0) > 0
|
||||
}
|
||||
|
||||
const action = resolveTerminalShortcutAction(
|
||||
const action = resolveTerminalKeyboardShortcutAction(
|
||||
e,
|
||||
isMac,
|
||||
macOptionAsAltRef.current,
|
||||
@@ -345,7 +391,8 @@ export function useTerminalKeyboardShortcuts({
|
||||
isLocalWindowsConptyPane,
|
||||
isKittyKeyboardActivePane,
|
||||
getLayoutBaseCharacterForCode,
|
||||
getActivePaneWindowsShiftEnterEncoding
|
||||
getActivePaneWindowsShiftEnterEncoding,
|
||||
isActivePaneWindowsTerminalHost
|
||||
)
|
||||
if (!action) {
|
||||
return
|
||||
|
||||
@@ -32,7 +32,10 @@ export type PtyBufferSnapshot = {
|
||||
pendingEscapeTailAnsi?: string
|
||||
}
|
||||
|
||||
export type LocalPtySessionMetadata = { cwd?: string; shellOverride?: string }
|
||||
export type LocalPtySessionMetadata = {
|
||||
cwd?: string
|
||||
shellOverride?: string
|
||||
}
|
||||
|
||||
export type PtyConnectResult = {
|
||||
id: string
|
||||
@@ -116,6 +119,9 @@ export type PtyTransport = {
|
||||
isConnected: () => boolean
|
||||
getPtyId: () => string | null
|
||||
getConnectionId?: () => string | null | undefined
|
||||
/** The runtime captured by this transport; legacy remote PTY ids do not
|
||||
* encode their owner, and current worktree settings may have changed. */
|
||||
getRuntimeEnvironmentId?: () => string | null
|
||||
getLocalSessionMetadata?: () => LocalPtySessionMetadata | null
|
||||
/** Drop cross-chunk parser carries (partial OSC-9999 prefix). Called when a
|
||||
* model-restore marker reports dropped bytes — a carry spanning the gap
|
||||
|
||||
@@ -238,7 +238,18 @@ describe('createIpcPtyTransport', () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const localTransport = createIpcPtyTransport({
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu-24.04\\home\\alice\\repo',
|
||||
shellOverride: 'wsl.exe'
|
||||
shellOverride: 'wsl.exe',
|
||||
projectRuntime: {
|
||||
status: 'resolved',
|
||||
runtime: {
|
||||
kind: 'wsl',
|
||||
hostPlatform: 'wsl',
|
||||
projectId: 'repo',
|
||||
distro: 'Ubuntu-24.04',
|
||||
reason: 'project-override',
|
||||
cacheKey: 'repo:wsl'
|
||||
}
|
||||
}
|
||||
})
|
||||
const sshTransport = createIpcPtyTransport({
|
||||
connectionId: 'ssh-1',
|
||||
@@ -253,6 +264,51 @@ describe('createIpcPtyTransport', () => {
|
||||
expect(sshTransport.getLocalSessionMetadata?.()).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps captured Windows and WSL metadata when existing PTYs reattach', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const currentWslForWindowsPty = createIpcPtyTransport({
|
||||
cwd: 'C:\\repo',
|
||||
shellOverride: 'pwsh.exe',
|
||||
projectRuntime: {
|
||||
status: 'resolved',
|
||||
runtime: {
|
||||
kind: 'wsl',
|
||||
hostPlatform: 'wsl',
|
||||
projectId: 'repo',
|
||||
distro: 'Ubuntu-24.04',
|
||||
reason: 'project-override',
|
||||
cacheKey: 'repo:wsl'
|
||||
}
|
||||
}
|
||||
})
|
||||
const currentWindowsForWslPty = createIpcPtyTransport({
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu-24.04\\home\\alice\\repo',
|
||||
shellOverride: 'wsl.exe',
|
||||
projectRuntime: {
|
||||
status: 'resolved',
|
||||
runtime: {
|
||||
kind: 'windows-host',
|
||||
hostPlatform: 'win32',
|
||||
projectId: 'repo',
|
||||
reason: 'project-override',
|
||||
cacheKey: 'repo:windows'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
currentWslForWindowsPty.attach({ existingPtyId: 'windows-pty', callbacks: {} })
|
||||
currentWindowsForWslPty.attach({ existingPtyId: 'wsl-pty', callbacks: {} })
|
||||
|
||||
expect(currentWslForWindowsPty.getLocalSessionMetadata?.()).toEqual({
|
||||
cwd: 'C:\\repo',
|
||||
shellOverride: 'pwsh.exe'
|
||||
})
|
||||
expect(currentWindowsForWslPty.getLocalSessionMetadata?.()).toEqual({
|
||||
cwd: '\\\\wsl.localhost\\Ubuntu-24.04\\home\\alice\\repo',
|
||||
shellOverride: 'wsl.exe'
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the missing-cwd fallback flag only for local IPC spawns', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -1029,8 +1029,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
if (connectionId) {
|
||||
return null
|
||||
}
|
||||
// Why: paste/runtime diagnostics must follow the launched PTY session,
|
||||
// not later project setting changes.
|
||||
// Why: input routing and diagnostics must follow the launched PTY
|
||||
// session, not later project setting changes.
|
||||
return {
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(shellOverride ? { shellOverride } : {})
|
||||
|
||||
@@ -168,6 +168,7 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1')
|
||||
expect(transport.getRuntimeEnvironmentId?.()).toBe('env-1')
|
||||
await vi.waitFor(() =>
|
||||
expect(latestSubscribePayload().capabilities).toEqual({
|
||||
ackOutput: 1,
|
||||
|
||||
@@ -858,6 +858,10 @@ export function createRemoteRuntimePtyTransport(
|
||||
return null
|
||||
},
|
||||
|
||||
getRuntimeEnvironmentId() {
|
||||
return currentRuntimeEnvironmentId
|
||||
},
|
||||
|
||||
async serializeBuffer(opts) {
|
||||
if (!connected || !handle) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { resolveTerminalInputHostPlatform } from './terminal-input-host-platform'
|
||||
|
||||
function state(overrides: Partial<AppState> = {}): AppState {
|
||||
return {
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
sshConnectionStates: new Map(),
|
||||
runtimeStatusByEnvironmentId: new Map(),
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: {},
|
||||
...overrides
|
||||
} as AppState
|
||||
}
|
||||
|
||||
describe('resolveTerminalInputHostPlatform', () => {
|
||||
it('uses a paired runtime host platform instead of the macOS client', () => {
|
||||
const worktreeId = 'repo::C:\\repo'
|
||||
expect(
|
||||
resolveTerminalInputHostPlatform({
|
||||
clientPlatform: 'darwin',
|
||||
state: state({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo',
|
||||
path: 'C:\\repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'runtime:windows-box'
|
||||
}
|
||||
],
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
[
|
||||
'windows-box',
|
||||
{
|
||||
status: { hostPlatform: 'win32' }
|
||||
} as AppState['runtimeStatusByEnvironmentId'] extends Map<string, infer T> ? T : never
|
||||
]
|
||||
])
|
||||
}),
|
||||
worktreeId,
|
||||
transport: null
|
||||
})
|
||||
).toBe('win32')
|
||||
})
|
||||
|
||||
it('uses the active remote runtime PTY owner after worktree ownership changes', () => {
|
||||
expect(
|
||||
resolveTerminalInputHostPlatform({
|
||||
clientPlatform: 'darwin',
|
||||
state: state({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo',
|
||||
path: 'C:\\repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'runtime:linux-box'
|
||||
}
|
||||
],
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
['windows-box', { status: { hostPlatform: 'win32' } } as never],
|
||||
['linux-box', { status: { hostPlatform: 'linux' } } as never]
|
||||
])
|
||||
}),
|
||||
worktreeId: 'repo::C:\\repo',
|
||||
transport: {
|
||||
getConnectionId: () => null,
|
||||
getPtyId: () => 'remote:windows-box@@terminal-1'
|
||||
}
|
||||
})
|
||||
).toBe('win32')
|
||||
})
|
||||
|
||||
it('uses captured runtime ownership for a legacy remote PTY id', () => {
|
||||
expect(
|
||||
resolveTerminalInputHostPlatform({
|
||||
clientPlatform: 'darwin',
|
||||
state: state({
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
['windows-box', { status: { hostPlatform: 'win32' } } as never]
|
||||
])
|
||||
}),
|
||||
worktreeId: 'repo::/repo',
|
||||
transport: {
|
||||
getConnectionId: () => null,
|
||||
getPtyId: () => 'remote:terminal-1',
|
||||
getRuntimeEnvironmentId: () => 'windows-box'
|
||||
}
|
||||
})
|
||||
).toBe('win32')
|
||||
})
|
||||
|
||||
it('keeps a live local PTY on the client after worktree ownership changes', () => {
|
||||
expect(
|
||||
resolveTerminalInputHostPlatform({
|
||||
clientPlatform: 'darwin',
|
||||
state: state({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo',
|
||||
path: '/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'runtime:windows-box'
|
||||
}
|
||||
],
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
['windows-box', { status: { hostPlatform: 'win32' } } as never]
|
||||
])
|
||||
}),
|
||||
worktreeId: 'repo::/repo',
|
||||
transport: {
|
||||
getConnectionId: () => null,
|
||||
getPtyId: () => 'local-pty-1',
|
||||
getLocalSessionMetadata: () => ({ cwd: '/repo' })
|
||||
}
|
||||
})
|
||||
).toBe('darwin')
|
||||
})
|
||||
|
||||
it('normalizes a live WSL session to a Linux terminal host', () => {
|
||||
expect(
|
||||
resolveTerminalInputHostPlatform({
|
||||
clientPlatform: 'win32',
|
||||
state: state(),
|
||||
worktreeId: 'repo::C:\\repo',
|
||||
transport: {
|
||||
getConnectionId: () => null,
|
||||
getPtyId: () => 'local-pty-1',
|
||||
getLocalSessionMetadata: () => ({
|
||||
shellOverride: ' "C:\\Windows\\System32\\wsl.exe" -d Ubuntu-24.04'
|
||||
})
|
||||
}
|
||||
})
|
||||
).toBe('linux')
|
||||
})
|
||||
|
||||
it('keeps a live native Windows session after the worktree switches to WSL', () => {
|
||||
expect(
|
||||
resolveTerminalInputHostPlatform({
|
||||
clientPlatform: 'win32',
|
||||
state: state({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo',
|
||||
path: 'C:\\repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'runtime:linux-box'
|
||||
}
|
||||
],
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
['linux-box', { status: { hostPlatform: 'linux' } } as never]
|
||||
])
|
||||
}),
|
||||
worktreeId: 'repo::C:\\repo',
|
||||
transport: {
|
||||
getConnectionId: () => null,
|
||||
getPtyId: () => 'windows-pty-1',
|
||||
getLocalSessionMetadata: () => ({ cwd: 'C:\\repo', shellOverride: 'pwsh.exe' })
|
||||
}
|
||||
})
|
||||
).toBe('win32')
|
||||
})
|
||||
|
||||
it('uses SSH remote platform metadata', () => {
|
||||
expect(
|
||||
resolveTerminalInputHostPlatform({
|
||||
clientPlatform: 'darwin',
|
||||
state: state({
|
||||
sshConnectionStates: new Map([['ssh-win', { remotePlatform: 'win32' } as never]])
|
||||
}),
|
||||
worktreeId: 'repo::C:\\repo',
|
||||
transport: { getConnectionId: () => 'ssh-win' }
|
||||
})
|
||||
).toBe('win32')
|
||||
})
|
||||
|
||||
it('falls back to the client when SSH platform metadata is unavailable', () => {
|
||||
expect(
|
||||
resolveTerminalInputHostPlatform({
|
||||
clientPlatform: 'darwin',
|
||||
state: state({ sshConnectionStates: new Map([['ssh-unknown', {} as never]]) }),
|
||||
worktreeId: 'repo::/repo',
|
||||
transport: { getConnectionId: () => 'ssh-unknown' }
|
||||
})
|
||||
).toBe('darwin')
|
||||
})
|
||||
|
||||
it('falls back to the client when runtime platform metadata is unavailable', () => {
|
||||
expect(
|
||||
resolveTerminalInputHostPlatform({
|
||||
clientPlatform: 'darwin',
|
||||
state: state(),
|
||||
worktreeId: 'repo::/repo',
|
||||
transport: {
|
||||
getConnectionId: () => null,
|
||||
getPtyId: () => 'remote:windows-box@@terminal-1',
|
||||
getRuntimeEnvironmentId: () => 'windows-box'
|
||||
}
|
||||
})
|
||||
).toBe('darwin')
|
||||
})
|
||||
|
||||
it('uses the SSH execution host when the transport has no connection id', () => {
|
||||
const worktreeId = 'repo::C:\\repo'
|
||||
expect(
|
||||
resolveTerminalInputHostPlatform({
|
||||
clientPlatform: 'darwin',
|
||||
state: state({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo',
|
||||
path: 'C:\\repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'ssh:ssh-win'
|
||||
}
|
||||
],
|
||||
sshConnectionStates: new Map([['ssh-win', { remotePlatform: 'win32' } as never]])
|
||||
}),
|
||||
worktreeId,
|
||||
transport: { getConnectionId: () => null }
|
||||
})
|
||||
).toBe('win32')
|
||||
})
|
||||
|
||||
it('keeps the client platform for local terminals', () => {
|
||||
expect(
|
||||
resolveTerminalInputHostPlatform({
|
||||
clientPlatform: 'darwin',
|
||||
state: state(),
|
||||
worktreeId: 'repo::/repo',
|
||||
transport: null
|
||||
})
|
||||
).toBe('darwin')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { isWslUncPath } from '../../../../shared/wsl-paths'
|
||||
import { getConnectionIdFromState } from '@/lib/connection-context'
|
||||
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { PtyTransport } from './pty-transport-types'
|
||||
import { isWslShellOverride } from './terminal-paste-runtime'
|
||||
|
||||
type TerminalInputHostPlatformState = Pick<
|
||||
AppState,
|
||||
| 'repos'
|
||||
| 'worktreesByRepo'
|
||||
| 'folderWorkspaces'
|
||||
| 'projectGroups'
|
||||
| 'settings'
|
||||
| 'sshConnectionStates'
|
||||
| 'runtimeStatusByEnvironmentId'
|
||||
| 'restoredRuntimeHostIdByWorkspaceSessionKey'
|
||||
>
|
||||
|
||||
export function resolveTerminalInputHostPlatform(args: {
|
||||
clientPlatform: NodeJS.Platform
|
||||
state: TerminalInputHostPlatformState
|
||||
worktreeId: string
|
||||
transport:
|
||||
| (Pick<PtyTransport, 'getConnectionId'> &
|
||||
Partial<
|
||||
Pick<PtyTransport, 'getPtyId' | 'getRuntimeEnvironmentId' | 'getLocalSessionMetadata'>
|
||||
>)
|
||||
| null
|
||||
}): NodeJS.Platform {
|
||||
const transportConnectionId = args.transport?.getConnectionId?.()
|
||||
const connectionId =
|
||||
transportConnectionId === undefined
|
||||
? getConnectionIdFromState(args.state, args.worktreeId)
|
||||
: transportConnectionId
|
||||
if (connectionId) {
|
||||
return args.state.sshConnectionStates.get(connectionId)?.remotePlatform ?? args.clientPlatform
|
||||
}
|
||||
|
||||
// Why: a running pane keeps its spawn-time runtime even if the worktree's
|
||||
// selected host changes later, so the live PTY identity is authoritative.
|
||||
const ptyId = args.transport?.getPtyId?.() ?? null
|
||||
const runtimeEnvironmentId =
|
||||
args.transport?.getRuntimeEnvironmentId?.() ??
|
||||
(ptyId ? getRemoteRuntimePtyEnvironmentId(ptyId) : null)
|
||||
if (runtimeEnvironmentId) {
|
||||
return (
|
||||
args.state.runtimeStatusByEnvironmentId.get(runtimeEnvironmentId)?.status?.hostPlatform ??
|
||||
args.clientPlatform
|
||||
)
|
||||
}
|
||||
const localSessionMetadata = args.transport?.getLocalSessionMetadata?.()
|
||||
if (ptyId !== null && localSessionMetadata != null) {
|
||||
const isWslSession =
|
||||
isWslUncPath(localSessionMetadata.cwd ?? '') ||
|
||||
isWslShellOverride(localSessionMetadata.shellOverride)
|
||||
return args.clientPlatform === 'win32' && isWslSession ? 'linux' : args.clientPlatform
|
||||
}
|
||||
|
||||
const host = parseExecutionHostId(getExecutionHostIdForWorktree(args.state, args.worktreeId))
|
||||
if (host?.kind === 'ssh') {
|
||||
return args.state.sshConnectionStates.get(host.targetId)?.remotePlatform ?? args.clientPlatform
|
||||
}
|
||||
if (host?.kind === 'runtime') {
|
||||
return (
|
||||
args.state.runtimeStatusByEnvironmentId.get(host.environmentId)?.status?.hostPlatform ??
|
||||
args.clientPlatform
|
||||
)
|
||||
}
|
||||
return args.clientPlatform
|
||||
}
|
||||
@@ -85,7 +85,7 @@ function resolveWslRuntimeKey(
|
||||
return null
|
||||
}
|
||||
|
||||
function isWslShellOverride(shellOverride: string | null | undefined): boolean {
|
||||
export function isWslShellOverride(shellOverride: string | null | undefined): boolean {
|
||||
const executable = getShellOverrideExecutableToken(shellOverride)
|
||||
const segmentStart = getShellOverridePathSegmentStart(executable)
|
||||
const name = executable.slice(segmentStart).toLowerCase()
|
||||
|
||||
@@ -139,28 +139,24 @@ describe('resolveTerminalShortcutAction', () => {
|
||||
).toEqual({ type: 'sendInput', data: '\x1b[13;2u' })
|
||||
})
|
||||
|
||||
it('preserves the Windows fallback for SSH, WSL, and remote panes', () => {
|
||||
// Why: a Windows client cannot safely infer a remote peer's decoder, so the
|
||||
// local Droid exception must not broaden main's existing remote behavior.
|
||||
const isLocalWindowsConptyPane = vi.fn(() => false)
|
||||
it('uses CSI-u for a non-Windows PTY reached from Windows only while Kitty is active', () => {
|
||||
const getWindowsShiftEnterEncoding = vi.fn(() => 'csi-u' as const)
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
expect(
|
||||
resolveTerminalShortcutAction(
|
||||
event({ key: 'Enter', code: 'Enter', shiftKey: true }),
|
||||
false,
|
||||
'false',
|
||||
0,
|
||||
true,
|
||||
undefined,
|
||||
isLocalWindowsConptyPane,
|
||||
undefined,
|
||||
undefined,
|
||||
getWindowsShiftEnterEncoding
|
||||
)
|
||||
).toEqual({ type: 'sendInput', data: '\x1b\r' })
|
||||
}
|
||||
expect(isLocalWindowsConptyPane).toHaveBeenCalledTimes(2)
|
||||
const resolve = (kittyActive: boolean) =>
|
||||
resolveTerminalShortcutAction(
|
||||
event({ key: 'Enter', code: 'Enter', shiftKey: true }),
|
||||
false,
|
||||
'false',
|
||||
0,
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
() => kittyActive,
|
||||
undefined,
|
||||
getWindowsShiftEnterEncoding,
|
||||
() => false
|
||||
)
|
||||
expect(resolve(true)).toEqual({ type: 'sendInput', data: '\x1b[13;2u' })
|
||||
expect(resolve(false)).toEqual({ type: 'sendInput', data: '\x1b\r' })
|
||||
expect(getWindowsShiftEnterEncoding).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -184,9 +180,11 @@ describe('resolveTerminalShortcutAction', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps ConPTY and agent lookups off unrelated keystrokes', () => {
|
||||
it('keeps host and agent lookups off unrelated keystrokes', () => {
|
||||
const isLocalWindowsConptyPane = vi.fn(() => true)
|
||||
const isKittyKeyboardActivePane = vi.fn(() => true)
|
||||
const getWindowsShiftEnterEncoding = vi.fn(() => 'csi-u' as const)
|
||||
const isWindowsTerminalHost = vi.fn(() => true)
|
||||
|
||||
expect(
|
||||
resolveTerminalShortcutAction(
|
||||
@@ -197,13 +195,16 @@ describe('resolveTerminalShortcutAction', () => {
|
||||
true,
|
||||
undefined,
|
||||
isLocalWindowsConptyPane,
|
||||
isKittyKeyboardActivePane,
|
||||
undefined,
|
||||
undefined,
|
||||
getWindowsShiftEnterEncoding
|
||||
getWindowsShiftEnterEncoding,
|
||||
isWindowsTerminalHost
|
||||
)
|
||||
).toBeNull()
|
||||
expect(isLocalWindowsConptyPane).not.toHaveBeenCalled()
|
||||
expect(getWindowsShiftEnterEncoding).not.toHaveBeenCalled()
|
||||
expect(isWindowsTerminalHost).not.toHaveBeenCalled()
|
||||
expect(isKittyKeyboardActivePane).not.toHaveBeenCalled()
|
||||
|
||||
expect(
|
||||
resolveTerminalShortcutAction(
|
||||
@@ -214,15 +215,18 @@ describe('resolveTerminalShortcutAction', () => {
|
||||
true,
|
||||
undefined,
|
||||
isLocalWindowsConptyPane,
|
||||
isKittyKeyboardActivePane,
|
||||
undefined,
|
||||
undefined,
|
||||
getWindowsShiftEnterEncoding
|
||||
getWindowsShiftEnterEncoding,
|
||||
isWindowsTerminalHost
|
||||
)
|
||||
).toEqual({ type: 'sendInput', data: '\x1b[13;2u' })
|
||||
expect(isLocalWindowsConptyPane).toHaveBeenCalledTimes(1)
|
||||
expect(isLocalWindowsConptyPane).not.toHaveBeenCalled()
|
||||
expect(getWindowsShiftEnterEncoding).toHaveBeenCalledTimes(1)
|
||||
expect(isWindowsTerminalHost).toHaveBeenCalledTimes(1)
|
||||
expect(isKittyKeyboardActivePane).not.toHaveBeenCalled()
|
||||
|
||||
isLocalWindowsConptyPane.mockReturnValue(false)
|
||||
isWindowsTerminalHost.mockReturnValue(false)
|
||||
expect(
|
||||
resolveTerminalShortcutAction(
|
||||
event({ key: 'Enter', code: 'Enter', shiftKey: true }),
|
||||
@@ -232,13 +236,37 @@ describe('resolveTerminalShortcutAction', () => {
|
||||
true,
|
||||
undefined,
|
||||
isLocalWindowsConptyPane,
|
||||
isKittyKeyboardActivePane,
|
||||
undefined,
|
||||
undefined,
|
||||
getWindowsShiftEnterEncoding
|
||||
getWindowsShiftEnterEncoding,
|
||||
isWindowsTerminalHost
|
||||
)
|
||||
).toEqual({ type: 'sendInput', data: '\x1b\r' })
|
||||
expect(isLocalWindowsConptyPane).toHaveBeenCalledTimes(2)
|
||||
).toEqual({ type: 'sendInput', data: '\x1b[13;2u' })
|
||||
expect(isLocalWindowsConptyPane).not.toHaveBeenCalled()
|
||||
expect(getWindowsShiftEnterEncoding).toHaveBeenCalledTimes(1)
|
||||
expect(isWindowsTerminalHost).toHaveBeenCalledTimes(2)
|
||||
expect(isKittyKeyboardActivePane).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('honors Kitty negotiation for a Windows PTY reached from macOS', () => {
|
||||
const getWindowsShiftEnterEncoding = vi.fn(() => 'alt-enter' as const)
|
||||
const resolve = (kittyActive: boolean) =>
|
||||
resolveTerminalShortcutAction(
|
||||
event({ key: 'Enter', code: 'Enter', shiftKey: true }),
|
||||
true,
|
||||
'false',
|
||||
0,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
() => kittyActive,
|
||||
undefined,
|
||||
getWindowsShiftEnterEncoding,
|
||||
() => true
|
||||
)
|
||||
expect(resolve(true)).toEqual({ type: 'sendInput', data: '\x1b[13;2u' })
|
||||
expect(resolve(false)).toEqual({ type: 'sendInput', data: '\x1b\r' })
|
||||
expect(getWindowsShiftEnterEncoding).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('forwards Ctrl+Enter as the kitty CSI-u chord so TUIs can cue instead of send', () => {
|
||||
|
||||
@@ -76,8 +76,8 @@ export function resolveTerminalShortcutAction(
|
||||
isWindows: boolean = false,
|
||||
keybindings?: KeybindingOverrides,
|
||||
// Why: lazily reports whether the active pane is a local native Windows
|
||||
// ConPTY. Only consulted for Shift+Enter and Ctrl+Arrow, so execution-host
|
||||
// lookup stays off every other keystroke.
|
||||
// ConPTY. Only consulted for Ctrl+Arrow, so execution-host lookup stays off
|
||||
// every other keystroke.
|
||||
isLocalWindowsConptyPane?: () => boolean,
|
||||
// Why: lazily reports whether the active pane's application has enabled the
|
||||
// kitty keyboard protocol (CSI > u). Gates the Option-as-Alt compensation
|
||||
@@ -90,7 +90,10 @@ export function resolveTerminalShortcutAction(
|
||||
layoutBaseCharacterForCode?: (code: string) => string | undefined,
|
||||
// Why: lazily resolves the active pane's Windows encoding. Only consulted for
|
||||
// Shift+Enter so agent-state lookup stays off every other keystroke.
|
||||
getWindowsShiftEnterEncoding?: () => WindowsShiftEnterEncoding
|
||||
getWindowsShiftEnterEncoding?: () => WindowsShiftEnterEncoding,
|
||||
// Why: keybindings follow the client OS, but terminal byte protocols follow
|
||||
// the PTY host. They differ for macOS clients attached to Windows runtimes.
|
||||
isWindowsTerminalHost: () => boolean = () => isWindows
|
||||
): TerminalShortcutAction | null {
|
||||
const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux'
|
||||
if (!event.repeat) {
|
||||
@@ -150,18 +153,13 @@ export function resolveTerminalShortcutAction(
|
||||
event.shiftKey &&
|
||||
event.key === 'Enter'
|
||||
) {
|
||||
// Why: Droid needs CSI-u but Codex needs Esc+CR; preserve legacy bytes for
|
||||
// SSH/WSL/remote peers that cannot be safely classified from this client.
|
||||
const useLocalWindowsCapability = isWindows && isLocalWindowsConptyPane?.() !== false
|
||||
const encoding = useLocalWindowsCapability
|
||||
? (getWindowsShiftEnterEncoding?.() ?? 'alt-enter')
|
||||
: isWindows
|
||||
? 'alt-enter'
|
||||
: 'csi-u'
|
||||
// Why: CSI-u is application input, not a universal terminal sequence. Off
|
||||
// Windows, only send it while the pane's application has KKP active.
|
||||
const canSendCsiU =
|
||||
encoding === 'csi-u' && (useLocalWindowsCapability || isKittyKeyboardActivePane?.() === true)
|
||||
// Why: negotiated KKP is authoritative on every host; trusted pane
|
||||
// evidence additionally preserves Droid's Windows encoding without KKP.
|
||||
const windowsHost = isWindowsTerminalHost()
|
||||
const hasTrustedWindowsCsiU = windowsHost && getWindowsShiftEnterEncoding?.() === 'csi-u'
|
||||
// Why: CSI-u is application input, not a universal terminal sequence.
|
||||
// Without trusted Windows agent evidence, require active KKP negotiation.
|
||||
const canSendCsiU = hasTrustedWindowsCsiU || isKittyKeyboardActivePane?.() === true
|
||||
return { type: 'sendInput', data: canSendCsiU ? '\x1b[13;2u' : '\x1b\r' }
|
||||
}
|
||||
|
||||
|
||||
+137
@@ -90,4 +90,141 @@ describe('workspace cleanup late settlement reconciliation', () => {
|
||||
})
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports later child settlements while a post-batch ancestor retry is stalled', async () => {
|
||||
const firstParent = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/a',
|
||||
displayName: 'first parent',
|
||||
branch: 'first-parent',
|
||||
path: '/repo/a'
|
||||
})
|
||||
const firstChild = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/a/child-long',
|
||||
displayName: 'first child',
|
||||
branch: 'first-child',
|
||||
path: '/repo/a/child-long'
|
||||
})
|
||||
const secondParent = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/bb',
|
||||
displayName: 'second parent',
|
||||
branch: 'second-parent',
|
||||
path: '/repo/bb'
|
||||
})
|
||||
const secondChild = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/bb/c',
|
||||
displayName: 'second child',
|
||||
branch: 'second-child',
|
||||
path: '/repo/bb/c'
|
||||
})
|
||||
let resolveFirstChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
|
||||
let resolveSecondChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
|
||||
const removeCandidates = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
|
||||
resolveFirstChild = resolve
|
||||
})
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
|
||||
resolveSecondChild = resolve
|
||||
})
|
||||
)
|
||||
.mockImplementationOnce(() => new Promise(() => {}))
|
||||
.mockResolvedValueOnce({ removedIds: [secondParent.worktreeId], failures: [] })
|
||||
const onLateResult = vi.fn()
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [firstParent, firstChild, secondParent, secondChild],
|
||||
removeCandidates,
|
||||
onProgress: vi.fn(),
|
||||
onResult: vi.fn(),
|
||||
onLateResult,
|
||||
removalTimeoutMs: 5,
|
||||
removalSettlementGraceMs: 5
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await settleBackgroundRemoval()
|
||||
resolveFirstChild({ removedIds: [firstChild.worktreeId], failures: [] })
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenNthCalledWith(3, [firstParent.worktreeId], {
|
||||
approvedCandidates: [firstParent]
|
||||
})
|
||||
expect(onLateResult).toHaveBeenNthCalledWith(1, {
|
||||
removedIds: [firstChild.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
|
||||
resolveSecondChild({ removedIds: [secondChild.worktreeId], failures: [] })
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(onLateResult).toHaveBeenNthCalledWith(2, {
|
||||
removedIds: [secondChild.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
expect(removeCandidates).toHaveBeenCalledTimes(3)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenNthCalledWith(4, [secondParent.worktreeId], {
|
||||
approvedCandidates: [secondParent]
|
||||
})
|
||||
expect(onLateResult).toHaveBeenCalledWith({
|
||||
removedIds: [secondParent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
expect(toast.info).toHaveBeenCalledWith('Still removing workspaces: 1')
|
||||
})
|
||||
|
||||
it('retries an unblocked ancestor when immediate late-result reporting throws', async () => {
|
||||
const parent = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/parent',
|
||||
displayName: 'parent',
|
||||
branch: 'parent',
|
||||
path: '/repo/parent'
|
||||
})
|
||||
const child = makeCandidate({
|
||||
worktreeId: 'repo-1::/repo/parent/child',
|
||||
displayName: 'child',
|
||||
branch: 'child',
|
||||
path: '/repo/parent/child'
|
||||
})
|
||||
let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {}
|
||||
const removeCandidates = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ removedIds: string[]; failures: [] }>((resolve) => {
|
||||
resolveChild = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] })
|
||||
vi.mocked(toast.success).mockImplementationOnce(() => {
|
||||
throw new Error('toast renderer failed')
|
||||
})
|
||||
|
||||
startWorkspaceCleanupBackgroundRemoval({
|
||||
candidates: [parent, child],
|
||||
removeCandidates,
|
||||
onProgress: vi.fn(),
|
||||
onResult: vi.fn(),
|
||||
onLateResult: vi.fn(),
|
||||
removalTimeoutMs: 5,
|
||||
removalSettlementGraceMs: 5
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await settleBackgroundRemoval()
|
||||
resolveChild({ removedIds: [child.worktreeId], failures: [] })
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], {
|
||||
approvedCandidates: [parent]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+14
-5
@@ -510,11 +510,15 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
approvedCandidates: [parent]
|
||||
})
|
||||
expect(onResult).toHaveBeenCalledTimes(1)
|
||||
expect(onLateResult).toHaveBeenCalledWith({
|
||||
removedIds: [child.worktreeId, parent.worktreeId],
|
||||
expect(onLateResult).toHaveBeenNthCalledWith(1, {
|
||||
removedIds: [child.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
expect(toast.success).toHaveBeenLastCalledWith('Removed workspaces: 2')
|
||||
expect(onLateResult).toHaveBeenNthCalledWith(2, {
|
||||
removedIds: [parent.worktreeId],
|
||||
failures: []
|
||||
})
|
||||
expect(toast.success).toHaveBeenLastCalledWith('Removed workspaces: 1')
|
||||
})
|
||||
|
||||
it('hardens a provisional parent skip after the child late-fails post-batch', async () => {
|
||||
@@ -554,13 +558,18 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => {
|
||||
rejectChild(new Error('remote removal failed'))
|
||||
await settleBackgroundRemoval()
|
||||
|
||||
expect(onLateResult).toHaveBeenCalledWith({
|
||||
expect(onLateResult).toHaveBeenNthCalledWith(1, {
|
||||
removedIds: [],
|
||||
failures: [
|
||||
expect.objectContaining({
|
||||
worktreeId: child.worktreeId,
|
||||
message: 'remote removal failed'
|
||||
}),
|
||||
})
|
||||
]
|
||||
})
|
||||
expect(onLateResult).toHaveBeenNthCalledWith(2, {
|
||||
removedIds: [],
|
||||
failures: [
|
||||
expect.objectContaining({
|
||||
worktreeId: parent.worktreeId,
|
||||
message: 'Skipped because a nested workspace could not be removed.'
|
||||
|
||||
+40
-56
@@ -11,11 +11,13 @@ import {
|
||||
isStrictWorkspaceCleanupDescendant,
|
||||
type SkippedWorkspaceCleanupAncestor
|
||||
} from './workspace-cleanup-ancestor-skips'
|
||||
import { reconcilePostBatchLateSettlement } from './workspace-cleanup-post-batch-late-settlement'
|
||||
import { createPostBatchLateSettlementReporter } from './workspace-cleanup-post-batch-late-settlement'
|
||||
import {
|
||||
getWorkspaceCleanupTimeoutFailure,
|
||||
trackWorkspaceCleanupLateSettlement,
|
||||
waitForWorkspaceCleanupRemovalWithTimeout
|
||||
waitForWorkspaceCleanupRemovalWithTimeout,
|
||||
type WorkspaceCleanupLateSettlementReporter,
|
||||
type WorkspaceCleanupLateSettlementTracker
|
||||
} from './workspace-cleanup-removal-settlement'
|
||||
import { showWorkspaceCleanupRemovalResultToasts } from './workspace-cleanup-removal-toasts'
|
||||
import { reclassifySkippedWorkspaceCleanupAncestors } from './workspace-cleanup-skipped-ancestor-reclassification'
|
||||
@@ -74,15 +76,12 @@ export function startWorkspaceCleanupBackgroundRemoval({
|
||||
const removedIds: string[] = []
|
||||
const failures: WorkspaceCleanupFailure[] = []
|
||||
const failedCandidates: WorkspaceCleanupCandidate[] = []
|
||||
const detachLateResultReconcilers: (() => void)[] = []
|
||||
const lateSettlementTrackers: WorkspaceCleanupLateSettlementTracker[] = []
|
||||
// Why: rows past the removal deadline are still removing; their skip fallout
|
||||
// and batch toasts must not present them as definitive failures.
|
||||
const provisionallyBlocked = new Set<WorkspaceCleanupCandidate>()
|
||||
const pendingSettlementFailures = new Set<WorkspaceCleanupFailure>()
|
||||
const skippedAncestors: SkippedWorkspaceCleanupAncestor[] = []
|
||||
// Why: serialize post-batch late reconciles so concurrent child settlements
|
||||
// do not interleave ancestor reclassification and retries.
|
||||
let postBatchLateReconcileChain: Promise<void> = Promise.resolve()
|
||||
let processedCount = 0
|
||||
|
||||
const emitProgress = (): void => {
|
||||
@@ -105,9 +104,13 @@ export function startWorkspaceCleanupBackgroundRemoval({
|
||||
}
|
||||
}
|
||||
|
||||
const detachAllLateResultReconcilers = (): void => {
|
||||
for (const detach of detachLateResultReconcilers) {
|
||||
detach()
|
||||
const detachAllLateResultReconcilers = (
|
||||
getReportAfterBatchResult?: (
|
||||
candidate: WorkspaceCleanupLateSettlementTracker['candidate']
|
||||
) => WorkspaceCleanupLateSettlementReporter
|
||||
): void => {
|
||||
for (const tracker of lateSettlementTrackers) {
|
||||
tracker.detach(getReportAfterBatchResult?.(tracker.candidate))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,31 +164,6 @@ export function startWorkspaceCleanupBackgroundRemoval({
|
||||
}
|
||||
}
|
||||
|
||||
const enqueuePostBatchLateSettlement = (
|
||||
settledCandidate: WorkspaceCleanupCandidate,
|
||||
timeoutFailure: WorkspaceCleanupFailure,
|
||||
lateResult: WorkspaceCleanupRemoveResult
|
||||
): void => {
|
||||
postBatchLateReconcileChain = postBatchLateReconcileChain
|
||||
.then(async () => {
|
||||
const reconciled = await reconcilePostBatchLateSettlement({
|
||||
lateResult,
|
||||
settledCandidate,
|
||||
timeoutFailure,
|
||||
skippedAncestors,
|
||||
failedCandidates,
|
||||
failures,
|
||||
provisionallyBlocked,
|
||||
pendingSettlementFailures,
|
||||
removeCandidates
|
||||
})
|
||||
reportLateWorkspaceCleanupResult(reconciled, onLateResult)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('Workspace cleanup post-batch late settlement failed', error)
|
||||
})
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
while (queue.length > 0) {
|
||||
const candidate = queue.shift()
|
||||
@@ -216,26 +194,19 @@ export function startWorkspaceCleanupBackgroundRemoval({
|
||||
// its authoritative settlement without unblocking ancestors early.
|
||||
// After the batch ends, detach switches to the post-batch path which
|
||||
// still holds skip state so ancestors can reclassify.
|
||||
detachLateResultReconcilers.push(
|
||||
trackWorkspaceCleanupLateSettlement(
|
||||
outcome.settlement,
|
||||
candidate,
|
||||
(lateResult) => {
|
||||
removeArrayEntry(failures, timeoutFailure)
|
||||
pendingSettlementFailures.delete(timeoutFailure)
|
||||
provisionallyBlocked.delete(candidate)
|
||||
removedIds.push(...lateResult.removedIds)
|
||||
reportFailures(lateResult.failures)
|
||||
if (lateResult.failures.length === 0) {
|
||||
removeArrayEntry(failedCandidates, candidate)
|
||||
}
|
||||
resettleSkippedAncestors()
|
||||
emitProgress()
|
||||
},
|
||||
(lateResult) => {
|
||||
enqueuePostBatchLateSettlement(candidate, timeoutFailure, lateResult)
|
||||
lateSettlementTrackers.push(
|
||||
trackWorkspaceCleanupLateSettlement(outcome.settlement, candidate, (lateResult) => {
|
||||
removeArrayEntry(failures, timeoutFailure)
|
||||
pendingSettlementFailures.delete(timeoutFailure)
|
||||
provisionallyBlocked.delete(candidate)
|
||||
removedIds.push(...lateResult.removedIds)
|
||||
reportFailures(lateResult.failures)
|
||||
if (lateResult.failures.length === 0) {
|
||||
removeArrayEntry(failedCandidates, candidate)
|
||||
}
|
||||
)
|
||||
resettleSkippedAncestors()
|
||||
emitProgress()
|
||||
})
|
||||
)
|
||||
continue
|
||||
}
|
||||
@@ -260,7 +231,19 @@ export function startWorkspaceCleanupBackgroundRemoval({
|
||||
}
|
||||
}
|
||||
|
||||
detachAllLateResultReconcilers()
|
||||
detachAllLateResultReconcilers(
|
||||
createPostBatchLateSettlementReporter({
|
||||
skippedAncestors,
|
||||
failedCandidates,
|
||||
provisionallyBlocked,
|
||||
removeCandidates,
|
||||
removalTimeoutMs,
|
||||
removalSettlementGraceMs,
|
||||
reportResult: (lateResult, latePendingFailures) => {
|
||||
reportLateWorkspaceCleanupResult(lateResult, onLateResult, latePendingFailures)
|
||||
}
|
||||
})
|
||||
)
|
||||
const result = { removedIds, failures }
|
||||
try {
|
||||
onResult?.(result)
|
||||
@@ -286,14 +269,15 @@ export function startWorkspaceCleanupBackgroundRemoval({
|
||||
|
||||
function reportLateWorkspaceCleanupResult(
|
||||
result: WorkspaceCleanupRemoveResult,
|
||||
onLateResult: WorkspaceCleanupBackgroundRemovalArgs['onLateResult']
|
||||
onLateResult: WorkspaceCleanupBackgroundRemovalArgs['onLateResult'],
|
||||
pendingSettlementFailures?: ReadonlySet<WorkspaceCleanupFailure>
|
||||
): void {
|
||||
try {
|
||||
onLateResult?.(result)
|
||||
} catch (callbackError) {
|
||||
console.error('Workspace cleanup late result callback failed', callbackError)
|
||||
}
|
||||
showWorkspaceCleanupRemovalResultToasts(result)
|
||||
showWorkspaceCleanupRemovalResultToasts(result, pendingSettlementFailures)
|
||||
}
|
||||
|
||||
function removeArrayEntry<T>(entries: T[], entry: T): void {
|
||||
|
||||
+187
-44
@@ -9,59 +9,148 @@ import {
|
||||
isStrictWorkspaceCleanupDescendant,
|
||||
type SkippedWorkspaceCleanupAncestor
|
||||
} from './workspace-cleanup-ancestor-skips'
|
||||
import {
|
||||
getWorkspaceCleanupTimeoutFailure,
|
||||
trackWorkspaceCleanupLateSettlement,
|
||||
waitForWorkspaceCleanupRemovalWithTimeout,
|
||||
type WorkspaceCleanupLateSettlementCandidate,
|
||||
type WorkspaceCleanupLateSettlementReporter
|
||||
} from './workspace-cleanup-removal-settlement'
|
||||
import { reclassifySkippedWorkspaceCleanupAncestors } from './workspace-cleanup-skipped-ancestor-reclassification'
|
||||
|
||||
export type PostBatchLateSettlementArgs = {
|
||||
lateResult: WorkspaceCleanupRemoveResult
|
||||
settledCandidate: WorkspaceCleanupCandidate
|
||||
timeoutFailure: WorkspaceCleanupFailure
|
||||
type WorkspaceCleanupRemoveCandidates = (
|
||||
worktreeIds: readonly string[],
|
||||
options?: WorkspaceCleanupRemoveOptions
|
||||
) => Promise<WorkspaceCleanupRemoveResult>
|
||||
|
||||
type PostBatchLateSettlementState = {
|
||||
skippedAncestors: SkippedWorkspaceCleanupAncestor[]
|
||||
failedCandidates: WorkspaceCleanupCandidate[]
|
||||
failures: WorkspaceCleanupFailure[]
|
||||
provisionallyBlocked: Set<WorkspaceCleanupCandidate>
|
||||
pendingSettlementFailures: Set<WorkspaceCleanupFailure>
|
||||
removeCandidates: (
|
||||
worktreeIds: readonly string[],
|
||||
options?: WorkspaceCleanupRemoveOptions
|
||||
) => Promise<WorkspaceCleanupRemoveResult>
|
||||
removeCandidates: WorkspaceCleanupRemoveCandidates | null
|
||||
removalTimeoutMs: number
|
||||
removalSettlementGraceMs: number
|
||||
}
|
||||
|
||||
// Why: once the batch detaches its mid-loop reconcilers, late child results must
|
||||
// still reclassify ancestors and retry unblocked ones so rowFailures do not keep
|
||||
// the provisional skip after the blocker settles.
|
||||
export async function reconcilePostBatchLateSettlement({
|
||||
lateResult,
|
||||
settledCandidate,
|
||||
timeoutFailure,
|
||||
export type PostBatchLateSettlementResultReporter = (
|
||||
result: WorkspaceCleanupRemoveResult,
|
||||
pendingSettlementFailures?: ReadonlySet<WorkspaceCleanupFailure>
|
||||
) => void
|
||||
|
||||
export type PostBatchLateSettlementReporterSelector = (
|
||||
candidate: WorkspaceCleanupLateSettlementCandidate
|
||||
) => WorkspaceCleanupLateSettlementReporter
|
||||
|
||||
export function createPostBatchLateSettlementReporter({
|
||||
skippedAncestors,
|
||||
failedCandidates,
|
||||
failures,
|
||||
provisionallyBlocked,
|
||||
pendingSettlementFailures,
|
||||
removeCandidates
|
||||
}: PostBatchLateSettlementArgs): Promise<WorkspaceCleanupRemoveResult> {
|
||||
removeArrayEntry(failures, timeoutFailure)
|
||||
pendingSettlementFailures.delete(timeoutFailure)
|
||||
provisionallyBlocked.delete(settledCandidate)
|
||||
if (lateResult.failures.length === 0) {
|
||||
removeArrayEntry(failedCandidates, settledCandidate)
|
||||
removeCandidates,
|
||||
removalTimeoutMs,
|
||||
removalSettlementGraceMs,
|
||||
reportResult
|
||||
}: {
|
||||
skippedAncestors: readonly SkippedWorkspaceCleanupAncestor[]
|
||||
failedCandidates: readonly WorkspaceCleanupCandidate[]
|
||||
provisionallyBlocked: ReadonlySet<WorkspaceCleanupCandidate>
|
||||
removeCandidates: WorkspaceCleanupRemoveCandidates
|
||||
removalTimeoutMs: number
|
||||
removalSettlementGraceMs: number
|
||||
reportResult: PostBatchLateSettlementResultReporter
|
||||
}): PostBatchLateSettlementReporterSelector {
|
||||
// Why: detached IPC promises can outlive the dialog; retain only unresolved
|
||||
// blockers and provisional skips, not the completed batch's result arrays.
|
||||
const retainedSkippedAncestors = skippedAncestors
|
||||
.filter((entry) => entry.provisional)
|
||||
.map((entry) => ({ ...entry, failure: { ...entry.failure } }))
|
||||
const retainedCandidates = failedCandidates.filter(
|
||||
(candidate) =>
|
||||
provisionallyBlocked.has(candidate) &&
|
||||
retainedSkippedAncestors.some(
|
||||
(entry) =>
|
||||
entry.candidate.worktreeId === candidate.worktreeId ||
|
||||
isStrictWorkspaceCleanupDescendant(entry.candidate, candidate)
|
||||
)
|
||||
)
|
||||
const state: PostBatchLateSettlementState = {
|
||||
skippedAncestors: retainedSkippedAncestors,
|
||||
failedCandidates: retainedCandidates,
|
||||
failures: retainedSkippedAncestors.map((entry) => entry.failure),
|
||||
provisionallyBlocked: new Set(retainedCandidates),
|
||||
removeCandidates,
|
||||
removalTimeoutMs,
|
||||
removalSettlementGraceMs
|
||||
}
|
||||
let reconcileChain: Promise<void> = Promise.resolve()
|
||||
const reportLateSettlement: WorkspaceCleanupLateSettlementReporter = (candidate, result) => {
|
||||
// Why: queue reconciliation before reporting so a presentation-layer error
|
||||
// cannot suppress ancestor retries; the report still runs synchronously.
|
||||
reconcileChain = reconcileChain
|
||||
.then(async () => {
|
||||
const reconciled = await reconcilePostBatchLateSettlement(
|
||||
state,
|
||||
candidate,
|
||||
result,
|
||||
reportLateSettlement
|
||||
)
|
||||
if (reconciled.result.removedIds.length > 0 || reconciled.result.failures.length > 0) {
|
||||
reportResult(reconciled.result, reconciled.pendingSettlementFailures)
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('Workspace cleanup post-batch late settlement failed', error)
|
||||
})
|
||||
reportResult(result)
|
||||
}
|
||||
const retainedCandidateIds = new Set(retainedCandidates.map((candidate) => candidate.worktreeId))
|
||||
const reportWithoutReconciliation: WorkspaceCleanupLateSettlementReporter = (
|
||||
_candidate,
|
||||
result
|
||||
) => {
|
||||
reportResult(result)
|
||||
}
|
||||
return (candidate) =>
|
||||
retainedCandidateIds.has(candidate.worktreeId)
|
||||
? reportLateSettlement
|
||||
: reportWithoutReconciliation
|
||||
}
|
||||
|
||||
async function reconcilePostBatchLateSettlement(
|
||||
state: PostBatchLateSettlementState,
|
||||
settledCandidateIdentity: WorkspaceCleanupLateSettlementCandidate,
|
||||
lateResult: WorkspaceCleanupRemoveResult,
|
||||
reportLateSettlement: WorkspaceCleanupLateSettlementReporter
|
||||
): Promise<{
|
||||
result: WorkspaceCleanupRemoveResult
|
||||
pendingSettlementFailures?: ReadonlySet<WorkspaceCleanupFailure>
|
||||
}> {
|
||||
const settledCandidate = state.failedCandidates.find(
|
||||
(candidate) => candidate.worktreeId === settledCandidateIdentity.worktreeId
|
||||
)
|
||||
if (settledCandidate) {
|
||||
state.provisionallyBlocked.delete(settledCandidate)
|
||||
if (lateResult.failures.length === 0) {
|
||||
removeArrayEntry(state.failedCandidates, settledCandidate)
|
||||
}
|
||||
}
|
||||
|
||||
const removedIds = [...lateResult.removedIds]
|
||||
const lateFailures = [...lateResult.failures]
|
||||
const removedIds: string[] = []
|
||||
const lateFailures: WorkspaceCleanupFailure[] = []
|
||||
const pendingSettlementFailures = new Set<WorkspaceCleanupFailure>()
|
||||
const findBlockingDescendants = (
|
||||
candidate: WorkspaceCleanupCandidate
|
||||
): WorkspaceCleanupCandidate[] =>
|
||||
failedCandidates.filter((failedCandidate) =>
|
||||
state.failedCandidates.filter((failedCandidate) =>
|
||||
isStrictWorkspaceCleanupDescendant(candidate, failedCandidate)
|
||||
)
|
||||
|
||||
const { unblocked, updatedFailures } = reclassifySkippedWorkspaceCleanupAncestors({
|
||||
skippedAncestors,
|
||||
skippedAncestors: state.skippedAncestors,
|
||||
findBlockingDescendants,
|
||||
provisionallyBlocked,
|
||||
failedCandidates,
|
||||
failures
|
||||
provisionallyBlocked: state.provisionallyBlocked,
|
||||
failedCandidates: state.failedCandidates,
|
||||
failures: state.failures
|
||||
})
|
||||
lateFailures.push(...updatedFailures)
|
||||
|
||||
@@ -71,41 +160,95 @@ export async function reconcilePostBatchLateSettlement({
|
||||
for (const ancestor of unblocked) {
|
||||
const blockers = findBlockingDescendants(ancestor)
|
||||
if (blockers.length > 0) {
|
||||
const provisional = blockers.every((blocker) => provisionallyBlocked.has(blocker))
|
||||
const provisional = blockers.every((blocker) => state.provisionallyBlocked.has(blocker))
|
||||
const failure: WorkspaceCleanupFailure = {
|
||||
worktreeId: ancestor.worktreeId,
|
||||
displayName: ancestor.displayName,
|
||||
message: getSkippedAncestorMessage(provisional)
|
||||
}
|
||||
if (provisional) {
|
||||
provisionallyBlocked.add(ancestor)
|
||||
state.provisionallyBlocked.add(ancestor)
|
||||
}
|
||||
failedCandidates.push(ancestor)
|
||||
skippedAncestors.push({ candidate: ancestor, failure, provisional })
|
||||
state.failedCandidates.push(ancestor)
|
||||
state.skippedAncestors.push({ candidate: ancestor, failure, provisional })
|
||||
state.failures.push(failure)
|
||||
lateFailures.push(failure)
|
||||
continue
|
||||
}
|
||||
|
||||
const removeCandidates = state.removeCandidates
|
||||
if (!removeCandidates) {
|
||||
continue
|
||||
}
|
||||
let removal: Promise<WorkspaceCleanupRemoveResult>
|
||||
try {
|
||||
const result = await removeCandidates([ancestor.worktreeId], {
|
||||
removal = removeCandidates([ancestor.worktreeId], {
|
||||
approvedCandidates: [ancestor]
|
||||
})
|
||||
removedIds.push(...result.removedIds)
|
||||
if (result.failures.length > 0) {
|
||||
failedCandidates.push(ancestor)
|
||||
lateFailures.push(...result.failures)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
failedCandidates.push(ancestor)
|
||||
state.failedCandidates.push(ancestor)
|
||||
lateFailures.push({
|
||||
worktreeId: ancestor.worktreeId,
|
||||
displayName: ancestor.displayName,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
continue
|
||||
}
|
||||
const outcome = await waitForWorkspaceCleanupRemovalWithTimeout(
|
||||
removal,
|
||||
state.removalTimeoutMs,
|
||||
state.removalSettlementGraceMs
|
||||
)
|
||||
if (outcome.status === 'unresolved') {
|
||||
const timeoutFailure = getWorkspaceCleanupTimeoutFailure(ancestor)
|
||||
state.failedCandidates.push(ancestor)
|
||||
state.provisionallyBlocked.add(ancestor)
|
||||
lateFailures.push(timeoutFailure)
|
||||
pendingSettlementFailures.add(timeoutFailure)
|
||||
const tracker = trackWorkspaceCleanupLateSettlement(outcome.settlement, ancestor, () => {})
|
||||
tracker.detach(reportLateSettlement)
|
||||
continue
|
||||
}
|
||||
const result =
|
||||
outcome.status === 'fulfilled'
|
||||
? outcome.result
|
||||
: {
|
||||
removedIds: [],
|
||||
failures: [
|
||||
{
|
||||
worktreeId: ancestor.worktreeId,
|
||||
displayName: ancestor.displayName,
|
||||
message:
|
||||
outcome.error instanceof Error ? outcome.error.message : String(outcome.error)
|
||||
}
|
||||
]
|
||||
}
|
||||
removedIds.push(...result.removedIds)
|
||||
if (result.failures.length > 0) {
|
||||
state.failedCandidates.push(ancestor)
|
||||
lateFailures.push(...result.failures)
|
||||
}
|
||||
}
|
||||
|
||||
return { removedIds, failures: lateFailures }
|
||||
releaseSettledPostBatchState(state)
|
||||
return {
|
||||
result: { removedIds, failures: lateFailures },
|
||||
pendingSettlementFailures:
|
||||
pendingSettlementFailures.size > 0 ? pendingSettlementFailures : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function releaseSettledPostBatchState(state: PostBatchLateSettlementState): void {
|
||||
if (state.skippedAncestors.some((entry) => entry.provisional)) {
|
||||
return
|
||||
}
|
||||
// Why: once no provisional ancestor can change, remaining late settlements
|
||||
// only report themselves and must not pin removal or candidate state.
|
||||
state.skippedAncestors.length = 0
|
||||
state.failedCandidates.length = 0
|
||||
state.failures.length = 0
|
||||
state.provisionallyBlocked.clear()
|
||||
state.removeCandidates = null
|
||||
}
|
||||
|
||||
function removeArrayEntry<T>(entries: T[], entry: T): void {
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
trackWorkspaceCleanupLateSettlement,
|
||||
type WorkspaceCleanupRemovalSettlement
|
||||
} from './workspace-cleanup-removal-settlement'
|
||||
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
|
||||
|
||||
describe('workspace cleanup late settlement tracking', () => {
|
||||
it('detaches the batch callback and reports with a compact candidate identity', async () => {
|
||||
const candidate = makeCandidate()
|
||||
let resolveSettlement: (settlement: WorkspaceCleanupRemovalSettlement) => void = () => {}
|
||||
const settlement = new Promise<WorkspaceCleanupRemovalSettlement>((resolve) => {
|
||||
resolveSettlement = resolve
|
||||
})
|
||||
const reconcileBeforeBatchResult = vi.fn()
|
||||
const reportAfterBatchResult = vi.fn()
|
||||
const tracker = trackWorkspaceCleanupLateSettlement(
|
||||
settlement,
|
||||
candidate,
|
||||
reconcileBeforeBatchResult
|
||||
)
|
||||
|
||||
expect(tracker.candidate).toEqual({
|
||||
worktreeId: candidate.worktreeId,
|
||||
displayName: candidate.displayName
|
||||
})
|
||||
expect(tracker.candidate).not.toBe(candidate)
|
||||
tracker.detach(reportAfterBatchResult)
|
||||
resolveSettlement({ status: 'rejected', error: new Error('remote removal failed') })
|
||||
await settlement
|
||||
await Promise.resolve()
|
||||
|
||||
expect(reconcileBeforeBatchResult).not.toHaveBeenCalled()
|
||||
expect(reportAfterBatchResult).toHaveBeenCalledWith(
|
||||
{ worktreeId: candidate.worktreeId, displayName: candidate.displayName },
|
||||
{
|
||||
removedIds: [],
|
||||
failures: [
|
||||
{
|
||||
worktreeId: candidate.worktreeId,
|
||||
displayName: candidate.displayName,
|
||||
message: 'remote removal failed'
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
expect(reportAfterBatchResult.mock.calls[0][0]).not.toBe(candidate)
|
||||
})
|
||||
})
|
||||
+49
-12
@@ -13,6 +13,21 @@ export type WorkspaceCleanupRemovalWaitResult =
|
||||
| WorkspaceCleanupRemovalSettlement
|
||||
| { status: 'unresolved'; settlement: Promise<WorkspaceCleanupRemovalSettlement> }
|
||||
|
||||
export type WorkspaceCleanupLateSettlementCandidate = Pick<
|
||||
WorkspaceCleanupCandidate,
|
||||
'worktreeId' | 'displayName'
|
||||
>
|
||||
|
||||
export type WorkspaceCleanupLateSettlementReporter = (
|
||||
candidate: WorkspaceCleanupLateSettlementCandidate,
|
||||
result: WorkspaceCleanupRemoveResult
|
||||
) => void
|
||||
|
||||
export type WorkspaceCleanupLateSettlementTracker = {
|
||||
candidate: WorkspaceCleanupLateSettlementCandidate
|
||||
detach: (reportAfterBatchResult?: WorkspaceCleanupLateSettlementReporter) => void
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceCleanupRemovalWithTimeout(
|
||||
promise: Promise<WorkspaceCleanupRemoveResult>,
|
||||
timeoutMs: number,
|
||||
@@ -48,27 +63,49 @@ export function getWorkspaceCleanupTimeoutFailure(
|
||||
export function trackWorkspaceCleanupLateSettlement(
|
||||
settlement: Promise<WorkspaceCleanupRemovalSettlement>,
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
reconcileBeforeBatchResult: (result: WorkspaceCleanupRemoveResult) => void,
|
||||
reportAfterBatchResult: (result: WorkspaceCleanupRemoveResult) => void
|
||||
): () => void {
|
||||
// Why: a truly hung IPC promise can live for the renderer's lifetime; detach
|
||||
// drops the reconcile closure (and the batch arrays it captures) at batch end.
|
||||
let reconcile: ((result: WorkspaceCleanupRemoveResult) => void) | null =
|
||||
reconcileBeforeBatchResult
|
||||
reconcileBeforeBatchResult: (result: WorkspaceCleanupRemoveResult) => void
|
||||
): WorkspaceCleanupLateSettlementTracker {
|
||||
const candidateIdentity: WorkspaceCleanupLateSettlementCandidate = {
|
||||
worktreeId: candidate.worktreeId,
|
||||
displayName: candidate.displayName
|
||||
}
|
||||
const state: {
|
||||
active: boolean
|
||||
reconcile: ((result: WorkspaceCleanupRemoveResult) => void) | null
|
||||
report: WorkspaceCleanupLateSettlementReporter | null
|
||||
} = {
|
||||
active: true,
|
||||
reconcile: reconcileBeforeBatchResult,
|
||||
report: null
|
||||
}
|
||||
settlement
|
||||
.then((outcome) => {
|
||||
const result = toWorkspaceCleanupRemoveResult(candidate, outcome)
|
||||
const reconcile = state.reconcile
|
||||
const report = state.report
|
||||
state.active = false
|
||||
state.reconcile = null
|
||||
state.report = null
|
||||
const result = toWorkspaceCleanupRemoveResult(candidateIdentity, outcome)
|
||||
if (reconcile) {
|
||||
reconcile(result)
|
||||
return
|
||||
}
|
||||
reportAfterBatchResult(result)
|
||||
report?.(candidateIdentity, result)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('Workspace cleanup late settlement reporting failed', error)
|
||||
})
|
||||
return () => {
|
||||
reconcile = null
|
||||
return {
|
||||
candidate: candidateIdentity,
|
||||
detach: (reportAfterBatchResult) => {
|
||||
if (!state.active) {
|
||||
return
|
||||
}
|
||||
// Why: hung settlements retain only a two-field identity and the compact
|
||||
// post-batch reporter, never the candidate or batch reconciliation closure.
|
||||
state.reconcile = null
|
||||
state.report = reportAfterBatchResult ?? null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +119,7 @@ function toWorkspaceCleanupRemovalSettlement(
|
||||
}
|
||||
|
||||
function toWorkspaceCleanupRemoveResult(
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
candidate: WorkspaceCleanupLateSettlementCandidate,
|
||||
settlement: WorkspaceCleanupRemovalSettlement
|
||||
): WorkspaceCleanupRemoveResult {
|
||||
if (settlement.status === 'fulfilled') {
|
||||
|
||||
Reference in New Issue
Block a user