mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
* perf(git): stop blocking main on the open-on-remote git cascade
`getRemoteFileUrl` ran up to 6 sequential `gitExecFileSync` calls on the Electron
main thread — `remote get-url`, then `getDefaultBaseRef`'s `symbolic-ref` plus up
to four `rev-parse --verify` probes — each with its own 15s timeout and no yield
between them.
A complete async twin already existed (`getDefaultBaseRefAsync` ->
`resolveDefaultBaseRefViaExec`, sharing DEFAULT_BASE_REF_PROBES), so the sync
cascade is deleted rather than converted. `getRemoteUrl`, `getRemoteFileUrl` and
`getRemoteCommitUrl` become async; all four downstream callers were already async
(`filesystem-git-url-handlers` inside `ipcMain.handle`, `runtime-git-diff-commands`
async methods) and the provider contract already typed both wrappers
`Promise<string | null>`, so no new async plumbing was needed.
Removes 3 of the 10 `gitExecFileSync` sites and the confusing name collision with
the unrelated async `getDefaultBaseRef` in hosted-review-creation-git-state.
The base-ref regression tests keep their coverage, repointed at the public async
`getBaseRefDefault`.
* perf(git): resolve the repo root in one sync spawn instead of two
getGitRepoRoot ran `rev-parse --is-inside-work-tree` and then `rev-parse
--show-toplevel` as separate blocking spawns. Each sync git call holds the main
thread for up to its whole 15s timeout, so the spawn count is the cost — and this
function is called twice per "Add Project" on a linked worktree, once directly and
once through getLinkedWorktreeMainRepoRoot's self-recursion.
Combined into one invocation. Safe only here: in a bare repo the combined form
exits non-zero, and both that throw and the plain `false` already land on the same
marker-scan fallback. probeGitRepo deliberately does NOT combine — it has to read
`false` cleanly to go on and detect a bare repo, which the combined form's exit 128
would misread as indeterminate.
* perf(git): rebuild only the repos whose authorized roots actually changed
One worktree create called `invalidateAuthorizedRootsCache()`, which dirties every
registered owner. The next authorization-requiring IPC then rebuilt by listing EVERY
repo — and the rebuild never consulted `dirty` when choosing what to list, so `dirty`
gated only whether a rebuild ran, not its scope. At 58 repos that is 58
`git worktree list` spawns, roughly ten seconds of git wall-clock through an
admission budget of four, to rediscover roots one repo changed.
Both halves were needed; scoping the invalidation alone changed nothing.
- `markAuthorizedRootsOwnerDirty` dirties a single owner, reusing the per-owner
primitives `registerWorktreeRootsForRepo` already used. It leaves `baseRevision`
and the per-repo revision map alone — that pair is the global side-effect-token
fence, and bumping it would retire in-flight tokens for untouched repos.
- `rebuildAuthorizedRootsCache(store, onlyDirty)` re-lists only owners that are
dirty, have no listing yet, or still hold recovered roots (those are retired by
comparison against a fresh listing, so skipping them would strand them as
authorized). Only `ensureAuthorizedRootsCache` passes `onlyDirty`; an explicit
rebuild keeps re-listing everything because callers use it to force a refresh —
`filesystem-auth.test.ts` pins that contract.
`invalidateAuthorizedRootsCacheForRepo` wraps the primitive and falls back to the
global form for an unknown owner or a missing store, rather than silently skipping an
invalidation and leaving a stale allowlist. Applied to the worktree-create path.
Changes that can alter the owner SET (store swap, host/WSL re-routing, nested-repo
import, folder->git upgrade) stay global. Removal paths are not converted yet.
The allowlist contents are unchanged and the failure direction is a false denial
rather than a false allow. The relist predicate is split into its own module so it is
testable alone and the cache file stays inside its line budget without a suppression.
* test(perf): measure what git orchestration actually costs the main thread
The existing churn probe (ORCA_MAIN_THREAD_DIAGNOSTICS=1) reported spawn-initiation
cost for git/gh/glab only — its 7 call sites all sit inside git/command-runner — so
it was blind to `spawnProcess`/`runProcess`, the repo's own mandated wrapper, and to
the blocking `execFileSync('ps')` per PTY resize. That understated total churn across
115 main call sites.
- `spawn-observer.ts`: a settable seam, since shared code cannot import src/main.
Unregistered in the daemon/relay/CLI, where it costs one boolean check.
- `spawnProcess` brackets `nodeSpawn` and reports; exec-file-capture's own report is
removed because it routes through runProcess and would double-count.
- `posix-pty-foreground-group` now reports its full blocking duration. Note this
lands on the daemon, not main, whenever the daemon hosts the PTY.
- `ORCA_UNMINIFIED_MAIN=1` build flag, because a minified main bundle cannot
attribute CPU-profile self time to real function names. Defaults unchanged.
- `main-thread-git-cost.spec.ts` + `analyze-main-cpuprofile.mjs`: sweeps concurrency
against real registered repos, captures the churn lines and a V8 CPU profile of
main per phase.
What it found, which is why this is worth keeping: at the width-4 admission ceiling
(~90 git:status/s) main sees ZERO event-loop gaps over 50ms and a worst gap of 23ms,
and is 85% idle. Git orchestration does not stall the main thread. Of the cost it
does incur, spawn-init is 58%, parse 5%, stdout drain 4%.
* test(perf): name the inspector params type the anti-slop gate requires
The broad `object` parameter trips anti-slop(no-object-parameters); the only
Profiler call that passes params sends `{ interval }`.
239 lines
8.0 KiB
TypeScript
239 lines
8.0 KiB
TypeScript
import { resolve, dirname, basename } from 'node:path'
|
|
import { realpathSync } from 'node:fs'
|
|
import { realpath } from 'node:fs/promises'
|
|
import type { Store } from '../persistence'
|
|
import { getAllowedRoots } from './filesystem-allowed-roots'
|
|
import { isDescendantOrEqual, isENOENT, normalizeExistingPath } from './filesystem-path-containment'
|
|
import {
|
|
ensureAuthorizedRootsCache,
|
|
isPathAllowedByCanonicalRegisteredRoot,
|
|
isRegisteredWorktreePath
|
|
} from './registered-worktree-roots-cache'
|
|
|
|
// Compatibility exports for runtime command modules that historically imported these seams from
|
|
// filesystem-auth. The implementations remain owned by their focused modules.
|
|
export { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
|
|
export { invalidateAuthorizedRootsCacheForRepo } from './registered-worktree-roots-scoped-invalidation'
|
|
export { isENOENT } from './filesystem-path-containment'
|
|
|
|
export const PATH_ACCESS_DENIED_MESSAGE =
|
|
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
|
|
// Why: authorized external paths accumulate all session; LRU-bound the set. Safe to evict because every caller re-authorizes before operating.
|
|
export const AUTHORIZED_EXTERNAL_PATHS_MAX = 4096
|
|
const authorizedExternalPaths = new Set<string>()
|
|
|
|
function rememberAuthorizedExternalPath(path: string): void {
|
|
// Delete-then-add makes re-authorized paths most-recent so LRU eviction sheds only the oldest untouched entries.
|
|
authorizedExternalPaths.delete(path)
|
|
authorizedExternalPaths.add(path)
|
|
while (authorizedExternalPaths.size > AUTHORIZED_EXTERNAL_PATHS_MAX) {
|
|
const oldest = authorizedExternalPaths.keys().next().value
|
|
if (oldest === undefined) {
|
|
break
|
|
}
|
|
authorizedExternalPaths.delete(oldest)
|
|
}
|
|
}
|
|
|
|
export function authorizeExternalPath(targetPath: string): void {
|
|
const resolvedTarget = resolve(targetPath)
|
|
rememberAuthorizedExternalPath(resolvedTarget)
|
|
try {
|
|
// Why: macOS canonicalizes /tmp to /private/tmp during read authorization.
|
|
rememberAuthorizedExternalPath(realpathSync(resolvedTarget))
|
|
} catch {}
|
|
}
|
|
|
|
/**
|
|
* One allowed-root list shared by every check in a single authorization.
|
|
*
|
|
* Lazy so a path already covered by an external grant still builds nothing at all, the way it did
|
|
* before the list was hoisted out of the individual checks.
|
|
*/
|
|
type AllowedRootsSnapshot = { get: () => readonly string[] }
|
|
|
|
function createAllowedRootsSnapshot(store: Store): AllowedRootsSnapshot {
|
|
let roots: readonly string[] | undefined
|
|
return { get: () => (roots ??= getAllowedRoots(store)) }
|
|
}
|
|
|
|
export function isPathAllowed(
|
|
targetPath: string,
|
|
store: Store,
|
|
allowedRoots?: AllowedRootsSnapshot
|
|
): boolean {
|
|
const resolvedTarget = resolve(targetPath)
|
|
if (authorizedExternalPaths.has(resolvedTarget)) {
|
|
return true
|
|
}
|
|
for (const authorizedPath of authorizedExternalPaths) {
|
|
if (isDescendantOrEqual(resolvedTarget, authorizedPath)) {
|
|
return true
|
|
}
|
|
}
|
|
return (allowedRoots?.get() ?? getAllowedRoots(store)).some((root) =>
|
|
isDescendantOrEqual(resolvedTarget, root)
|
|
)
|
|
}
|
|
|
|
export type ResolveAuthorizedPathOptions = {
|
|
/**
|
|
* Canonicalize the parent but preserve the leaf so delete/rename target the symlink itself, not its destination (which may live outside allowed roots).
|
|
*/
|
|
preserveSymlink?: boolean
|
|
}
|
|
|
|
export async function resolveAuthorizedPath(
|
|
targetPath: string,
|
|
store: Store,
|
|
options: ResolveAuthorizedPathOptions = {}
|
|
): Promise<string> {
|
|
const resolvedTarget = resolve(targetPath)
|
|
// Why: the roots depend only on store state, not on the candidate path, so one snapshot serves
|
|
// every authorization below; each candidate is still checked against it in full.
|
|
const allowedRoots = createAllowedRootsSnapshot(store)
|
|
if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store, { allowedRoots }))) {
|
|
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
|
|
}
|
|
|
|
if (options.preserveSymlink) {
|
|
// Canonicalize the parent so ancestor symlinks can't redirect outside allowed roots, but keep the leaf so delete/rename act on the link itself.
|
|
let realParent: string
|
|
try {
|
|
realParent = await realpath(dirname(resolvedTarget))
|
|
} catch (error) {
|
|
if (isENOENT(error)) {
|
|
return resolveAuthorizedMissingPath(resolvedTarget, store, allowedRoots)
|
|
}
|
|
throw error
|
|
}
|
|
const candidateTarget = resolve(realParent, basename(resolvedTarget))
|
|
if (
|
|
!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, {
|
|
canonicalSourcePath: resolvedTarget,
|
|
allowedRoots
|
|
}))
|
|
) {
|
|
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
|
|
}
|
|
return candidateTarget
|
|
}
|
|
|
|
try {
|
|
// Why: Windows/WSL realpath can return UNC-shaped paths; re-resolve to compare against this module's allow-list roots.
|
|
const realTarget = resolve(await realpath(resolvedTarget))
|
|
if (
|
|
!(await isPathAllowedIncludingRegisteredWorktrees(realTarget, store, {
|
|
canonicalSourcePath: resolvedTarget,
|
|
allowedRoots
|
|
}))
|
|
) {
|
|
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
|
|
}
|
|
return realTarget
|
|
} catch (error) {
|
|
if (!isENOENT(error)) {
|
|
throw error
|
|
}
|
|
return resolveAuthorizedMissingPath(resolvedTarget, store, allowedRoots)
|
|
}
|
|
}
|
|
|
|
async function resolveAuthorizedMissingPath(
|
|
resolvedTarget: string,
|
|
store: Store,
|
|
allowedRoots: AllowedRootsSnapshot
|
|
): Promise<string> {
|
|
let existingAncestor = resolvedTarget
|
|
const missingSegments: string[] = []
|
|
|
|
while (true) {
|
|
try {
|
|
const realAncestor = await realpath(existingAncestor)
|
|
const candidateTarget = resolve(realAncestor, ...missingSegments)
|
|
if (
|
|
!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, {
|
|
canonicalSourcePath: resolvedTarget,
|
|
allowedRoots
|
|
}))
|
|
) {
|
|
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
|
|
}
|
|
return candidateTarget
|
|
} catch (error) {
|
|
if (!isENOENT(error)) {
|
|
throw error
|
|
}
|
|
const parent = dirname(existingAncestor)
|
|
if (parent === existingAncestor) {
|
|
throw error
|
|
}
|
|
// Why: create/copy make missing parents after auth; canonicalize nearest existing ancestor to catch symlink escapes without rejecting nested paths.
|
|
missingSegments.unshift(basename(existingAncestor))
|
|
existingAncestor = parent
|
|
}
|
|
}
|
|
}
|
|
|
|
async function isPathAllowedIncludingRegisteredWorktrees(
|
|
targetPath: string,
|
|
store: Store,
|
|
options: { canonicalSourcePath?: string; allowedRoots?: AllowedRootsSnapshot } = {}
|
|
): Promise<boolean> {
|
|
if (isPathAllowed(targetPath, store, options.allowedRoots)) {
|
|
return true
|
|
}
|
|
|
|
if (isRegisteredWorktreePath(targetPath, store)) {
|
|
return true
|
|
}
|
|
|
|
if (
|
|
await isPathAllowedByCanonicalAllowedRoot(
|
|
targetPath,
|
|
options.canonicalSourcePath,
|
|
store,
|
|
options.allowedRoots
|
|
)
|
|
) {
|
|
return true
|
|
}
|
|
|
|
if (
|
|
await isPathAllowedByCanonicalRegisteredRoot(targetPath, options.canonicalSourcePath, store)
|
|
) {
|
|
return true
|
|
}
|
|
|
|
await ensureAuthorizedRootsCache(store)
|
|
|
|
// Why: linked worktrees are already git-trusted; reuse the cached root index so reads don't spawn `git worktree list` each time.
|
|
return (
|
|
isRegisteredWorktreePath(targetPath, store) ||
|
|
(await isPathAllowedByCanonicalRegisteredRoot(targetPath, options.canonicalSourcePath, store))
|
|
)
|
|
}
|
|
|
|
async function isPathAllowedByCanonicalAllowedRoot(
|
|
targetPath: string,
|
|
sourcePath: string | undefined,
|
|
store: Store,
|
|
allowedRoots?: AllowedRootsSnapshot
|
|
): Promise<boolean> {
|
|
if (!sourcePath) {
|
|
return false
|
|
}
|
|
for (const root of allowedRoots?.get() ?? getAllowedRoots(store)) {
|
|
const resolvedRoot = resolve(root)
|
|
if (!isDescendantOrEqual(sourcePath, resolvedRoot)) {
|
|
continue
|
|
}
|
|
// Why: macOS resolves /var→/private/var; canonicalize only the matched root, not the whole repo set.
|
|
const canonicalRoot = await normalizeExistingPath(resolvedRoot)
|
|
if (isDescendantOrEqual(targetPath, canonicalRoot)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|