mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(remote): resolve the spawn cwd, the node manager dir, the vault host and the scrollback seed (#17952)
* fix(remote): resolve workspace cwd, mise Node, host scope, and TUI scrollback honestly #15296 relay: a folder workspace id (`folder:<uuid>`) carries no path, so the worktree-id split yielded nothing and $HOME silently won. Resolve the spawn cwd through worktreeId -> ORCA_WORKSPACE_ROOT -> host default, and refuse an agent spawn outright when a folder workspace names a root this host cannot resolve. #11733 ssh: generalize the NVM dotfile scrape into `orca_dotfile_dirs` and drive mise off `MISE_DATA_DIR` / `XDG_DATA_HOME` instead of a hardcoded `$HOME/.local/share/mise`. #13713 ai-vault: an unresolvable workspace host is `unverifiable`, not local. Widen the default scope to every host rather than scanning the client's own history and reporting "No agent sessions found". #6106 terminal: hydration asked the renderer for `scrollback: 0` while an alt-screen TUI was up, which drops the normal buffer's shell history rather than the TUI bytes. Drop the flag; readers already split the two buffers apart. * fix(remote): stop the relay answering host questions for a guest execution host Three findings from review of the spawn-cwd resolver, all the same shape: a path question answered against the wrong host, or with the wrong key. - resolveRelaySpawnCwd refused an agent launch whenever a folder workspace named a root that did not stat on the relay. But relayHostDirectoryExists stats the relay's *own* filesystem, and the relay supports WSL shells, so a folder workspace on a Windows relay launching into WSL now threw where it previously spawned -- contradicting the function's own doc comment, which says an absent path for that exact host pair is a miss, not a refusal. Thread the shell's execution host in and demote the refusal to a miss when the spawn does not run on the relay's filesystem. - requireRelaySpawnCwd's doc claims both call sites route through one resolver so the fence can never be keyed on a directory the spawn won't use, but the fence key was still computed with the non-stripping splitWorktreeId while the cwd used splitWorktreeIdForFilesystem. For a `::workspace:<uuid>` id those disagree by construction, in adjacent lines: the removal fence guarded a path no spawn ever enters. Same defect in shutdownForWorktreePath and the revive path; all three now use the filesystem split. - The remote Node probe expanded `$HOME` and `~/` prefixes out of a dotfile assignment but not `$XDG_DATA_HOME`, so `MISE_DATA_DIR=$XDG_DATA_HOME/...` was used as a literal directory name. Add the case arm, defaulting to the POSIX `$HOME/.local/share` the seed value already uses -- sshd's exec channel usually has no XDG_DATA_HOME at all.
This commit is contained in:
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SerializeResult> {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -149,7 +149,7 @@ export type PtyIpcSession = {
|
||||
sendPtySpawnedToRenderer: (id: string) => void
|
||||
requestSerializedBuffer: (
|
||||
ptyId: string,
|
||||
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
|
||||
opts?: { scrollbackRows?: number }
|
||||
) => Promise<SerializeResult>
|
||||
shutdownProviderAndDetectExit: (
|
||||
provider: IPtyProvider,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, unknown> | 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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -126,7 +126,7 @@ export type RuntimePtyController = {
|
||||
}>
|
||||
serializeBuffer?(
|
||||
ptyId: string,
|
||||
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
|
||||
opts?: { scrollbackRows?: number }
|
||||
): Promise<{
|
||||
data: string
|
||||
cols: number
|
||||
|
||||
@@ -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
|
||||
`
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string | null> {
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:<uuid>` 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) => {
|
||||
|
||||
@@ -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:<uuid>` 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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>,
|
||||
env: Record<string, string> | 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<string, unknown>,
|
||||
env: Record<string, string> | 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<string, string> | 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:<uuid>` 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<string, string> | 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)
|
||||
|
||||
@@ -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:<uuid>` 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<string, string>
|
||||
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)() }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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<ExecutionHostScope>(defaultExecutionHostScope)
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { IDisposable } from '@xterm/xterm'
|
||||
|
||||
export type SerializeOpts = {
|
||||
scrollbackRows?: number
|
||||
altScreenForcesZeroRows?: boolean
|
||||
}
|
||||
|
||||
export type SerializedBuffer = {
|
||||
|
||||
+12
-14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user