fix(desktop): recover from macOS directory permission failures

This commit is contained in:
deatheros
2026-08-07 01:49:44 +03:00
parent 7e0e22f6e2
commit d8518bf053
30 changed files with 497 additions and 106 deletions
+1
View File
@@ -75,6 +75,7 @@ The composer compares normalized attachment MIME types with the selected model's
- A mounted directory-store consumer pins that store for its lifetime. Eviction may dispose only unmounted directories, so optimistic actions and realtime events cannot move to a replacement store while visible React consumers remain subscribed to an older identity.
- Reconfiguration and runtime switching invalidate stale generations. A stale completion must not publish state into the new runtime.
- Failure is recorded as `failed`; it is not converted into a successful empty snapshot. Forced demand can retry failed or completed work.
- A failed bootstrap is classified as `os-permission` only when the owning runtime filesystem API independently confirms `EPERM`/`EACCES` for that exact directory. OpenCode/proxy error text is never used as permission evidence. The scheduler retains the directory-scoped reason so local Desktop can offer native folder selection before a forced retry.
Bootstrap remains stale-while-revalidate: a directory store may paint persisted sessions immediately, but only a successful authoritative fetch may replace that cached list.
+35
View File
@@ -13,6 +13,7 @@ import {
setSyncPerformanceDiagnosticsEnabled,
} from './performance-diagnostics';
import { DIR_IDLE_TTL_MS } from './types';
import { FilesystemError } from '@/lib/api/files-errors';
const deferred = () => {
let resolve!: () => void;
@@ -414,6 +415,40 @@ describe('ChildStoreManager directory bootstrap scheduler', () => {
manager.disposeAll();
});
test('records os-permission failures and clears them on forced retry', async () => {
const manager = new ChildStoreManager();
let denied = true;
const cleanup = manager.configure({
onBootstrap: () => {
if (denied) {
throw new FilesystemError('Access denied', { reason: 'os-permission', status: 403 });
}
},
});
manager.requestBootstrap({ directory: '/protected', priority: 'selected', reason: 'current-directory' });
await settle();
await settle();
expect(manager.getBootstrapState('/protected')).toBe('failed');
expect(manager.getBootstrapFailure('/protected')).toBe('os-permission');
denied = false;
manager.requestBootstrap({
directory: '/protected',
priority: 'selected',
reason: 'action-demand',
force: true,
});
await settle();
await settle();
expect(manager.getBootstrapState('/protected')).toBe('complete');
expect(manager.getBootstrapFailure('/protected')).toBe(undefined);
cleanup();
manager.disposeAll();
});
test('continues after a synchronous bootstrap failure', async () => {
const manager = new ChildStoreManager();
const started: string[] = [];
+17 -1
View File
@@ -6,6 +6,7 @@ import { readDirCache, persistVcs, persistProjectMeta, persistIcon, persistSessi
import { normalizePath } from "@/lib/pathNormalization"
import { startSessionLoadPerformanceEvent } from "./session-load-performance"
import { countSyncPerformance } from "./performance-diagnostics"
import { isFilesystemError } from "@/lib/api/files-errors"
export type DirectoryStore = State & {
/** Apply a partial state update */
@@ -216,6 +217,7 @@ export type DirectoryBootstrapDemand = {
}
export type DirectoryBootstrapState = "queued" | "running" | "complete" | "failed"
export type DirectoryBootstrapFailureReason = "os-permission" | "generic"
export type DirectoryBootstrapContext = DirectoryBootstrapDemand & {
generation: number
@@ -296,6 +298,7 @@ export class ChildStoreManager {
private readonly bootstrapQueue = new Map<string, QueuedBootstrap>()
private readonly runningBootstraps = new Map<string, RunningBootstrap>()
private readonly bootstrapStates = new Map<string, DirectoryBootstrapState>()
private readonly bootstrapFailures = new Map<string, DirectoryBootstrapFailureReason>()
private onBootstrap?: (context: DirectoryBootstrapContext) => Promise<void> | void
private onDispose?: (directory: string) => void
@@ -479,6 +482,11 @@ export class ChildStoreManager {
return normalizedDirectory ? this.bootstrapStates.get(normalizedDirectory) : undefined
}
getBootstrapFailure(directory: string): DirectoryBootstrapFailureReason | undefined {
const normalizedDirectory = normalizePath(directory)
return normalizedDirectory ? this.bootstrapFailures.get(normalizedDirectory) : undefined
}
subscribeBootstrap(listener: () => void): () => void {
this.bootstrapSubscribers.add(listener)
return () => this.bootstrapSubscribers.delete(listener)
@@ -530,6 +538,7 @@ export class ChildStoreManager {
if (demand.force) running.rerunRequested = true
return false
}
this.bootstrapFailures.delete(directory)
const existing = this.bootstrapQueue.get(directory)
const next: QueuedBootstrap = existing
? {
@@ -603,14 +612,19 @@ export class ChildStoreManager {
.then(() => {
if (isCurrent()) {
this.bootstrapStates.set(next.directory, "complete")
this.bootstrapFailures.delete(next.directory)
finishPerformanceEvent("complete")
} else {
finishPerformanceEvent("stale")
}
})
.catch(() => {
.catch((error) => {
if (isCurrent()) {
this.bootstrapStates.set(next.directory, "failed")
this.bootstrapFailures.set(
next.directory,
isFilesystemError(error) && error.reason === "os-permission" ? "os-permission" : "generic",
)
finishPerformanceEvent("error")
} else {
finishPerformanceEvent("stale")
@@ -658,6 +672,7 @@ export class ChildStoreManager {
this.bootstrapQueue.delete(directory)
this.manualBootstrapDemands.delete(directory)
this.bootstrapStates.delete(directory)
this.bootstrapFailures.delete(directory)
for (const demands of this.bootstrapDemandsByOwner.values()) demands.delete(directory)
this.children.delete(directory)
this.notifyRegistrySubscribers()
@@ -719,6 +734,7 @@ export class ChildStoreManager {
this.bootstrapQueue.clear()
this.runningBootstraps.clear()
this.bootstrapStates.clear()
this.bootstrapFailures.clear()
this.bootstrapDemandsByOwner.clear()
this.manualBootstrapDemands.clear()
this.notifyBootstrapSubscribers()
+16 -1
View File
@@ -68,6 +68,7 @@ import { getPermissionToastKey, showPermissionNeededToast } from "./permission-t
import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-memory"
import { getRuntimeKey } from "@/lib/runtime-switch"
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
import { isFilesystemError } from "@/lib/api/files-errors"
import { listGlobalSessionPages } from "@/stores/globalSessions"
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
@@ -2068,7 +2069,21 @@ export function SyncProvider(props: {
}
const result = await runBootstrap(0)
if (result === "failed") throw new Error(`Directory bootstrap failed for ${directory}`)
if (result === "failed") {
// OpenCode can mask the underlying errno while initializing an
// inaccessible workspace. Probe the exact directory through the
// owning runtime filesystem API so only an authoritative local
// EPERM/EACCES becomes an actionable grant-access failure.
const files = getRegisteredRuntimeAPIs()?.files
if (files) {
try {
await files.listDirectory(directory)
} catch (error) {
if (isFilesystemError(error) && error.reason === "os-permission") throw error
}
}
throw new Error(`Directory bootstrap failed for ${directory}`)
}
// Selecting a session whose directory this client had not indexed yet
// routes it through the active directory as a documented guess. This is