fix(terminal): allow terminals to spawn outside the worktree (#7685) (#7750)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-07-07 15:34:29 -07:00 committed by GitHub
parent c4db3ca51d
commit d8df9c818f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 52 additions and 178 deletions

View File

@ -83,7 +83,6 @@ import {
} from '../../shared/stable-pane-id'
import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
import { resolveTerminalStartupCwdForWorkspace } from '../../shared/terminal-startup-cwd'
import { localTerminalCwdCanonicalizer } from '../pty/terminal-cwd-realpath'
import {
clearMigrationUnsupportedPty,
clearMigrationUnsupportedPtysForPaneKey
@ -2043,19 +2042,15 @@ export function registerPtyHandlers(
assertFolderWorkspacePathUsable(status)
}
const resolveGuardedPtySpawnCwd = (
const resolvePtySpawnStartupCwd = (
worktreeId: string | undefined,
cwd: string | undefined,
connectionId?: string | null
cwd: string | undefined
): string | undefined =>
resolveTerminalStartupCwdForWorkspace({
workspaceId: worktreeId,
requestedCwd: cwd,
resolveFolderWorkspacePath: (folderWorkspaceId) =>
store?.getFolderWorkspace(folderWorkspaceId)?.folderPath,
// Why: realpath only makes sense on the local filesystem; SSH worktree
// paths live on the remote host.
canonicalizePath: localTerminalCwdCanonicalizer(connectionId)
store?.getFolderWorkspace(folderWorkspaceId)?.folderPath
})
// Why: the runtime controller must route through getProviderForPty() so that
@ -2068,7 +2063,7 @@ export function registerPtyHandlers(
await startupPromise
}
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd, args.connectionId)
const cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd)
const provider = getProvider(args.connectionId)
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
@ -2690,7 +2685,7 @@ export function registerPtyHandlers(
await startupPromise
}
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd, args.connectionId)
const cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd)
spawnTiming.mark('preflight')
const provider = getProvider(args.connectionId)
const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command)

View File

@ -1,43 +0,0 @@
import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import {
canonicalizeLocalTerminalPath,
localTerminalCwdCanonicalizer
} from './terminal-cwd-realpath'
describe('canonicalizeLocalTerminalPath', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-cwd-realpath-'))
afterAll(() => {
rmSync(root, { recursive: true, force: true })
})
it('resolves symlinks to their target', () => {
const outside = join(root, 'outside')
const worktree = join(root, 'worktree')
mkdirSync(outside)
mkdirSync(worktree)
const link = join(worktree, 'escape')
symlinkSync(outside, link, 'dir')
expect(canonicalizeLocalTerminalPath(link)).toBe(canonicalizeLocalTerminalPath(outside))
})
it('returns null for nonexistent paths', () => {
expect(canonicalizeLocalTerminalPath(join(root, 'does-not-exist'))).toBeNull()
})
it('skips WSL UNC paths instead of touching the 9P share', () => {
expect(canonicalizeLocalTerminalPath('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo')).toBeNull()
expect(canonicalizeLocalTerminalPath('//wsl$/Ubuntu/home/jin/repo')).toBeNull()
})
})
describe('localTerminalCwdCanonicalizer', () => {
it('is disabled for SSH connections and enabled locally', () => {
expect(localTerminalCwdCanonicalizer('ssh-1')).toBeUndefined()
expect(localTerminalCwdCanonicalizer(null)).toBe(canonicalizeLocalTerminalPath)
expect(localTerminalCwdCanonicalizer(undefined)).toBe(canonicalizeLocalTerminalPath)
})
})

View File

@ -1,30 +0,0 @@
import { realpathSync } from 'node:fs'
import { isWslUncPath } from '../../shared/wsl-paths'
// Why: terminal startup cwd containment is string-based; realpath closes the
// symlink escape for local paths. Only for local worktrees — SSH/remote paths
// are not resolvable on this filesystem.
export function canonicalizeLocalTerminalPath(targetPath: string): string | null {
// Why: realpath over the WSL 9P share is unreliable and can block the main
// process while the distro boots; keep the string containment check only.
if (isWslUncPath(targetPath)) {
return null
}
try {
return realpathSync.native(targetPath)
} catch {
try {
// Why: realpathSync.native can fail on network/UNC mounts where the
// JS implementation still succeeds (same fallback as git repo scanning).
return realpathSync(targetPath)
} catch {
return null
}
}
}
export function localTerminalCwdCanonicalizer(
connectionId: string | null | undefined
): ((path: string) => string | null) | undefined {
return connectionId ? undefined : canonicalizeLocalTerminalPath
}

View File

@ -52,7 +52,7 @@ describe('OrcaRuntimeService terminal startup cwd', () => {
)
})
it('rejects requested terminal cwd values outside the selected worktree', async () => {
it('spawns terminals at a requested cwd outside the selected worktree (#7685)', async () => {
const runtime = new OrcaRuntimeService()
stubLaunchScope(runtime)
const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' })
@ -63,10 +63,14 @@ describe('OrcaRuntimeService terminal startup cwd', () => {
getForegroundProcess: async () => null
})
await expect(runtime.createTerminal('id:wt-1', { cwd: '/repo/app-other' })).rejects.toThrow(
'Terminal cwd must be inside the selected worktree.'
await runtime.createTerminal('id:wt-1', { cwd: '/repo/app-other' })
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
cwd: '/repo/app-other',
worktreeId: 'wt-1'
})
)
expect(spawn).not.toHaveBeenCalled()
})
it('reveals main-spawned terminals with their nested startup cwd', async () => {

View File

@ -209,7 +209,6 @@ import {
normalizeRuntimePathForComparison
} from '../../shared/cross-platform-path'
import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd'
import { localTerminalCwdCanonicalizer } from '../pty/terminal-cwd-realpath'
import { isWslUncPath } from '../../shared/wsl-paths'
import {
folderWorkspaceKey,
@ -15815,7 +15814,7 @@ export class OrcaRuntimeService {
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts)
const cwd =
this.resolveGuardedWorkspaceTerminalCwd(workspace, launchOpts.cwd) ?? workspace.path
this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
// Why: mint tabId in main before spawn so paneKey is known at PTY env
// build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys
@ -16008,7 +16007,7 @@ export class OrcaRuntimeService {
: opts
const worktreeId = workspace?.id
const cwd = workspace
? this.resolveGuardedWorkspaceTerminalCwd(workspace, launchOpts.cwd)
? this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd)
: launchOpts.cwd
const requestId = randomUUID()
@ -16152,7 +16151,7 @@ export class OrcaRuntimeService {
this.assertGraphReady()
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
const worktreeId = workspace.id
const cwd = this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.cwd)
const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd)
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
let afterDesktopTabId: string | undefined
if (opts.afterTabId) {
@ -16389,7 +16388,7 @@ export class OrcaRuntimeService {
} = {}
): Promise<RuntimeMobileSessionCreateTerminalResult> {
const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
const cwd = this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.cwd)
const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd)
// Why: SshPtyProvider treats sessionId as a relay reattach request. Only
// synthesize local serve ids; SSH fresh terminals must call pty.spawn.
const stableSessionId =
@ -17313,15 +17312,11 @@ export class OrcaRuntimeService {
}
}
// Why: every terminal-creation path must apply the same symlink-aware cwd
// guard; keep the canonicalizer wiring in one place.
private resolveGuardedWorkspaceTerminalCwd(
workspace: Pick<TerminalWorkspaceLaunchScope, 'path' | 'connectionId'>,
private resolveWorkspaceTerminalStartupCwd(
workspace: Pick<TerminalWorkspaceLaunchScope, 'path'>,
requestedCwd?: string | null
): string | undefined {
return resolveTerminalStartupCwd(workspace.path, requestedCwd, {
canonicalizePath: localTerminalCwdCanonicalizer(workspace.connectionId)
})
return resolveTerminalStartupCwd(workspace.path, requestedCwd)
}
private async resolveTerminalWorkspaceLaunchScope(

View File

@ -17,70 +17,53 @@ describe('resolveTerminalStartupCwd', () => {
expect(resolveTerminalStartupCwd('/repo/app', 'packages/web')).toBe('/repo/app/packages/web')
})
it('rejects sibling paths outside the worktree', () => {
expect(() => resolveTerminalStartupCwd('/repo/app', '/repo/app-other')).toThrow(
'Terminal cwd must be inside the selected worktree.'
)
it('allows absolute cwds outside the worktree (#7685)', () => {
// Why: opening/splitting a terminal outside the worktree (e.g. after
// `cd ..`) is allowed; the cwd is resolved, not constrained.
expect(resolveTerminalStartupCwd('/repo/app', '/repo/app-other')).toBe('/repo/app-other')
})
it('rejects parent traversal outside the worktree', () => {
expect(() => resolveTerminalStartupCwd('/repo/app', '../other')).toThrow(
'Terminal cwd must be inside the selected worktree.'
)
it('resolves parent traversal to a path outside the worktree (#7685)', () => {
expect(resolveTerminalStartupCwd('/repo/app', '../other')).toBe('/repo/other')
})
it('trims whitespace-padded requested cwds before resolving', () => {
expect(resolveTerminalStartupCwd('/repo/app', ' packages/web ')).toBe('/repo/app/packages/web')
})
it('falls back to the default cwd when a symlink escapes the worktree', () => {
const canonicalize = (path: string): string | null =>
path === '/repo/app/link' ? '/outside/target' : path
expect(
resolveTerminalStartupCwd('/repo/app', 'link', { canonicalizePath: canonicalize })
).toBeUndefined()
it('returns undefined for an empty requested cwd', () => {
expect(resolveTerminalStartupCwd('/repo/app', '')).toBeUndefined()
expect(resolveTerminalStartupCwd('/repo/app', ' ')).toBeUndefined()
expect(resolveTerminalStartupCwd('/repo/app', null)).toBeUndefined()
})
it('accepts cwds under a symlinked worktree root', () => {
const canonicalize = (path: string): string | null => path.replace(/^\/tmp\//, '/private/tmp/')
expect(
resolveTerminalStartupCwd('/tmp/repo', 'packages/web', { canonicalizePath: canonicalize })
).toBe('/tmp/repo/packages/web')
})
it('skips the symlink re-check when a path cannot be canonicalized', () => {
expect(
resolveTerminalStartupCwd('/repo/app', 'missing', { canonicalizePath: () => null })
).toBe('/repo/app/missing')
})
it('handles Windows path containment without case drift', () => {
it('normalizes Windows separators and allows out-of-worktree drives', () => {
expect(resolveTerminalStartupCwd('C:\\Repo\\App', 'packages\\web')).toBe(
'C:/Repo/App/packages/web'
)
expect(() => resolveTerminalStartupCwd('C:\\Repo\\App', 'C:\\Repo\\AppOther')).toThrow(
'Terminal cwd must be inside the selected worktree.'
expect(resolveTerminalStartupCwd('C:\\Repo\\App', 'C:\\Repo\\AppOther')).toBe(
'C:/Repo/AppOther'
)
})
it('validates renderer PTY cwd values against raw worktree IDs', () => {
it('resolves renderer PTY cwd values against raw worktree IDs', () => {
expect(
resolveTerminalStartupCwdForWorkspace({
workspaceId: 'repo-1::/repo/app',
requestedCwd: '/repo/app/packages/web'
})
).toBe('/repo/app/packages/web')
expect(() =>
expect(
resolveTerminalStartupCwdForWorkspace({
workspaceId: 'repo-1::/repo/app',
requestedCwd: '/repo/app-other'
})
).toThrow('Terminal cwd must be inside the selected worktree.')
).toBe('/repo/app-other')
})
it('passes floating terminal cwds through untouched', () => {
// Why: floating terminal cwds are validated against trusted-directory
// grants in main and have no worktree root to contain within.
// grants in main and have no worktree root to resolve against.
expect(
resolveTerminalStartupCwdForWorkspace({
workspaceId: FLOATING_TERMINAL_WORKTREE_ID,
@ -89,7 +72,7 @@ describe('resolveTerminalStartupCwd', () => {
).toBe('/Volumes/work/notes')
})
it('refuses the requested cwd when no workspace root is resolvable', () => {
it('falls back to the provider default when no workspace root is resolvable', () => {
expect(
resolveTerminalStartupCwdForWorkspace({
workspaceId: undefined,
@ -104,7 +87,7 @@ describe('resolveTerminalStartupCwd', () => {
).toBeUndefined()
})
it('validates renderer PTY cwd values against folder workspace keys', () => {
it('resolves renderer PTY cwd values against folder workspace keys', () => {
expect(
resolveTerminalStartupCwdForWorkspace({
workspaceId: folderWorkspaceKey('folder-1'),
@ -112,12 +95,12 @@ describe('resolveTerminalStartupCwd', () => {
resolveFolderWorkspacePath: (id) => (id === 'folder-1' ? '/repo/app' : null)
})
).toBe('/repo/app/packages/web')
expect(() =>
expect(
resolveTerminalStartupCwdForWorkspace({
workspaceId: folderWorkspaceKey('folder-1'),
requestedCwd: '../other',
resolveFolderWorkspacePath: (id) => (id === 'folder-1' ? '/repo/app' : null)
})
).toThrow('Terminal cwd must be inside the selected worktree.')
).toBe('/repo/other')
})
})

View File

@ -1,60 +1,33 @@
import { FLOATING_TERMINAL_WORKTREE_ID } from './constants'
import { isPathInsideOrEqual, resolveRuntimePath } from './cross-platform-path'
import { resolveRuntimePath } from './cross-platform-path'
import { parseWorkspaceKey } from './workspace-scope'
import { splitWorktreeIdForFilesystem } from './worktree-id'
export type TerminalStartupCwdOptions = {
// Why: the string containment check can't see symlinks; only local callers
// can canonicalize — SSH worktree paths live on the remote host.
canonicalizePath?: (path: string) => string | null
}
export function resolveTerminalStartupCwd(
worktreePath: string,
requestedCwd?: string | null,
options?: TerminalStartupCwdOptions
requestedCwd?: string | null
): string | undefined {
const trimmedCwd = requestedCwd?.trim()
if (!trimmedCwd) {
return undefined
}
const resolvedCwd = resolveRuntimePath(worktreePath, trimmedCwd)
if (!isPathInsideOrEqual(worktreePath, resolvedCwd)) {
// Why: remote/session clients can request terminal cwd; never let that
// become a shell outside the selected workspace.
throw new Error('Terminal cwd must be inside the selected worktree.')
}
const canonicalizePath = options?.canonicalizePath
if (canonicalizePath) {
const canonicalWorktreePath = canonicalizePath(worktreePath)
const canonicalCwd = canonicalizePath(resolvedCwd)
if (
canonicalWorktreePath &&
canonicalCwd &&
!isPathInsideOrEqual(canonicalWorktreePath, canonicalCwd)
) {
// Why: a symlink escaping the worktree can be legitimate (e.g. a pnpm
// store link), so fall back to the default cwd instead of failing the
// spawn — but never grant the requested out-of-worktree shell.
return undefined
}
}
return resolvedCwd
// Why: resolve relative requests against the worktree root and normalize
// `..`; the cwd is intentionally not constrained to the worktree, so opening
// or splitting a terminal outside it (e.g. after `cd ..`) is allowed. (#7685)
return resolveRuntimePath(worktreePath, trimmedCwd)
}
export function resolveTerminalStartupCwdForWorkspace(args: {
workspaceId?: string
requestedCwd?: string | null
resolveFolderWorkspacePath?: (folderWorkspaceId: string) => string | null | undefined
canonicalizePath?: (path: string) => string | null
}): string | undefined {
if (!args.requestedCwd || args.requestedCwd.trim().length === 0) {
return undefined
}
if (args.workspaceId === FLOATING_TERMINAL_WORKTREE_ID) {
// Why: floating terminals have no worktree root to contain within; their
// cwd was already validated against the trusted-directory grants in
// resolveFloatingTerminalCwd.
// Why: floating terminals have no worktree root; their cwd was already
// resolved against the trusted-directory grants in resolveFloatingTerminalCwd.
return args.requestedCwd
}
const workspacePath = resolveTerminalWorkspacePath(
@ -62,14 +35,11 @@ export function resolveTerminalStartupCwdForWorkspace(args: {
args.resolveFolderWorkspacePath
)
if (!workspacePath) {
// Why: without a resolvable workspace root we cannot enforce the
// in-worktree containment check, so refuse the requested cwd rather
// than trusting an unvalidated path.
// Why: without a worktree root we can't anchor a relative request, so fall
// back to the provider default rather than guessing a base.
return undefined
}
return resolveTerminalStartupCwd(workspacePath, args.requestedCwd, {
canonicalizePath: args.canonicalizePath
})
return resolveTerminalStartupCwd(workspacePath, args.requestedCwd)
}
function resolveTerminalWorkspacePath(