Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 53 additions & 25 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ import { createWebClientServer } from './web-client-server'
import { getOrCreateWsToken, rotateWsToken } from './ws-token'
import { networkInterfaces } from 'os'
import type { Server as HttpServer } from 'http'
import type { ServerTransport } from '../shared/transport/transport'
import type { ServerTransport, ConnectionContext } from '../shared/transport/transport'
import { detectRuntime } from './paths'
import { fixPathFromLoginShell } from './path-fix'
import { ChatStartGate } from './chat-start-gate'
import { createSingleFlight } from './single-flight'
import { createSingleFlight } from '../shared/single-flight'
import { isWithinRoots, ownedPathsOnly } from './own-paths'
import {
knownClaudeConfigDirs,
onShellEnvCaptured,
Expand Down Expand Up @@ -1974,6 +1975,32 @@ function registerIpcHandlers(): void {
}
)

// Worktree reads only run for paths this backend hosts. A misrouted
// request carries another machine's path, and git spawned there blocks
// main — about a second per call under macOS's /home automount. See
// own-paths.ts.
const refusedForeignPaths = new Set<string>()
const isOwnPath = (path: string): boolean => {
const { worktrees, terminals } = store.getSnapshot().state
const owned = isWithinRoots(path, worktrees.list.map((w) => w.path)) ||
isWithinRoots(path, worktrees.repoRoots) ||
isWithinRoots(path, Object.keys(terminals.panes))
if (!owned && !refusedForeignPaths.has(path)) {
refusedForeignPaths.add(path)
log('ipc', `refusing worktree read for a path this backend does not host: ${path}`)
}
return owned
}
// Every handler that takes a worktree path first and reaches git or the
// disk registers through these two, so a new one can't skip the check.
// Store-only handlers (panes, aliases, pins) don't need it: a foreign
// path there costs nothing.
type WorktreeHandler = (ctx: ConnectionContext, worktreePath: string, ...rest: any[]) => unknown // eslint-disable-line @typescript-eslint/no-explicit-any
const onWorktreeRequest = (name: string, handler: WorktreeHandler): void =>
transport.onRequest(name, ownedPathsOnly(isOwnPath, handler))
const onWorktreeSignal = (name: string, handler: WorktreeHandler): void =>
transport.onSignal(name, ownedPathsOnly(isOwnPath, handler, 'drop'))

// Identical concurrent asks share one `git status`. A client that re-sends
// the same sweep on every render (the 2.18 Cleanup modal did, once per
// streamed token) otherwise queues thousands of reads, and each spawn
Expand All @@ -1982,7 +2009,7 @@ function registerIpcHandlers(): void {
// Keyed on priority too, so a bulk sweep never hands its queued answer
// to an interactive caller.
const dirtyFlight = createSingleFlight<boolean>()
transport.onRequest('worktree:isDirty', async (_ctx, path: string, opts?: { bulk?: boolean }) => {
onWorktreeRequest('worktree:isDirty', async (_ctx, path: string, opts?: { bulk?: boolean }) => {
const priority = opts?.bulk ? 'bulk' : 'interactive'
const git = await dirtyFlight(`${priority}\0${path}`, () => isWorktreeDirty(path, priority))
const scratchpad = hasScratchpadNote(store.getSnapshot().state.scratchpad, path)
Expand Down Expand Up @@ -2117,11 +2144,11 @@ function registerIpcHandlers(): void {
})

// Changed files
transport.onRequest('worktree:changedFiles', async (_ctx, worktreePath: string, mode?: 'working' | 'branch') => {
onWorktreeRequest('worktree:changedFiles', async (_ctx, worktreePath: string, mode?: 'working' | 'branch') => {
return getChangedFiles(worktreePath, mode ?? 'working')
})

transport.onSignal('worktree:watchChangedFiles', (ctx, worktreePath: string) => {
onWorktreeSignal('worktree:watchChangedFiles', (ctx, worktreePath: string) => {
if (!worktreePath) return
let subs = worktreeWatchSubs.get(ctx.clientId)
if (!subs) {
Expand All @@ -2148,7 +2175,7 @@ function registerIpcHandlers(): void {
// (worktreePath, relativePath) tuple when the absolute file changes on
// disk. Used by FileView and working-tree ReviewDiffPane to refresh
// when an agent (or any external tool) edits the same file.
transport.onSignal(
onWorktreeSignal(
'file:watchSubscribe',
(ctx, worktreePath: string, relativePath: string) => {
if (!worktreePath || !relativePath) return
Expand Down Expand Up @@ -2181,7 +2208,7 @@ function registerIpcHandlers(): void {
}
)

transport.onRequest(
onWorktreeRequest(
'worktree:fileDiff',
async (_ctx,
worktreePath: string,
Expand All @@ -2193,33 +2220,33 @@ function registerIpcHandlers(): void {
}
)

transport.onRequest('worktree:listFiles', async (_ctx, worktreePath: string) => {
onWorktreeRequest('worktree:listFiles', async (_ctx, worktreePath: string) => {
return listAllFiles(worktreePath)
})

transport.onRequest('worktree:recentCommitShas', async (_ctx, worktreePath: string) => {
onWorktreeRequest('worktree:recentCommitShas', async (_ctx, worktreePath: string) => {
return listRecentCommitShas(worktreePath)
})

transport.onRequest('worktree:readFile', async (_ctx, worktreePath: string, filePath: string) => {
onWorktreeRequest('worktree:readFile', async (_ctx, worktreePath: string, filePath: string) => {
return readWorktreeFile(worktreePath, filePath)
})

transport.onRequest(
onWorktreeRequest(
'worktree:readFileBinary',
async (_ctx, worktreePath: string, filePath: string) => {
return readWorktreeFileBinary(worktreePath, filePath)
}
)

transport.onRequest(
onWorktreeRequest(
'worktree:writeFile',
async (_ctx, worktreePath: string, filePath: string, contents: string) => {
return writeWorktreeFile(worktreePath, filePath, contents)
}
)

transport.onRequest(
onWorktreeRequest(
'worktree:fileDiffSides',
async (_ctx,
worktreePath: string,
Expand All @@ -2231,49 +2258,49 @@ function registerIpcHandlers(): void {
}
)

transport.onRequest('worktree:branchCommits', async (_ctx, worktreePath: string) => {
onWorktreeRequest('worktree:branchCommits', async (_ctx, worktreePath: string) => {
return getBranchCommits(worktreePath)
})

transport.onRequest('tools:list', async (_ctx, worktreePath: string) => {
onWorktreeRequest('tools:list', async (_ctx, worktreePath: string) => {
return discoverTools(worktreePath)
})

transport.onRequest('tools:run', async (_ctx, worktreePath: string, toolId: string) => {
onWorktreeRequest('tools:run', async (_ctx, worktreePath: string, toolId: string) => {
const wt = store.getSnapshot().state.worktrees.list.find((w) => w.path === worktreePath)
return runTool(worktreePath, toolId, {
branch: wt?.branch ?? '',
repoRoot: wt?.repoRoot ?? worktreePath
})
})

transport.onRequest('worktree:commitDiff', async (_ctx, worktreePath: string, hash: string) => {
onWorktreeRequest('worktree:commitDiff', async (_ctx, worktreePath: string, hash: string) => {
return getCommitDiff(worktreePath, hash)
})

transport.onRequest('worktree:commitMeta', async (_ctx, worktreePath: string, hash: string) => {
onWorktreeRequest('worktree:commitMeta', async (_ctx, worktreePath: string, hash: string) => {
return getCommitMeta(worktreePath, hash)
})

transport.onRequest('worktree:commitChangedFiles', async (_ctx, worktreePath: string, hash: string) => {
onWorktreeRequest('worktree:commitChangedFiles', async (_ctx, worktreePath: string, hash: string) => {
return getCommitChangedFiles(worktreePath, hash)
})

transport.onRequest(
onWorktreeRequest(
'worktree:commitFileDiffSides',
async (_ctx, worktreePath: string, hash: string, filePath: string) => {
return getCommitFileDiffSides(worktreePath, hash, filePath)
}
)

transport.onRequest(
onWorktreeRequest(
'worktree:commitRangeChangedFiles',
async (_ctx, worktreePath: string, fromHash: string, toHash: string) => {
return getCommitRangeChangedFiles(worktreePath, fromHash, toHash)
}
)

transport.onRequest(
onWorktreeRequest(
'worktree:commitRangeFileDiffSides',
async (_ctx, worktreePath: string, fromHash: string, toHash: string, filePath: string) => {
return getCommitRangeFileDiffSides(worktreePath, fromHash, toHash, filePath)
Expand Down Expand Up @@ -3288,7 +3315,7 @@ function registerIpcHandlers(): void {
return AVAILABLE_EDITORS.map(({ id, name }) => ({ id, name }))
})

transport.onRequest('editor:open', (_ctx, worktreePath: string, filePath?: string) => {
onWorktreeRequest('editor:open', (_ctx, worktreePath: string, filePath?: string) => {
return openInEditor(resolveEditorForWorktree(worktreePath), worktreePath, filePath)
})

Expand Down Expand Up @@ -3636,13 +3663,13 @@ function registerIpcHandlers(): void {
return true
})

transport.onRequest('agent:sessionFileExists', async (_ctx, cwd: string, sessionId: string, agentKind?: string): Promise<boolean> => {
onWorktreeRequest('agent:sessionFileExists', async (_ctx, cwd: string, sessionId: string, agentKind?: string): Promise<boolean> => {
const kind = toAgentKind(agentKind)
if (kind === 'claude') await resolveShellEnv(cwd)
return getAgent(kind).sessionFileExists(cwd, sessionId)
})

transport.onRequest('agent:latestSessionId', async (_ctx, cwd: string, agentKind?: string): Promise<string | null> => {
onWorktreeRequest('agent:latestSessionId', async (_ctx, cwd: string, agentKind?: string): Promise<string | null> => {
const kind = toAgentKind(agentKind)
if (kind === 'claude') await resolveShellEnv(cwd)
return getAgent(kind).latestSessionId(cwd)
Expand All @@ -3656,6 +3683,7 @@ function registerIpcHandlers(): void {
sessionName?: string;
modelOverride?: string
}): Promise<string> => {
if (!isOwnPath(opts.cwd)) throw new Error(`Not a worktree on this host: ${opts.cwd}`)
const kind = toAgentKind(agentKind)
const agent = getAgent(kind)
// --resume vs --session-id is decided by looking for the transcript
Expand Down
49 changes: 49 additions & 0 deletions src/main/own-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest'
import { isWithinRoots, ownedPathsOnly } from './own-paths'

describe('isWithinRoots', () => {
const roots = ['/Users/me/repo', '/Users/me/repo-worktrees/feat']

it('accepts a root itself and paths inside one', () => {
expect(isWithinRoots('/Users/me/repo', roots)).toBe(true)
expect(isWithinRoots('/Users/me/repo/src/a.ts', roots)).toBe(true)
expect(isWithinRoots('/Users/me/repo-worktrees/feat', roots)).toBe(true)
})

it("rejects another host's path", () => {
expect(isWithinRoots('/home/me/personal-assistant', roots)).toBe(false)
})

it('does not treat a shared name prefix as containment', () => {
expect(isWithinRoots('/Users/me/repo-other', roots)).toBe(false)
})

it('handles roots with a trailing slash, and empty input', () => {
expect(isWithinRoots('/a/b/c', ['/a/b/'])).toBe(true)
expect(isWithinRoots('', roots)).toBe(false)
expect(isWithinRoots('/a', [''])).toBe(false)
})
})

describe('ownedPathsOnly', () => {
const isOwn = (p: string) => isWithinRoots(p, ['/Users/me/repo'])

it('runs the handler for an owned path, passing every argument through', () => {
const handler = vi.fn((_ctx: string, path: string, a: number, b: string) => `${path}:${a}:${b}`)
const guarded = ownedPathsOnly(isOwn, handler)
expect(guarded('ctx', '/Users/me/repo/src', 1, 'x')).toBe('/Users/me/repo/src:1:x')
expect(handler).toHaveBeenCalledWith('ctx', '/Users/me/repo/src', 1, 'x')
})

it('rejects a foreign path without running the handler', () => {
const handler = vi.fn()
expect(() => ownedPathsOnly(isOwn, handler)('ctx', '/home/me/repo')).toThrow(/Not a worktree on this host/)
expect(handler).not.toHaveBeenCalled()
})

it('drops a foreign path silently in signal mode', () => {
const handler = vi.fn()
expect(ownedPathsOnly(isOwn, handler, 'drop')('ctx', '/home/me/repo')).toBeUndefined()
expect(handler).not.toHaveBeenCalled()
})
})
42 changes: 42 additions & 0 deletions src/main/own-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Whether a path belongs to this backend: equal to, or inside, one of its
// worktrees or repo roots.
//
// A client that routes a worktree request to the wrong backend sends a
// path that doesn't exist on this machine. Running git there isn't just a
// wasted call: spawn blocks the main thread until the child has entered
// its cwd, and on macOS `/home` is an autofs mount, so a Linux remote's
// `/home/<user>/…` path costs about a second of frozen main per spawn
// while the automounter gives up. A panel polling a remote worktree
// against the local backend beach-balled the app that way. This check is
// pure string work — it must never touch the filesystem, or it would hit
// the same automounter it exists to avoid.
export function isWithinRoots(path: string, roots: Iterable<string>): boolean {
if (!path) return false
for (const root of roots) {
if (!root) continue
if (path === root) return true
const prefix = root.endsWith('/') ? root : `${root}/`
if (path.startsWith(prefix)) return true
}
return false
}

/** Wraps a handler whose first argument after the context is a worktree
* path so it only runs for paths `isOwn` accepts. A request handler
* rejects a foreign path; a signal handler (`mode: 'drop'`) ignores it,
* since a signal has no caller to report to. Every worktree handler that
* reaches git or the disk registers through this, so a new one can't
* forget the check. */
export function ownedPathsOnly<C, R>(
isOwn: (path: string) => boolean,
handler: (ctx: C, path: string, ...rest: any[]) => R, // eslint-disable-line @typescript-eslint/no-explicit-any
mode: 'reject' | 'drop' = 'reject'
): (ctx: C, path: string, ...rest: any[]) => R | undefined { // eslint-disable-line @typescript-eslint/no-explicit-any
return (ctx, path, ...rest) => {
if (!isOwn(path)) {
if (mode === 'drop') return undefined
throw new Error(`Not a worktree on this host: ${path}`)
}
return handler(ctx, path, ...rest)
}
}
Loading
Loading