Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
c4db3ca51d
commit
d8df9c818f
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue