mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
Validate terminal startup CWD paths against symlink escapes
Resolve terminal CWD and worktree paths to their canonical form using realpath before validating containment. This blocks symlink escape routes for local terminal spawns, returning the default workspace directory if a requested path attempts to escape the worktree. - Local terminal paths are canonicalized using native realpath logic - WSL UNC paths and SSH/remote terminals skip canonicalization - Floating terminal startup directories bypass containment checks - Missing or unresolvable workspace directories default safely
This commit is contained in:
+41
-14
@@ -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' }
|
||||
})
|
||||
|
||||
|
||||
+9
-4
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<RuntimeMobileSessionCreateTerminalResult> {
|
||||
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<TerminalWorkspaceLaunchScope, 'path' | 'connectionId'>,
|
||||
requestedCwd?: string | null
|
||||
): string | undefined {
|
||||
return resolveTerminalStartupCwd(workspace.path, requestedCwd, {
|
||||
canonicalizePath: localTerminalCwdCanonicalizer(workspace.connectionId)
|
||||
})
|
||||
}
|
||||
|
||||
private async resolveTerminalWorkspaceLaunchScope(
|
||||
selector: string
|
||||
): Promise<TerminalWorkspaceLaunchScope> {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user