diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 71f742a7149..1f6137296ab 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -9,6 +9,7 @@ import { TERMINAL_INPUT_MAX_BYTES } from '../../shared/terminal-input' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it @@ -1307,6 +1308,7 @@ describe('registerPtyHandlers', () => { // from main and PR #2662 command threading for OMP target selection. spawnArgs?: { cwd?: string + worktreeId?: string shellOverride?: string command?: string envToDelete?: string[] @@ -1500,7 +1502,10 @@ describe('registerPtyHandlers', () => { CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home', ORCA_CODEX_HOME: 'C:\\Users\\test\\AppData\\Roaming\\Orca\\codex-runtime-home\\home' }, - { cwd: '\\\\wsl.localhost\\Ubuntu\\home\\test\\repo' } + { + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\test\\repo', + worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\test\\repo' + } ) const { env } = spawnOptions expect(env.CODEX_HOME).toBeUndefined() @@ -4348,7 +4353,7 @@ describe('registerPtyHandlers', () => { cols: 80, rows: 24, cwd: '/tmp', - worktreeId: 'wt-1', + worktreeId: 'repo-1::/tmp', tabId: 'tab-race', leafId, env: { ORCA_PANE_KEY: paneKey }, @@ -4362,12 +4367,12 @@ describe('registerPtyHandlers', () => { cols: 80, rows: 24, cwd: '/tmp', - worktreeId: 'wt-1', + worktreeId: 'repo-1::/tmp', tabId: 'tab-race', leafId, env: { ORCA_TAB_ID: 'tab-race', - ORCA_WORKTREE_ID: 'wt-1' + ORCA_WORKTREE_ID: 'repo-1::/tmp' } }) as Promise<{ id: string }> await Promise.resolve() @@ -4380,7 +4385,7 @@ describe('registerPtyHandlers', () => { ]) expect(providerSpawn).toHaveBeenCalledTimes(1) expect(store.persistPtyBinding).toHaveBeenCalledWith({ - worktreeId: 'wt-1', + worktreeId: 'repo-1::/tmp', tabId: 'tab-race', leafId, ptyId: 'pty-shared', @@ -4462,13 +4467,13 @@ describe('registerPtyHandlers', () => { cols: 80, rows: 24, cwd: '/tmp', - worktreeId: 'wt-1', + worktreeId: 'repo-1::/tmp', tabId: 'tab-race', leafId, env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: 'tab-race', - ORCA_WORKTREE_ID: 'wt-1' + ORCA_WORKTREE_ID: 'repo-1::/tmp' } }) as Promise<{ id: string }> await Promise.resolve() @@ -4478,7 +4483,7 @@ describe('registerPtyHandlers', () => { cols: 80, rows: 24, cwd: '/tmp', - worktreeId: 'wt-1', + worktreeId: 'repo-1::/tmp', tabId: 'tab-race', leafId, env: { ORCA_PANE_KEY: paneKey }, @@ -4494,7 +4499,7 @@ describe('registerPtyHandlers', () => { ]) expect(providerSpawn).toHaveBeenCalledTimes(1) expect(store.persistPtyBinding).toHaveBeenCalledWith({ - worktreeId: 'wt-1', + worktreeId: 'repo-1::/tmp', tabId: 'tab-race', leafId, ptyId: 'pty-renderer', @@ -5838,6 +5843,22 @@ describe('registerPtyHandlers', () => { }) }) + it('passes floating terminal cwds through to the spawned shell', async () => { + // Why: the floating sentinel has no worktree root; its cwd is validated + // against trusted-directory grants before it reaches pty:spawn. + registerPtyHandlers(mainWindow as never) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp/floating-notes', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID + }) + + const [, , options] = spawnMock.mock.calls.at(-1) as [string, string[], { cwd: string }] + expect(options.cwd).toBe('/tmp/floating-notes') + }) + it('rejects missing WSL worktree cwd instead of validating only the fallback Windows cwd', async () => { const originalPlatform = process.platform const originalUserProfile = process.env.USERPROFILE @@ -5848,8 +5869,10 @@ describe('registerPtyHandlers', () => { }) process.env.USERPROFILE = 'C:\\Users\\jinwo' + // Why: the startup-cwd guard normalizes separators, so the provider sees + // the forward-slash UNC form. existsSyncMock.mockImplementation((targetPath: string) => { - if (targetPath === '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing') { + if (targetPath === '//wsl.localhost/Ubuntu/home/jin/missing') { return false } return true @@ -5862,10 +5885,11 @@ describe('registerPtyHandlers', () => { handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing' + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing', + worktreeId: 'repo-1::\\\\wsl.localhost\\Ubuntu\\home\\jin' }) ).rejects.toThrow( - 'Working directory "\\\\wsl.localhost\\Ubuntu\\home\\jin\\missing" does not exist.' + 'Working directory "//wsl.localhost/Ubuntu/home/jin/missing" does not exist.' ) expect(spawnMock).not.toHaveBeenCalled() } finally { @@ -7352,7 +7376,8 @@ describe('registerPtyHandlers', () => { const result = await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, - cwd: '/tmp' + cwd: '/tmp', + worktreeId: 'repo-1::/tmp' }) expect(result).toEqual({ id: expect.any(String), pid: 12345 }) @@ -7392,7 +7417,8 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, - cwd: '/tmp' + cwd: '/tmp', + worktreeId: 'repo-1::/tmp' }) expect(spawnMock).toHaveBeenCalledTimes(1) @@ -7708,6 +7734,7 @@ describe('registerPtyHandlers', () => { cols: 80, rows: 24, cwd: '/tmp', + worktreeId: 'repo-1::/tmp', env: { SHELL: '/opt/homebrew/bin/bash' } }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 9538f246415..8a2c109f744 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -78,6 +78,7 @@ import { parsePaneKey } from '../../shared/stable-pane-id' import { resolveTerminalStartupCwdForWorkspace } from '../../shared/terminal-startup-cwd' +import { localTerminalCwdCanonicalizer } from '../pty/terminal-cwd-realpath' import { clearMigrationUnsupportedPty, clearMigrationUnsupportedPtysForPaneKey @@ -1975,13 +1976,17 @@ export function registerPtyHandlers( const resolveGuardedPtySpawnCwd = ( worktreeId: string | undefined, - cwd: string | undefined + cwd: string | undefined, + connectionId?: string | null ): string | undefined => resolveTerminalStartupCwdForWorkspace({ workspaceId: worktreeId, requestedCwd: cwd, resolveFolderWorkspacePath: (folderWorkspaceId) => - store?.getFolderWorkspace(folderWorkspaceId)?.folderPath + store?.getFolderWorkspace(folderWorkspaceId)?.folderPath, + // Why: realpath only makes sense on the local filesystem; SSH worktree + // paths live on the remote host. + canonicalizePath: localTerminalCwdCanonicalizer(connectionId) }) // Why: the runtime controller must route through getProviderForPty() so that @@ -1994,7 +1999,7 @@ export function registerPtyHandlers( await startupPromise } await assertFolderWorkspacePtyPathUsable(args.worktreeId) - const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd) + const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd, args.connectionId) const provider = getProvider(args.connectionId) const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { @@ -2574,7 +2579,7 @@ export function registerPtyHandlers( await startupPromise } await assertFolderWorkspacePtyPathUsable(args.worktreeId) - const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd) + const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd, args.connectionId) spawnTiming.mark('preflight') const provider = getProvider(args.connectionId) const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) diff --git a/src/main/pty/terminal-cwd-realpath.test.ts b/src/main/pty/terminal-cwd-realpath.test.ts new file mode 100644 index 00000000000..47110648b19 --- /dev/null +++ b/src/main/pty/terminal-cwd-realpath.test.ts @@ -0,0 +1,43 @@ +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) + }) +}) diff --git a/src/main/pty/terminal-cwd-realpath.ts b/src/main/pty/terminal-cwd-realpath.ts new file mode 100644 index 00000000000..f9131d9f01a --- /dev/null +++ b/src/main/pty/terminal-cwd-realpath.ts @@ -0,0 +1,30 @@ +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 +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5ae0c67d065..f349ec06b35 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -202,6 +202,7 @@ 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, @@ -15487,7 +15488,7 @@ export class OrcaRuntimeService { throw new Error('runtime_unavailable') } const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) - const cwd = resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path + const cwd = this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.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 @@ -15672,7 +15673,7 @@ export class OrcaRuntimeService { ? await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) : null const worktreeId = workspace?.id - const cwd = workspace ? resolveTerminalStartupCwd(workspace.path, opts.cwd) : opts.cwd + const cwd = workspace ? this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.cwd) : opts.cwd const requestId = randomUUID() // Why: terminal creation is a renderer-side Zustand store operation (like @@ -15815,7 +15816,7 @@ export class OrcaRuntimeService { this.assertGraphReady() const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) const worktreeId = workspace.id - const cwd = resolveTerminalStartupCwd(workspace.path, opts.cwd) + const cwd = this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.cwd) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) let afterDesktopTabId: string | undefined if (opts.afterTabId) { @@ -16019,7 +16020,7 @@ export class OrcaRuntimeService { } = {} ): Promise { const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) - const cwd = resolveTerminalStartupCwd(workspace.path, opts.cwd) + const cwd = this.resolveGuardedWorkspaceTerminalCwd(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 = @@ -16888,6 +16889,17 @@ 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, + requestedCwd?: string | null + ): string | undefined { + return resolveTerminalStartupCwd(workspace.path, requestedCwd, { + canonicalizePath: localTerminalCwdCanonicalizer(workspace.connectionId) + }) + } + private async resolveTerminalWorkspaceLaunchScope( selector: string ): Promise { diff --git a/src/shared/terminal-startup-cwd.test.ts b/src/shared/terminal-startup-cwd.test.ts index e6ac5575ceb..f87ef7d51ce 100644 --- a/src/shared/terminal-startup-cwd.test.ts +++ b/src/shared/terminal-startup-cwd.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' import { resolveTerminalStartupCwd, resolveTerminalStartupCwdForWorkspace @@ -28,6 +29,31 @@ describe('resolveTerminalStartupCwd', () => { ) }) + 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('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', () => { expect(resolveTerminalStartupCwd('C:\\Repo\\App', 'packages\\web')).toBe( 'C:/Repo/App/packages/web' @@ -52,6 +78,32 @@ describe('resolveTerminalStartupCwd', () => { ).toThrow('Terminal cwd must be inside the selected worktree.') }) + 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. + expect( + resolveTerminalStartupCwdForWorkspace({ + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + requestedCwd: '/Volumes/work/notes' + }) + ).toBe('/Volumes/work/notes') + }) + + it('refuses the requested cwd when no workspace root is resolvable', () => { + expect( + resolveTerminalStartupCwdForWorkspace({ + workspaceId: undefined, + requestedCwd: '/anywhere' + }) + ).toBeUndefined() + expect( + resolveTerminalStartupCwdForWorkspace({ + workspaceId: 'opaque-worktree-id', + requestedCwd: '/anywhere' + }) + ).toBeUndefined() + }) + it('validates renderer PTY cwd values against folder workspace keys', () => { expect( resolveTerminalStartupCwdForWorkspace({ diff --git a/src/shared/terminal-startup-cwd.ts b/src/shared/terminal-startup-cwd.ts index d71a0f7c37d..b71b737fa43 100644 --- a/src/shared/terminal-startup-cwd.ts +++ b/src/shared/terminal-startup-cwd.ts @@ -1,20 +1,44 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' import { isPathInsideOrEqual, 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 + requestedCwd?: string | null, + options?: TerminalStartupCwdOptions ): string | undefined { - if (!requestedCwd || requestedCwd.trim().length === 0) { + const trimmedCwd = requestedCwd?.trim() + if (!trimmedCwd) { return undefined } - const resolvedCwd = resolveRuntimePath(worktreePath, requestedCwd) + 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 } @@ -22,18 +46,30 @@ 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. + return args.requestedCwd + } const workspacePath = resolveTerminalWorkspacePath( args.workspaceId, args.resolveFolderWorkspacePath ) if (!workspacePath) { - return args.requestedCwd + // 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. + return undefined } - return resolveTerminalStartupCwd(workspacePath, args.requestedCwd) + return resolveTerminalStartupCwd(workspacePath, args.requestedCwd, { + canonicalizePath: args.canonicalizePath + }) } function resolveTerminalWorkspacePath(