diff --git a/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts b/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts index e08e6aa2090..5be4194bc76 100644 --- a/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts +++ b/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts @@ -141,7 +141,7 @@ describe('registerPtyHandlers', () => { type SerializeController = { serializeBuffer: ( ptyId: string, - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + opts?: { scrollbackRows?: number } ) => Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null> } diff --git a/src/main/ipc/pty/ipc/serialize-buffer.ts b/src/main/ipc/pty/ipc/serialize-buffer.ts index 10011c19aae..d7b5d35d642 100644 --- a/src/main/ipc/pty/ipc/serialize-buffer.ts +++ b/src/main/ipc/pty/ipc/serialize-buffer.ts @@ -86,7 +86,7 @@ export function installPtySerializeBufferIpc(session: PtyIpcSession): void { export function requestSerializedBuffer( session: PtyIpcSession, ptyId: string, - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + opts?: { scrollbackRows?: number } ): Promise { if (session.mainWindow.isDestroyed()) { return Promise.resolve(null) @@ -101,7 +101,7 @@ export function requestSerializedBuffer( const payload: { requestId: string ptyId: string - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + opts?: { scrollbackRows?: number } } = { requestId, ptyId } if (opts) { payload.opts = opts diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index da6c45ce5c5..0d388599bc9 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -54,7 +54,7 @@ export type PtyRuntimeControllerDeps = { ) => string | undefined requestSerializedBuffer: ( ptyId: string, - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + opts?: { scrollbackRows?: number } ) => Promise<{ data: string cols: number diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index ae1dcc33cf0..5ec4c1063a2 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -310,7 +310,7 @@ export function getSizeFromRuntimeController(ptyId: string) { export async function serializeProviderBufferFromRuntimeController( ptyId: string, - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + opts?: { scrollbackRows?: number } ) { try { // Why: restored daemon PTYs can be live while their desktop pane is unmounted; query the provider model so phone-local navigation works. diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts index 423ecabbe3d..3570e428b56 100644 --- a/src/main/ipc/pty/session.ts +++ b/src/main/ipc/pty/session.ts @@ -149,7 +149,7 @@ export type PtyIpcSession = { sendPtySpawnedToRenderer: (id: string) => void requestSerializedBuffer: ( ptyId: string, - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + opts?: { scrollbackRows?: number } ) => Promise shutdownProviderAndDetectExit: ( provider: IPtyProvider, diff --git a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts index f2b6ef57269..163ffaecdb3 100644 --- a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts +++ b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts @@ -52,9 +52,11 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime // state and the seed-resolve would overwrite it, dropping live bytes. state.writeChain = state.writeChain.then(async () => { try { + // Why the scrollback is not suppressed mid-TUI: the seed IS the model's + // normal buffer, so zeroing it while an alt-screen agent was up left the + // model with no pre-TUI history to restore from (#6106). const rendered = await controller.serializeBuffer!(ptyId, { - scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, - altScreenForcesZeroRows: true + scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS }) if (!rendered || rendered.data.length === 0) { return diff --git a/src/main/runtime/orca-runtime-serialize-terminal-buffer-from-available-state.ts b/src/main/runtime/orca-runtime-serialize-terminal-buffer-from-available-state.ts index aabe4b94a8c..e031dc1b6f5 100644 --- a/src/main/runtime/orca-runtime-serialize-terminal-buffer-from-available-state.ts +++ b/src/main/runtime/orca-runtime-serialize-terminal-buffer-from-available-state.ts @@ -87,12 +87,8 @@ export class OrcaRuntimeWithSerializeTerminalBufferFromAvailableState extends Or kittyKeyboardFlags?: number } | null = null try { - // Why: recovery/read fallback wants visible alt-screen content (e.g. an - // active TUI), so altScreenForcesZeroRows is FALSE here. Hydration is - // the only path that suppresses alt-screen scrollback. rendererSnapshot = await (this.ptyController?.serializeBuffer?.(ptyId, { - scrollbackRows: opts.scrollbackRows, - altScreenForcesZeroRows: false + scrollbackRows: opts.scrollbackRows }) ?? Promise.resolve(null)) } catch { // Why: terminal snapshots should not depend on a mounted renderer pane. diff --git a/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts b/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts index 87b0a4c52de..4de568618cc 100644 --- a/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts +++ b/src/main/runtime/orca-runtime-tests/headless-snapshots.spec.ts @@ -111,6 +111,39 @@ describe('OrcaRuntimeService', () => { } }) + it('keeps pre-TUI shell scrollback when hydrating from a renderer on the alternate screen (#6106)', async () => { + // The real renderer serializer emits the normal buffer first and the `?1049h` + // alt frame after it; asking it to zero the scrollback while a TUI is up drops + // the shell history, not the TUI bytes. Model that contract here. + const serializeBuffer = vi.fn(async (_ptyId: string, opts?: { scrollbackRows?: number }) => { + const suppressesScrollback = + (opts as Record | undefined)?.altScreenForcesZeroRows === true + const scrollback = suppressesScrollback ? '' : 'PRE_CODEX_START\r\nAGENTS.md\r\n' + return { + data: `${scrollback}\x1b[?1049h\x1b[HCodex TUI frame`, + cols: 80, + rows: 24 + } + }) + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime, 'pty-1') + + runtime.onPtyData('pty-1', 'live byte', 100) + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 1000 }) + const restored = `${snapshot?.scrollbackAnsi ?? ''}${snapshot?.data ?? ''}` + expect(restored).toContain('PRE_CODEX_START') + expect(restored).toContain('AGENTS.md') + }) + it('adopts renderer-seeded titles into headless main terminal snapshots', async () => { const artifactPath = '/tmp/renderer-seeded-artifact.json' const serializeBuffer = vi.fn().mockResolvedValue({ @@ -139,8 +172,7 @@ describe('OrcaRuntimeService', () => { lastTitle: 'Renderer seeded Codex' }) expect(serializeBuffer).toHaveBeenCalledWith('pty-1', { - scrollbackRows: expect.any(Number), - altScreenForcesZeroRows: true + scrollbackRows: expect.any(Number) }) expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( true @@ -412,8 +444,7 @@ describe('OrcaRuntimeService', () => { source: 'renderer' }) expect(serializeBuffer).toHaveBeenCalledWith('pty-1', { - scrollbackRows: 5000, - altScreenForcesZeroRows: false + scrollbackRows: 5000 }) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-09.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-09.spec.ts index 2237691c9ae..ef0a1a96de5 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-09.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-09.spec.ts @@ -472,8 +472,7 @@ describe('OrcaRuntimeService', () => { expect(read.tail).toEqual(['Claude Code', 'Working on fix', 'Tool: Read']) expect(serializeBuffer).toHaveBeenCalledWith('pty-1', { - scrollbackRows: 0, - altScreenForcesZeroRows: false + scrollbackRows: 0 }) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-10.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-10.spec.ts index 2e75e1c218c..1db69464935 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-10.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-10.spec.ts @@ -532,8 +532,7 @@ describe('OrcaRuntimeService', () => { draft: 'proceed with the release' }) expect(serializeBuffer).toHaveBeenCalledWith('pty-1', { - scrollbackRows: 0, - altScreenForcesZeroRows: false + scrollbackRows: 0 }) }) @@ -595,8 +594,7 @@ describe('OrcaRuntimeService', () => { expect(read.tail).toEqual(['']) expect(serializeBuffer).not.toHaveBeenCalledWith('pty-1', { - scrollbackRows: 0, - altScreenForcesZeroRows: false + scrollbackRows: 0 }) }) @@ -623,8 +621,7 @@ describe('OrcaRuntimeService', () => { expect(read.tail).toEqual(['', '']) expect(serializeBuffer).not.toHaveBeenCalledWith('pty-1', { - scrollbackRows: 0, - altScreenForcesZeroRows: false + scrollbackRows: 0 }) }) }) diff --git a/src/main/runtime/orca-runtime-visible-snapshot-preview.ts b/src/main/runtime/orca-runtime-visible-snapshot-preview.ts index 06bf4b338ef..ac9eb99a67b 100644 --- a/src/main/runtime/orca-runtime-visible-snapshot-preview.ts +++ b/src/main/runtime/orca-runtime-visible-snapshot-preview.ts @@ -176,10 +176,7 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur // visibly nonblank in renderer xterm. Ask the renderer for the active // screen instead of reusing the headless transcript path. const snapshot = await withTimeout( - controller.serializeBuffer(ptyId, { - scrollbackRows: 0, - altScreenForcesZeroRows: false - }), + controller.serializeBuffer(ptyId, { scrollbackRows: 0 }), VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS, null ) diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 2b17787f67d..f5393bb6e33 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -126,7 +126,7 @@ export type RuntimePtyController = { }> serializeBuffer?( ptyId: string, - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + opts?: { scrollbackRows?: number } ): Promise<{ data: string cols: number diff --git a/src/main/ssh/ssh-remote-node-probe-script.ts b/src/main/ssh/ssh-remote-node-probe-script.ts new file mode 100644 index 00000000000..04da4852562 --- /dev/null +++ b/src/main/ssh/ssh-remote-node-probe-script.ts @@ -0,0 +1,70 @@ +// POSIX `sh` probe listing every plausible remote Node binary, one per line. +// Kept out of the resolver so the shell text can grow without pushing that file +// past its line budget. + +// Why the dotfile scrape: sshd's exec channel runs without the user's profile, so +// MISE_DATA_DIR / NVM_DIR set in ~/.zshrc are not in this environment. Reading the +// assignment out of the dotfiles is the only way to see a relocated data dir. +export const REMOTE_NODE_PATH_PROBE_SCRIPT = ` +command -v node 2>/dev/null +orca_dotfile_dirs() { + orca_var_name=$1 + orca_dirs=$2 + for orca_file in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc" + do + [ -r "$orca_file" ] || continue + orca_dir_from_file=$(sed -n "s/^[[:space:]]*export[[:space:]][[:space:]]*$orca_var_name[[:space:]]*=[[:space:]]*//p; s/^[[:space:]]*$orca_var_name[[:space:]]*=[[:space:]]*//p" "$orca_file" | tail -n 1) + case "$orca_dir_from_file" in + \\"*\\") orca_dir_from_file=\${orca_dir_from_file#\\"}; orca_dir_from_file=\${orca_dir_from_file%%\\"*} ;; + \\'*\\') orca_dir_from_file=\${orca_dir_from_file#\\'}; orca_dir_from_file=\${orca_dir_from_file%%\\'*} ;; + *) orca_dir_from_file=\${orca_dir_from_file%%[[:space:]]*} ;; + esac + case "$orca_dir_from_file" in + '$XDG_DATA_HOME'*) orca_dir_from_file="\${XDG_DATA_HOME:-$HOME/.local/share}\${orca_dir_from_file#'$XDG_DATA_HOME'}" ;; + '$HOME'*) orca_dir_from_file="$HOME\${orca_dir_from_file#'$HOME'}" ;; + "~/"*) orca_dir_from_file="$HOME/\${orca_dir_from_file#\\~/}" ;; + esac + [ -n "$orca_dir_from_file" ] && orca_dirs="$orca_dirs +$orca_dir_from_file" + done + printf '%s\\n' "$orca_dirs" +} +nvm_dirs=\${NVM_DIR:-"$HOME/.nvm"} +nvm_dirs=$(orca_dotfile_dirs NVM_DIR "$nvm_dirs") +printf '%s\\n' "$nvm_dirs" | while IFS= read -r nvm_dir +do + [ -n "$nvm_dir" ] || continue + for candidate in "$nvm_dir"/versions/node/*/bin/node + do + [ -x "$candidate" ] && printf '%s\\n' "$candidate" + done +done +mise_dirs=\${MISE_DATA_DIR:-\${XDG_DATA_HOME:-$HOME/.local/share}/mise} +mise_dirs=$(orca_dotfile_dirs MISE_DATA_DIR "$mise_dirs") +printf '%s\\n' "$mise_dirs" | while IFS= read -r mise_dir +do + [ -n "$mise_dir" ] || continue + [ -x "$mise_dir/shims/node" ] && printf '%s\\n' "$mise_dir/shims/node" + for candidate in "$mise_dir"/installs/node/*/bin/node + do + [ -x "$candidate" ] && printf '%s\\n' "$candidate" + done +done +for candidate in \\ + /usr/local/bin/node \\ + /opt/homebrew/bin/node \\ + "$HOME/.local/bin/node" \\ + "$HOME/.fnm/aliases/default/bin/node" \\ + "$HOME/.fnm/node-versions"/*/installation/bin/node \\ + "$HOME/.local/share/fnm/node-versions"/*/installation/bin/node \\ + "$HOME/.local/share/mise/shims/node" \\ + "$HOME/.local/share/mise/installs/node"/*/bin/node \\ + "$HOME/.asdf/shims/node" \\ + "$HOME/.asdf/installs/nodejs"/*/bin/node \\ + "$HOME/.volta/bin/node" \\ + /usr/local/n/versions/node/*/bin/node +do + [ -x "$candidate" ] && printf '%s\\n' "$candidate" +done +true +` diff --git a/src/main/ssh/ssh-remote-node-resolution.test.ts b/src/main/ssh/ssh-remote-node-resolution.test.ts index a48687284ad..fa259a5d620 100644 --- a/src/main/ssh/ssh-remote-node-resolution.test.ts +++ b/src/main/ssh/ssh-remote-node-resolution.test.ts @@ -133,10 +133,110 @@ describe('resolveRemoteNodePath', () => { const callScript = execCommandMock.mock.calls[0]![1] as string expect(callScript).toContain('nvm_dirs=${NVM_DIR:-"$HOME/.nvm"}') - expect(callScript).toContain('NVM_DIR[[:space:]]*=') + expect(callScript).toContain('orca_dotfile_dirs NVM_DIR') expect(callScript).toContain('"$nvm_dir"/versions/node/*/bin/node') }) + it('respects a custom MISE_DATA_DIR instead of hardcoding $HOME/.local/share/mise', async () => { + execCommandMock + .mockResolvedValueOnce('/opt/mise-data/installs/node/v20.11.0/bin/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + expect(callScript).toContain('mise_dirs=${MISE_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}') + expect(callScript).toContain('orca_dotfile_dirs MISE_DATA_DIR') + expect(callScript).toContain('"$mise_dir"/installs/node/*/bin/node') + expect(callScript).toContain('"$mise_dir/shims/node"') + }) + + it('finds node under a MISE_DATA_DIR exported from a shell dotfile', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.local/share/mise/shims/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + const home = mkdtempSync(path.join(os.tmpdir(), 'orca-mise-probe-')) + try { + const shimPath = path.join(home, 'custom-mise/shims/node') + const installPath = path.join(home, 'custom-mise/installs/node/v20.11.0/bin/node') + for (const target of [shimPath, installPath]) { + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, '#!/bin/sh\nprintf "v20.11.0\\n"\n') + chmodSync(target, 0o755) + } + writeFileSync(path.join(home, '.zshrc'), 'export MISE_DATA_DIR=~/custom-mise\n') + + const output = execFileSync('/bin/sh', ['-c', callScript], { + encoding: 'utf8', + env: { HOME: home, PATH: '/usr/bin:/bin' } + }) + + const lines = output.split('\n') + expect(lines).toContain(shimPath) + expect(lines).toContain(installPath) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + it('finds node under a MISE_DATA_DIR present only in the probe environment', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.local/share/mise/shims/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + const home = mkdtempSync(path.join(os.tmpdir(), 'orca-mise-env-probe-')) + try { + const miseDataDir = path.join(home, 'env-mise') + const installPath = path.join(miseDataDir, 'installs/node/v20.11.0/bin/node') + mkdirSync(path.dirname(installPath), { recursive: true }) + writeFileSync(installPath, '#!/bin/sh\nprintf "v20.11.0\\n"\n') + chmodSync(installPath, 0o755) + + const output = execFileSync('/bin/sh', ['-c', callScript], { + encoding: 'utf8', + env: { HOME: home, MISE_DATA_DIR: miseDataDir, PATH: '/usr/bin:/bin' } + }) + + expect(output.split('\n')).toContain(installPath) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + it('falls back to XDG_DATA_HOME for mise installs when MISE_DATA_DIR is unset', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.local/share/mise/shims/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + const home = mkdtempSync(path.join(os.tmpdir(), 'orca-mise-xdg-probe-')) + try { + const xdgDataHome = path.join(home, 'xdg') + const installPath = path.join(xdgDataHome, 'mise/installs/node/v20.11.0/bin/node') + mkdirSync(path.dirname(installPath), { recursive: true }) + writeFileSync(installPath, '#!/bin/sh\nprintf "v20.11.0\\n"\n') + chmodSync(installPath, 0o755) + + const output = execFileSync('/bin/sh', ['-c', callScript], { + encoding: 'utf8', + env: { HOME: home, XDG_DATA_HOME: xdgDataHome, PATH: '/usr/bin:/bin' } + }) + + expect(output.split('\n')).toContain(installPath) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + it('quotes version-manager directory prefixes while leaving globs active', async () => { execCommandMock .mockResolvedValueOnce('/home/u/.fnm/node-versions/v20.11.0/installation/bin/node\n') @@ -212,6 +312,67 @@ describe('resolveRemoteNodePath', () => { } }) + it('expands an $XDG_DATA_HOME-relative MISE_DATA_DIR assignment from shell dotfiles', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.local/share/mise/shims/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + const home = mkdtempSync(path.join(os.tmpdir(), 'orca-xdg-probe-')) + try { + // A name the seeded `${XDG_DATA_HOME:-$HOME/.local/share}/mise` default cannot reach, so + // only the dotfile arm can find it. + const nodePath = path.join(home, 'xdg-data/custom-mise/installs/node/20.11.0/bin/node') + mkdirSync(path.dirname(nodePath), { recursive: true }) + writeFileSync(nodePath, '#!/bin/sh\nprintf "v20.11.0\\n"\n') + chmodSync(nodePath, 0o755) + writeFileSync(path.join(home, '.zshrc'), 'export MISE_DATA_DIR=$XDG_DATA_HOME/custom-mise\n') + + const output = execFileSync('/bin/sh', ['-c', callScript], { + encoding: 'utf8', + env: { + HOME: home, + PATH: '/usr/bin:/bin', + XDG_DATA_HOME: path.join(home, 'xdg-data') + } + }) + + expect(output.split('\n')).toContain(nodePath) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + + it('falls back to the POSIX default when an $XDG_DATA_HOME assignment has no env value', async () => { + execCommandMock + .mockResolvedValueOnce('/home/u/.local/share/mise/shims/node\n') + .mockResolvedValueOnce('v20.11.0\n') + + await resolveRemoteNodePath(conn) + + const callScript = execCommandMock.mock.calls[0]![1] as string + const home = mkdtempSync(path.join(os.tmpdir(), 'orca-xdg-default-probe-')) + try { + // sshd's exec channel runs without the profile, so XDG_DATA_HOME is often simply absent. + const nodePath = path.join(home, '.local/share/custom-mise/installs/node/20.11.0/bin/node') + mkdirSync(path.dirname(nodePath), { recursive: true }) + writeFileSync(nodePath, '#!/bin/sh\nprintf "v20.11.0\\n"\n') + chmodSync(nodePath, 0o755) + writeFileSync(path.join(home, '.zshrc'), 'export MISE_DATA_DIR=$XDG_DATA_HOME/custom-mise\n') + + const output = execFileSync('/bin/sh', ['-c', callScript], { + encoding: 'utf8', + env: { HOME: home, PATH: '/usr/bin:/bin' } + }) + + expect(output.split('\n')).toContain(nodePath) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) + it('joins probes with newlines, not ||, so a missing dir does not mask later probes', async () => { execCommandMock .mockResolvedValueOnce('/usr/local/bin/node\n') diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index 1f2af057039..fd55b6454ac 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -15,6 +15,7 @@ import { } from './ssh-remote-node-toolchain-probe' import { isSshSessionLimitError } from './ssh-session-limit-error' import { buildSshLoginShellCommand } from './ssh-login-shell-command' +import { REMOTE_NODE_PATH_PROBE_SCRIPT } from './ssh-remote-node-probe-script' // Why: the login-shell fallback catches custom PATH setups in ~/.profile that // the path probes don't cover. Interactive configs (conda prompts, etc.) can @@ -58,51 +59,7 @@ async function tryResolveViaKnownPaths( conn: SshConnection, options?: RemoteNodeResolutionOptions ): Promise { - const script = ` -command -v node 2>/dev/null -nvm_dirs=\${NVM_DIR:-"$HOME/.nvm"} -for nvm_file in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc" -do - [ -r "$nvm_file" ] || continue - nvm_dir_from_file=$(sed -n 's/^[[:space:]]*export[[:space:]][[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p; s/^[[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p' "$nvm_file" | tail -n 1) - case "$nvm_dir_from_file" in - \\"*\\") nvm_dir_from_file=\${nvm_dir_from_file#\\"}; nvm_dir_from_file=\${nvm_dir_from_file%%\\"*} ;; - \\'*\\') nvm_dir_from_file=\${nvm_dir_from_file#\\'}; nvm_dir_from_file=\${nvm_dir_from_file%%\\'*} ;; - *) nvm_dir_from_file=\${nvm_dir_from_file%%[[:space:]]*} ;; - esac - case "$nvm_dir_from_file" in - '$HOME'*) nvm_dir_from_file="$HOME\${nvm_dir_from_file#'$HOME'}" ;; - "~/"*) nvm_dir_from_file="$HOME/\${nvm_dir_from_file#\\~/}" ;; - esac - [ -n "$nvm_dir_from_file" ] && nvm_dirs="$nvm_dirs -$nvm_dir_from_file" -done -printf '%s\\n' "$nvm_dirs" | while IFS= read -r nvm_dir -do - [ -n "$nvm_dir" ] || continue - for candidate in "$nvm_dir"/versions/node/*/bin/node - do - [ -x "$candidate" ] && printf '%s\\n' "$candidate" - done -done -for candidate in \\ - /usr/local/bin/node \\ - /opt/homebrew/bin/node \\ - "$HOME/.local/bin/node" \\ - "$HOME/.fnm/aliases/default/bin/node" \\ - "$HOME/.fnm/node-versions"/*/installation/bin/node \\ - "$HOME/.local/share/fnm/node-versions"/*/installation/bin/node \\ - "$HOME/.local/share/mise/shims/node" \\ - "$HOME/.local/share/mise/installs/node"/*/bin/node \\ - "$HOME/.asdf/shims/node" \\ - "$HOME/.asdf/installs/nodejs"/*/bin/node \\ - "$HOME/.volta/bin/node" \\ - /usr/local/n/versions/node/*/bin/node -do - [ -x "$candidate" ] && printf '%s\\n' "$candidate" -done -true -` + const script = REMOTE_NODE_PATH_PROBE_SCRIPT try { const result = await execCommandWithOptionalOptions(conn, script, signalOnlyOptions(options)) diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 3199ebd1190..32cacf17737 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -213,7 +213,7 @@ export type PtyApi = { callback: (data: { requestId: string ptyId: string - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + opts?: { scrollbackRows?: number } }) => void ) => () => void onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index b6f1373aad1..cbf23b1be8f 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -95,7 +95,7 @@ export const ptyStreamAndSerializationApi = { callback: (data: { requestId: string ptyId: string - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + opts?: { scrollbackRows?: number } }) => void ): (() => void) => { const listener = ( @@ -103,7 +103,7 @@ export const ptyStreamAndSerializationApi = { data: { requestId: string ptyId: string - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + opts?: { scrollbackRows?: number } } ) => callback(data) ipcRenderer.on('pty:serializeBuffer:request', listener) diff --git a/src/relay/pty-handler-spawn-admission.test.ts b/src/relay/pty-handler-spawn-admission.test.ts index 20b64d16b1c..4fbc1229509 100644 --- a/src/relay/pty-handler-spawn-admission.test.ts +++ b/src/relay/pty-handler-spawn-admission.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import * as ptyShellUtils from './pty-shell-utils' const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ @@ -408,6 +411,28 @@ describe('PtyHandler', () => { expect(finishCreation).toHaveBeenCalledTimes(1) }) + // requireRelaySpawnCwd strips the `::workspace:` instance suffix to get the real folder + // path, so a fence keyed on the unstripped id would guard a directory no spawn ever uses -- + // exactly what routing both through one resolver is supposed to make impossible. + it('fences a folder-workspace instance id on the directory the spawn will use', async () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), 'orca-relay-fence-')) + try { + const finishCreation = vi.fn() + const beginWorktreePtySpawn = vi.fn((_operationPath: string) => finishCreation) + handler.setWorktreeRemovalCoordinator({ beginWorktreePtySpawn }) + + await dispatcher.callRequest('pty.spawn', { + worktreeId: `repo-1::${workspaceRoot}::workspace:b1706d92-9d05-4932-8360-01e00b54305a` + }) + + const fencedPaths = beginWorktreePtySpawn.mock.calls.map((call) => call[0]) + expect(fencedPaths).toContain(workspaceRoot) + expect(fencedPaths.some((fenced) => fenced.includes('::workspace:'))).toBe(false) + } finally { + rmSync(workspaceRoot, { recursive: true, force: true }) + } + }) + it('fences both sibling worktree identity and removing cwd with rollback', async () => { const finishSiblingAdmission = vi.fn() const beginWorktreePtySpawn = vi.fn((operationPath: string) => { diff --git a/src/relay/pty-handler-spawn-cwd.test.ts b/src/relay/pty-handler-spawn-cwd.test.ts new file mode 100644 index 00000000000..2aab95a401e --- /dev/null +++ b/src/relay/pty-handler-spawn-cwd.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' + +const { mockPtySpawn, mockPtyInstance, mockCreateShellPromptReadinessProbe } = vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockCreateShellPromptReadinessProbe: vi.fn(), + mockPtyInstance: { + pid: process.pid, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + } +})) + +vi.mock('node-pty', () => ({ + spawn: mockPtySpawn +})) + +vi.mock('../main/pty/posix-pty-process-groups', () => ({ + forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) +})) + +vi.mock('../main/shell-prompt-readiness-probe', () => ({ + createShellPromptReadinessProbe: mockCreateShellPromptReadinessProbe +})) + +import type { PtyHandler } from './pty-handler' +import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' +import type { MockDispatcher } from './pty-handler-test-harness' + +function spawnCwd(callIndex = 0): string { + return (mockPtySpawn.mock.calls[callIndex][2] as { cwd: string }).cwd +} + +describe('relay pty spawn cwd (#15296)', () => { + let dispatcher: MockDispatcher + let handler: PtyHandler + let originalPlatform: PropertyDescriptor | undefined + let root: string + + beforeEach(() => { + ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ + mockPtySpawn, + mockPtyInstance, + mockCreateShellPromptReadinessProbe + })) + root = mkdtempSync(join(tmpdir(), 'orca-relay-cwd-')) + }) + + afterEach(async () => { + await endPtyHandlerTest(handler, originalPlatform) + rmSync(root, { recursive: true, force: true }) + }) + + it('spawns a folder workspace in ORCA_WORKSPACE_ROOT instead of the host default', async () => { + // Why: `folder:` carries no path, so the worktree-id split yields nothing and the + // configured root — delivered in the same env — was silently replaced by $HOME. + const workspaceRoot = join(root, 'workspace') + mkdirSync(workspaceRoot) + const workspaceId = 'folder:b1706d92-9d05-4932-8360-01e00b54305a' + + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + env: { + ORCA_WORKSPACE_ID: workspaceId, + ORCA_WORKTREE_ID: workspaceId, + ORCA_WORKSPACE_ROOT: workspaceRoot + } + }) + + expect(spawnCwd()).toBe(workspaceRoot) + expect(spawnCwd()).not.toBe(homedir()) + }) + + it('refuses to launch an agent when the named workspace root is not on this host', async () => { + const workspaceId = 'folder:b1706d92-9d05-4932-8360-01e00b54305a' + + await expect( + dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + launchAgent: 'claude', + env: { + ORCA_WORKSPACE_ID: workspaceId, + ORCA_WORKTREE_ID: workspaceId, + ORCA_WORKSPACE_ROOT: join(root, 'gone') + } + }) + ).rejects.toThrow(/Cannot determine the working directory/) + expect(mockPtySpawn).not.toHaveBeenCalled() + }) + + it('refuses to launch an agent for a folder workspace that carries no root at all', async () => { + await expect( + dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + launchAgent: 'claude', + env: { ORCA_WORKTREE_ID: 'folder:b1706d92-9d05-4932-8360-01e00b54305a' } + }) + ).rejects.toThrow(/Cannot determine the working directory/) + expect(mockPtySpawn).not.toHaveBeenCalled() + }) + + it('falls back to the worktree path carried by the worktree id', async () => { + const worktreePath = join(root, 'checkout') + mkdirSync(worktreePath) + + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + worktreeId: `repo-1::${worktreePath}` + }) + + expect(spawnCwd()).toBe(worktreePath) + }) + + it('keeps an explicitly requested cwd verbatim', async () => { + const workspaceRoot = join(root, 'workspace') + const requested = join(root, 'workspace', 'sub') + mkdirSync(requested, { recursive: true }) + + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + cwd: requested, + env: { ORCA_WORKTREE_ID: 'folder:abc', ORCA_WORKSPACE_ROOT: workspaceRoot } + }) + + expect(spawnCwd()).toBe(requested) + }) + + it('still uses the host default for a terminal that names no workspace', async () => { + await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) + + expect(spawnCwd()).toBe(process.env.HOME || homedir()) + }) + + it('does not refuse a plain shell whose workspace root is missing', async () => { + const missing = join(root, 'gone') + + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + env: { ORCA_WORKTREE_ID: 'folder:abc', ORCA_WORKSPACE_ROOT: missing } + }) + + expect(spawnCwd()).toBe(process.env.HOME || homedir()) + }) +}) + +describe('relay pty spawn cwd when the relay is not the execution host', () => { + let dispatcher: MockDispatcher + let handler: PtyHandler + let originalPlatform: PropertyDescriptor | undefined + let root: string + + beforeEach(() => { + ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest({ + mockPtySpawn, + mockPtyInstance, + mockCreateShellPromptReadinessProbe + })) + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + root = mkdtempSync(join(tmpdir(), 'orca-relay-wsl-cwd-')) + }) + + afterEach(async () => { + await endPtyHandlerTest(handler, originalPlatform) + rmSync(root, { recursive: true, force: true }) + }) + + // The relay supports WSL shells, and relayHostDirectoryExists stats the relay's own filesystem. + // A guest path never stats on a Windows relay, so refusing there would fail an agent launch the + // launch wrapper would have cd'd into fine -- the same host pair the worktree branch already + // treats as a miss rather than a refusal. + it('does not refuse an agent whose folder-workspace root lives in the WSL guest', async () => { + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + launchAgent: 'claude', + shellOverride: 'wsl.exe', + env: { + ORCA_WORKSPACE_ID: 'folder:b1706d92-9d05-4932-8360-01e00b54305a', + ORCA_WORKTREE_ID: 'folder:b1706d92-9d05-4932-8360-01e00b54305a', + ORCA_WORKSPACE_ROOT: '/home/u/guest-only-project' + } + }) + + expect(mockPtySpawn).toHaveBeenCalled() + }) + + it('still refuses when the same spawn runs on the relay filesystem itself', async () => { + await expect( + dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + launchAgent: 'claude', + shellOverride: 'powershell.exe', + env: { + ORCA_WORKSPACE_ID: 'folder:b1706d92-9d05-4932-8360-01e00b54305a', + ORCA_WORKTREE_ID: 'folder:b1706d92-9d05-4932-8360-01e00b54305a', + ORCA_WORKSPACE_ROOT: join(root, 'gone') + } + }) + ).rejects.toThrow(/Cannot determine the working directory/) + expect(mockPtySpawn).not.toHaveBeenCalled() + }) +}) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index b7fe5bb160b..aba05b63be5 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -9,7 +9,6 @@ import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell' import type { RelayDispatcher, RequestContext } from './dispatcher' import { resolveDefaultShell, - resolveDefaultCwd, resolveProcessCwd, processHasChildren, getForegroundProcessName, @@ -28,7 +27,12 @@ import { isPathInsideOrEqual, normalizeRuntimePathForComparison } from '../shared/cross-platform-path' -import { splitWorktreeId } from '../shared/worktree/id' +import { splitWorktreeIdForFilesystem } from '../shared/worktree/id' +import { + formatUnresolvedRelaySpawnCwdMessage, + resolveRelaySpawnCwd, + type RelaySpawnCwdResolution +} from './pty-spawn-cwd' import { PhysicalExitTracker } from '../shared/physical-exit-tracker' import { SHELL_READY_MARKER_PREFIX } from '../main/shell-ready-marker-scanner' import { @@ -111,6 +115,44 @@ import { } from './node-pty-unavailable-diagnosis' import { TERMINAL_UNAVAILABLE_RPC_ERROR_CODE } from '../shared/terminal-unavailable-cause' +/** + * The shell a spawn will actually launch, resolved the same way `spawnAfterAdmission` resolves it. + * + * Non-throwing on an unsupported override: that override fails the spawn later regardless, and this + * is only asked in order to decide whose filesystem the cwd lives on. + */ +function resolveRelaySpawnShell( + params: Record, + env: Record | undefined +): string { + const shellOverride = typeof params.shellOverride === 'string' ? params.shellOverride.trim() : '' + const requestedEnvShell = + process.platform !== 'win32' && typeof env?.SHELL === 'string' ? env.SHELL.trim() : '' + return resolveRevivedShellOverride(shellOverride) || requestedEnvShell || resolveDefaultShell() +} + +/** + * Spawn cwd, or a refusal. Both `spawnOnce` (admission fence) and `spawnAfterAdmission` (the native + * spawn) resolve through here so the fence can never be keyed on a directory the spawn won't use. + */ +function requireRelaySpawnCwd( + params: Record, + env: Record | undefined +): string { + const resolution: RelaySpawnCwdResolution = resolveRelaySpawnCwd({ + requestedCwd: params.cwd, + worktreeId: typeof params.worktreeId === 'string' ? params.worktreeId : env?.ORCA_WORKTREE_ID, + env, + launchAgent: isTuiAgent(params.launchAgent) ? params.launchAgent : undefined, + // A WSL shell executes in a guest, so the relay's own statSync is not the right question. + executesOnRelayFilesystem: !isRelayWslShell(resolveRelaySpawnShell(params, env)) + }) + if (resolution.kind === 'unresolved') { + throw new Error(formatUnresolvedRelaySpawnCwdMessage(resolution.workspaceId)) + } + return resolution.cwd +} + function isMissingNodePtyNativeBinding(error: unknown): boolean { return error instanceof Error && isFlattenedNodePtyLoaderMessage(error.message) } @@ -638,7 +680,7 @@ export class PtyHandler { const matchingIds = [...this.ptys.values()] .filter((managed) => { const ownedPath = managed.worktreeId - ? splitWorktreeId(managed.worktreeId)?.worktreePath + ? splitWorktreeIdForFilesystem(managed.worktreeId)?.worktreePath : undefined return ( (ownedPath !== undefined && isPathInsideOrEqual(rootPath, ownedPath)) || @@ -1669,8 +1711,12 @@ export class PtyHandler { const env = params.env as Record | undefined const worktreeId = typeof params.worktreeId === 'string' ? params.worktreeId : env?.ORCA_WORKTREE_ID - const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined - const cwd = typeof params.cwd === 'string' ? params.cwd : resolveDefaultCwd() + // Must be the filesystem split, matching requireRelaySpawnCwd: a `::workspace:` id would + // otherwise fence a directory the spawn never enters. + const worktreePath = worktreeId + ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + : undefined + const cwd = requireRelaySpawnCwd(params, env) const finishCreation = this.beginPtyCreation([worktreePath, cwd]) let physicalSpawnCommitted = false const markPhysicalSpawnCommitted = (): void => { @@ -1770,8 +1816,8 @@ export class PtyHandler { const cols = (params.cols as number) || 80 const rows = (params.rows as number) || 24 - const cwd = (params.cwd as string) || resolveDefaultCwd() const env = params.env as Record | undefined + const cwd = requireRelaySpawnCwd(params, env) const envToDelete = sanitizeEnvToDelete(params.envToDelete) const explicitTerm = !envToDelete.includes('TERM') && @@ -2680,7 +2726,7 @@ export class PtyHandler { continue } const ownedPath = entry.worktreeId - ? splitWorktreeId(entry.worktreeId)?.worktreePath + ? splitWorktreeIdForFilesystem(entry.worktreeId)?.worktreePath : undefined const finishCreation = this.beginPtyCreation([ownedPath, entry.cwd]) this.pendingReviveIds.add(entry.id) diff --git a/src/relay/pty-spawn-cwd.ts b/src/relay/pty-spawn-cwd.ts new file mode 100644 index 00000000000..17d380c2102 --- /dev/null +++ b/src/relay/pty-spawn-cwd.ts @@ -0,0 +1,86 @@ +import { statSync } from 'node:fs' +import { parseWorkspaceKey } from '../shared/workspace-scope' +import { splitWorktreeIdForFilesystem } from '../shared/worktree/id' +import { resolveDefaultCwd } from './pty-shell-utils' + +export type RelaySpawnCwdResolution = + | { kind: 'requested' | 'worktree' | 'workspace-root' | 'host-default'; cwd: string } + | { kind: 'unresolved'; workspaceId: string } + +export function relayHostDirectoryExists(path: string): boolean { + try { + return statSync(path).isDirectory() + } catch { + return false + } +} + +export function formatUnresolvedRelaySpawnCwdMessage(workspaceId: string): string { + return `Cannot determine the working directory for workspace ${workspaceId} on this host. Refusing to start an agent in a fallback directory.` +} + +function trimmedString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined +} + +/** + * Resolve the cwd a relay PTY must spawn in. + * + * Why the workspace-root hop: a folder workspace's id is `folder:` and carries no path, so + * the worktree-id split yields nothing and the host default silently won (#15296). The client + * already delivers the configured root as `ORCA_WORKSPACE_ROOT`; read it before falling back. + * + * Existence is checked on the relay host only when the relay is itself the execution host. A + * worktree path absent here (a Windows relay launching into WSL) stays a miss, not a refusal — the + * launch wrapper owns that cd. A declared-but-absent folder root is different: we know exactly which + * directory was meant, so substituting `$HOME` for an agent is the damage this refuses to do. + * + * That refusal is only sound when the relay's own filesystem is the one the spawn will use. A WSL + * shell hands execution to a guest, and a guest path never stats on the Windows relay, so + * `executesOnRelayFilesystem: false` demotes the refusal back to a miss — the same treatment the + * worktree branch already gives that host pair. + */ +export function resolveRelaySpawnCwd(args: { + requestedCwd?: unknown + worktreeId?: string + env?: Record + launchAgent?: unknown + directoryExists?: (path: string) => boolean + hostDefaultCwd?: () => string + /** Defaults to true: absent better knowledge, the relay is the execution host. */ + executesOnRelayFilesystem?: boolean +}): RelaySpawnCwdResolution { + const directoryExists = args.directoryExists ?? relayHostDirectoryExists + const requested = trimmedString(args.requestedCwd) + if (requested) { + return { kind: 'requested', cwd: requested } + } + + const workspaceId = trimmedString(args.worktreeId) ?? trimmedString(args.env?.ORCA_WORKSPACE_ID) + const scope = workspaceId ? parseWorkspaceKey(workspaceId) : null + const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId + const worktreePath = + scope?.type === 'folder' || !worktreeId + ? undefined + : splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + if (worktreePath && directoryExists(worktreePath)) { + return { kind: 'worktree', cwd: worktreePath } + } + + const workspaceRoot = trimmedString(args.env?.ORCA_WORKSPACE_ROOT) + if (workspaceRoot && directoryExists(workspaceRoot)) { + return { kind: 'workspace-root', cwd: workspaceRoot } + } + + // A folder workspace named a root we could not resolve. For an agent that is + // "cannot determine", not permission to pick one. + if ( + args.launchAgent !== undefined && + args.executesOnRelayFilesystem !== false && + (workspaceRoot !== undefined || scope?.type === 'folder') + ) { + return { kind: 'unresolved', workspaceId: workspaceId ?? 'unknown' } + } + + return { kind: 'host-default', cwd: (args.hostDefaultCwd ?? resolveDefaultCwd)() } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts index bfd98ca51b0..da55500cd51 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts @@ -106,6 +106,36 @@ describe('useAiVaultExecutionHostScope', () => { expect(latest?.activeExecutionHostScope).toBeNull() }) + it('does not claim local history when the active workspace host cannot be resolved (#13713)', async () => { + // The worktree and its repo are absent from the client store — `unverifiable`, not local. + await renderHook({ + activeWorktreeId: 'repo-1::/remote/repo', + resumeTargetState: { + folderWorkspaces: [], + projectGroups: [], + repos: [], + worktreesByRepo: {} + } as unknown as AiVaultSessionResumeTargetState + }) + + expect(latest?.executionHostScope).not.toBe('local') + expect(latest?.executionHostScope).toBe('all') + }) + + it('keeps local history when no workspace is selected at all', async () => { + await renderHook({ + activeWorktreeId: null, + resumeTargetState: { + folderWorkspaces: [], + projectGroups: [], + repos: [], + worktreesByRepo: {} + } as unknown as AiVaultSessionResumeTargetState + }) + + expect(latest?.executionHostScope).toBe('local') + }) + it('defaults runtime worktrees to their runtime execution host', async () => { await renderHook({ activeWorktreeId: 'repo-1::/runtime/repo', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts index 0acb4f54644..9a52609b58a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.ts @@ -36,8 +36,14 @@ export function useAiVaultExecutionHostScope(args: { activeExecutionHost?.kind === 'ssh' || activeExecutionHost?.kind === 'runtime' ? activeExecutionHost.id : null + // Why: a named workspace whose host the client store cannot place is `unverifiable`, not local. + // Defaulting it to local scanned the desktop's own history and reported "No agent sessions found" + // for a user whose sessions all live on an SSH host (#13713). Widen to every host instead of + // asserting one. A local workspace still resolves to `local` and is unaffected. + const workspaceHostUnresolved = args.activeWorktreeId !== null && activeExecutionHostId === null const defaultExecutionHostScope: ExecutionHostScope = - activeExecutionHostScope ?? LOCAL_EXECUTION_HOST_ID + activeExecutionHostScope ?? + (workspaceHostUnresolved ? ALL_EXECUTION_HOSTS_SCOPE : LOCAL_EXECUTION_HOST_ID) const [executionHostScope, setExecutionHostScope] = useState(defaultExecutionHostScope) diff --git a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts index 8fc1df0985c..ffc0648802b 100644 --- a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts +++ b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts @@ -7,7 +7,6 @@ import type { IDisposable } from '@xterm/xterm' export type SerializeOpts = { scrollbackRows?: number - altScreenForcesZeroRows?: boolean } export type SerializedBuffer = { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts index ded2c9e208d..d498df855a1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-register.ts @@ -33,23 +33,21 @@ export function bindRegisterPaneSerializer(session: ConnectPanePtySession): void if (isTerminalWritePipelineCertifiedDead(session.pane.terminal)) { return null } - // Why: alt-screen TUIs (vim, claude-code) hold transient state in - // the alternate screen. The hydration path requests - // altScreenForcesZeroRows so normal-buffer scrollback isn't bled - // into the seed when the user is mid-TUI; the read-fallback path - // omits it because it wants the user's currently-visible content. - const alt = session.pane.terminal.buffer.active.type === 'alternate' // Why serializeWithAbsoluteCursor: SerializeAddon's relative // cursor restore lands one column short when replay of a // margin-filling final row leaves the target wrap-pending. - const data = - opts?.altScreenForcesZeroRows && alt - ? serializeWithAbsoluteCursor(session.pane.serializeAddon, session.pane.terminal, { - scrollback: 0 - }) - : serializeWithAbsoluteCursor(session.pane.serializeAddon, session.pane.terminal, { - scrollback: opts?.scrollbackRows - }) + // + // Why scrollback is never zeroed mid-TUI: the addon emits the normal + // buffer first and only then the `?1049h` alt frame, and readers split + // the two back apart (splitTerminalSnapshotAnsi). Forcing `scrollback: 0` + // while an alt-screen TUI was up therefore dropped the pre-TUI shell + // output from the seed instead of the transient TUI bytes, and every + // later restore painted only the TUI screen (#6106). + const data = serializeWithAbsoluteCursor( + session.pane.serializeAddon, + session.pane.terminal, + { scrollback: opts?.scrollbackRows } + ) const orderedSeq = session.rendererOrderedPtyId === ptyId ? session.rendererOrderedSeq : null // Why snapshotFlags and not `flags`: this pane may itself have