diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 1461b8773dc..7a6d137ebfa 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -7,11 +7,9 @@ import { readFileSync, readdirSync, realpathSync, - statSync, - writeFileSync + statSync } from 'node:fs' -import { createHash } from 'node:crypto' -import { isSafeDescendCandidate, mirrorEntry, safeRemoveOverlay } from '../pty/overlay-mirror' +import { isSafeDescendCandidate, mirrorEntry } from '../pty/overlay-mirror' import { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, @@ -19,7 +17,8 @@ import { } from './status-plugin-module-source' import { readOpenCodeOverlayManifest, - OPENCODE_OVERLAY_MANIFEST_FILE, + clearOpenCodeOverlayManifestEntries, + writeOpenCodeOverlayManifest, type OpenCodeOverlayManifest } from './opencode-overlay-manifest' import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' @@ -33,15 +32,19 @@ import { writeOpenCodeTuiPlugin } from '../../shared/opencode-tui-plugin-install' import { writeLegacyOpenCodePluginWithAclRetry } from './legacy-plugin-acl-retry' +import { + OPENCODE_OVERLAY_DIR, + ORCA_OPENCODE_PLUGIN_FILE, + sourceOverlayDirName, + toSafeDirName +} from './overlay-dir-names' +import { OpenCodeDirGcLifecycle } from './overlay-dir-gc-lifecycle' export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } import { writeCanonicalOpenCodePluginAtomically } from '../../shared/opencode-plugin-atomic-write' import { writeOpenCodePluginConfig } from './opencode-plugin-config-writer' -const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' -const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' - type OpenCodeHookVariant = { pluginFileName: string legacyHooksDir: string @@ -57,11 +60,6 @@ function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } -function toSafeDirName(id: string): string { - // Why: 32 hex chars (128 bits) makes collisions negligible and stays filesystem-portable (no base64 padding or `/`). - return createHash('sha256').update(id).digest('hex').slice(0, 32) -} - // Why: installs the plugin into OpenCode's config discovery path so it POSTs to the shared agent-hooks server, unifying OpenCode status with Claude/Codex/Gemini. export class OpenCodeHookService { private readonly pluginSource: () => string @@ -70,6 +68,7 @@ export class OpenCodeHookService { private readonly overlayDir: string private readonly installsTuiPlugin: boolean private readonly tuiOnlyDirectory: string | undefined + readonly configDirGc: OpenCodeDirGcLifecycle constructor(variant?: OpenCodeHookVariant | (() => string)) { const config: OpenCodeHookVariant = @@ -93,6 +92,7 @@ export class OpenCodeHookService { this.pluginFileName = config.pluginFileName this.legacyHooksDir = config.legacyHooksDir this.overlayDir = config.overlayDir + this.configDirGc = new OpenCodeDirGcLifecycle(() => this.getOverlayRoot(), this.pluginFileName) } clearPty(_ptyId: string): void { @@ -114,6 +114,7 @@ export class OpenCodeHookService { } } this.writePluginIntoOverlay(directory) + owner.configDirGc.reference(directory) return 'installed' } catch { return 'failed' @@ -152,6 +153,7 @@ export class OpenCodeHookService { this.mirrorUserConfig(existingConfigDir, overlayDir) } this.writePluginIntoOverlay(overlayDir) + this.configDirGc.reference(overlayDir) return { OPENCODE_CONFIG_DIR: overlayDir } } catch { return { OPENCODE_CONFIG_DIR: existingConfigDir } @@ -217,7 +219,7 @@ export class OpenCodeHookService { } private getSourceOverlayDir(sourceConfigDir: string): string { - return join(this.getOverlayRoot(), toSafeDirName(`source:${sourceConfigDir}`)) + return join(this.getOverlayRoot(), sourceOverlayDirName(sourceConfigDir)) } private getSharedConfigDir(): string { @@ -227,34 +229,17 @@ export class OpenCodeHookService { ) } - private writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void { - writeFileSync( - join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), - `${JSON.stringify(manifest, null, 2)}\n` - ) - } - - private clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void { - for (const entryName of manifest.topLevelEntries) { - safeRemoveOverlay(join(overlayDir, entryName), overlayDir) - } - - const overlayPluginsDir = join(overlayDir, 'plugins') - for (const entryName of manifest.pluginEntries) { - if (entryName === this.pluginFileName) { - continue - } - safeRemoveOverlay(join(overlayPluginsDir, entryName), overlayPluginsDir) - } - } - // Why: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file. private mirrorUserConfig(sourceDir: string, overlayDir: string): void { const previousManifest = readOpenCodeOverlayManifest(overlayDir) // Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (node_modules) survive. - this.clearManifestEntries(overlayDir, previousManifest) + clearOpenCodeOverlayManifestEntries(overlayDir, previousManifest, this.pluginFileName) - const nextManifest: OpenCodeOverlayManifest = { topLevelEntries: [], pluginEntries: [] } + const nextManifest: OpenCodeOverlayManifest = { + topLevelEntries: [], + pluginEntries: [], + sourceConfigDir: sourceDir + } for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { const sourcePath = join(sourceDir, entry.name) @@ -301,7 +286,7 @@ export class OpenCodeHookService { nextManifest.topLevelEntries.push(entry.name) } - this.writeOverlayManifest(overlayDir, nextManifest) + writeOpenCodeOverlayManifest(overlayDir, nextManifest) } private writePluginIntoOverlay(overlayDir: string): void { diff --git a/src/main/opencode/opencode-overlay-manifest.test.ts b/src/main/opencode/opencode-overlay-manifest.test.ts index 7dc0404a53e..65c2c765bbb 100644 --- a/src/main/opencode/opencode-overlay-manifest.test.ts +++ b/src/main/opencode/opencode-overlay-manifest.test.ts @@ -30,3 +30,32 @@ it('accepts only string manifest entries and tolerates invalid persisted shapes' rmSync(root, { recursive: true, force: true }) } }) + +it('keeps source ownership optional for older manifests and rejects non-string metadata', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-overlay-source-manifest-')) + try { + const file = join(root, OPENCODE_OVERLAY_MANIFEST_FILE) + for (const source of [undefined, null, 42, {}]) { + writeFileSync( + file, + JSON.stringify({ topLevelEntries: [], pluginEntries: [], sourceConfigDir: source }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ topLevelEntries: [], pluginEntries: [] }) + } + writeFileSync( + file, + JSON.stringify({ + topLevelEntries: ['opencode.json'], + pluginEntries: [], + sourceConfigDir: '/owned/source' + }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ + topLevelEntries: ['opencode.json'], + pluginEntries: [], + sourceConfigDir: '/owned/source' + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/opencode/opencode-overlay-manifest.ts b/src/main/opencode/opencode-overlay-manifest.ts index 5abd0328f25..a256c14f8ca 100644 --- a/src/main/opencode/opencode-overlay-manifest.ts +++ b/src/main/opencode/opencode-overlay-manifest.ts @@ -1,8 +1,13 @@ -import { readFileSync } from 'node:fs' +import { readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import { safeRemoveOverlay } from '../pty/overlay-mirror' export const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' -export type OpenCodeOverlayManifest = { topLevelEntries: string[]; pluginEntries: string[] } +export type OpenCodeOverlayManifest = { + topLevelEntries: string[] + pluginEntries: string[] + sourceConfigDir?: string +} export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { const empty = { topLevelEntries: [], pluginEntries: [] } @@ -14,6 +19,9 @@ export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlay return empty } return { + ...('sourceConfigDir' in parsed && typeof parsed.sourceConfigDir === 'string' + ? { sourceConfigDir: parsed.sourceConfigDir } + : {}), topLevelEntries: 'topLevelEntries' in parsed && Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries.filter((entry): entry is string => typeof entry === 'string') @@ -27,3 +35,30 @@ export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlay return empty } } + +export function writeOpenCodeOverlayManifest( + overlayDir: string, + manifest: OpenCodeOverlayManifest +): void { + writeFileSync( + join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), + `${JSON.stringify(manifest, null, 2)}\n` + ) +} + +export function clearOpenCodeOverlayManifestEntries( + overlayDir: string, + manifest: OpenCodeOverlayManifest, + pluginFileName: string +): void { + for (const entryName of manifest.topLevelEntries) { + safeRemoveOverlay(join(overlayDir, entryName), overlayDir) + } + const overlayPluginsDir = join(overlayDir, 'plugins') + for (const entryName of manifest.pluginEntries) { + if (entryName === pluginFileName) { + continue + } + safeRemoveOverlay(join(overlayPluginsDir, entryName), overlayPluginsDir) + } +} diff --git a/src/main/opencode/overlay-dir-gc-lifecycle.test.ts b/src/main/opencode/overlay-dir-gc-lifecycle.test.ts new file mode 100644 index 00000000000..6149c1094fe --- /dev/null +++ b/src/main/opencode/overlay-dir-gc-lifecycle.test.ts @@ -0,0 +1,30 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { OpenCodeDirGcLifecycle } from './overlay-dir-gc-lifecycle' + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +it('schedules one delayed sweep without holding the app open', async () => { + vi.useFakeTimers() + const timeout = vi.spyOn(globalThis, 'setTimeout') + const lifecycle = new OpenCodeDirGcLifecycle(() => '/unused-test-root', 'unused-plugin.js') + const run = vi.spyOn(lifecycle, 'run').mockResolvedValue({ + scanned: 0, + removed: 0, + failed: 0, + keptReferenced: 0, + keptYoung: 0, + keptSourcePresent: 0, + keptUnverifiable: 0 + }) + const inventory = vi.fn(async () => []) + lifecycle.schedule(inventory) + lifecycle.schedule(inventory) + expect(timeout.mock.results[0]?.value.hasRef()).toBe(false) + await vi.advanceTimersByTimeAsync(179999) + expect(run).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(run).toHaveBeenCalledExactlyOnceWith(inventory) +}) diff --git a/src/main/opencode/overlay-dir-gc-lifecycle.ts b/src/main/opencode/overlay-dir-gc-lifecycle.ts new file mode 100644 index 00000000000..df84b28bf18 --- /dev/null +++ b/src/main/opencode/overlay-dir-gc-lifecycle.ts @@ -0,0 +1,46 @@ +import { OPENCODE_CONFIG_DIR_ENV_KEYS } from './legacy-shared-config-dir' +import { sweepOrphanedOpenCodeDirs, type OpenCodeDirGcResult } from './overlay-dir-gc' + +type ReadLivePtyIds = () => Promise + +export class OpenCodeDirGcLifecycle { + private readonly references = new Set() + private scheduled = false + + constructor( + private readonly getRoot: () => string, + private readonly pluginFileName: string + ) {} + + reference(directory: string): void { + this.references.add(directory) + } + + schedule(readLivePtyIds: ReadLivePtyIds, delayMs = 3 * 60_000): void { + if (this.scheduled) { + return + } + this.scheduled = true + const timer = setTimeout(() => { + void this.run(readLivePtyIds).catch((error) => { + console.warn('[OpenCode] Overlay cleanup skipped:', error) + }) + }, delayMs) + timer.unref() + } + + async run(readLivePtyIds: ReadLivePtyIds): Promise { + for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { + const value = process.env[key] + if (value) { + this.references.add(value) + } + } + return sweepOrphanedOpenCodeDirs({ + overlayRoot: this.getRoot(), + pluginFileName: this.pluginFileName, + referencedConfigDirs: this.references, + readLivePtyIds + }) + } +} diff --git a/src/main/opencode/overlay-dir-gc.test.ts b/src/main/opencode/overlay-dir-gc.test.ts new file mode 100644 index 00000000000..e2472477154 --- /dev/null +++ b/src/main/opencode/overlay-dir-gc.test.ts @@ -0,0 +1,227 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { OpenCodeHookService } from './hook-service' +import { OPENCODE_OVERLAY_MANIFEST_FILE } from './opencode-overlay-manifest' +import { OPENCODE_DIR_GC_MIN_AGE_MS, sweepOrphanedOpenCodeDirs } from './overlay-dir-gc' +import { ORCA_OPENCODE_PLUGIN_FILE, sourceOverlayDirName } from './overlay-dir-names' + +let root: string +let overlays: string +const now = Date.now() + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-overlay-gc-'))) + overlays = join(root, 'opencode-config-overlays') + mkdirSync(overlays) + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'xdg')) + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + ]) { + vi.stubEnv(key, '') + } + setAppEnvironment({ + getPath: () => root, + getAppPath: () => root, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +function age(directory: string): void { + const old = new Date(now - OPENCODE_DIR_GC_MIN_AGE_MS * 2) + for (const suffix of [ + '', + OPENCODE_OVERLAY_MANIFEST_FILE, + 'plugins', + join('plugins', ORCA_OPENCODE_PLUGIN_FILE) + ]) { + utimesSync(join(directory, suffix), old, old) + } +} + +function createOverlay(name: string): { source: string; directory: string } { + const source = join(root, name) + const directory = join(overlays, sourceOverlayDirName(source)) + mkdirSync(join(directory, 'plugins'), { recursive: true }) + writeFileSync(join(directory, 'plugins', ORCA_OPENCODE_PLUGIN_FILE), 'export default {}') + writeFileSync( + join(directory, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ + topLevelEntries: [], + pluginEntries: [], + sourceConfigDir: source + }) + ) + age(directory) + return { source, directory } +} + +function sweep(extra: Partial[0]> = {}) { + return sweepOrphanedOpenCodeDirs({ + overlayRoot: overlays, + pluginFileName: ORCA_OPENCODE_PLUGIN_FILE, + referencedConfigDirs: new Set(), + readLivePtyIds: async () => [], + now, + yieldBetweenRemovals: async () => {}, + ...extra + }) +} + +it('collects only an old, owned missing source while retaining active and reusable sources', async () => { + const retired = createOverlay('retired') + const active = createOverlay('active') + const reusable = createOverlay('reusable') + mkdirSync(active.source) + mkdirSync(reusable.source) + const result = await sweep() + expect(result.removed).toBe(1) + expect(result.keptSourcePresent).toBe(2) + expect(existsSync(retired.directory)).toBe(false) + expect(existsSync(active.directory)).toBe(true) + expect(existsSync(reusable.directory)).toBe(true) +}) + +it.each([null, ['surviving-daemon-pty']] as const)( + 'keeps a retired source with an empty fresh-process reference cache when inventory is %s', + async (ids) => { + const retired = createOverlay('retired') + expect((await sweep({ readLivePtyIds: async () => ids })).removed).toBe(0) + expect(existsSync(retired.directory)).toBe(true) + } +) + +it('preserves a candidate when owning-host inventory fails', async () => { + const retired = createOverlay('retired') + expect( + ( + await sweep({ + readLivePtyIds: async () => { + throw new Error('host unavailable') + } + }) + ).keptUnverifiable + ).toBe(1) + expect(existsSync(retired.directory)).toBe(true) +}) + +it.each(['service.json', 'service-local.json'])( + 'preserves %s without guessing service process death', + async (file) => { + const retired = createOverlay('retired') + writeFileSync(join(retired.directory, file), '{}') + age(retired.directory) + const inventory = vi.fn(async () => []) + expect((await sweep({ readLivePtyIds: inventory })).keptUnverifiable).toBe(1) + expect(inventory).not.toHaveBeenCalled() + expect(existsSync(retired.directory)).toBe(true) + } +) + +it('keeps a young candidate and refreshes reference protection after an asynchronous inventory', async () => { + const young = createOverlay('young') + utimesSync( + join(young.directory, 'plugins', ORCA_OPENCODE_PLUGIN_FILE), + new Date(now), + new Date(now) + ) + const retired = createOverlay('retired') + const references = new Set() + const result = await sweep({ + referencedConfigDirs: references, + readLivePtyIds: async () => { + references.add(join(retired.directory, 'plugins')) + return [] + } + }) + expect(result.keptYoung).toBe(1) + expect(result.keptReferenced).toBe(1) + expect(existsSync(retired.directory)).toBe(true) +}) + +it('does not follow a replaced overlay root or a source ancestor link', async () => { + const retired = createOverlay('retired') + const target = join(root, 'elsewhere') + mkdirSync(target) + symlinkSync(target, retired.source, process.platform === 'win32' ? 'junction' : 'dir') + expect((await sweep()).removed).toBe(0) + rmSync(overlays, { recursive: true }) + symlinkSync(target, overlays, process.platform === 'win32' ? 'junction' : 'dir') + expect((await sweep()).scanned).toBe(0) + expect(existsSync(target)).toBe(true) +}) + +it('retains unowned names, ambiguous old manifests and mismatched source hashes', async () => { + for (const name of ['shared', '123', 'human']) { + mkdirSync(join(overlays, name)) + } + const ambiguous = createOverlay('ambiguous') + writeFileSync( + join(ambiguous.directory, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: [] }) + ) + const mismatch = createOverlay('mismatch') + writeFileSync( + join(mismatch.directory, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: [], sourceConfigDir: root }) + ) + expect((await sweep()).keptUnverifiable).toBe(5) +}) + +it('counts failed deletion attempts toward the bound and yields between them', async () => { + for (let i = 0; i < 4; i += 1) { + createOverlay(`retired-${i}`) + } + const remove = vi.fn(() => { + throw new Error('busy') + }) + const yieldBetween = vi.fn(async () => {}) + const result = await sweep({ + maxRemovals: 2, + removeTree: remove, + yieldBetweenRemovals: yieldBetween + }) + expect(result.failed).toBe(2) + expect(remove).toHaveBeenCalledTimes(2) + expect(yieldBetween).toHaveBeenCalledTimes(2) +}) + +it('retains handed-out and inherited source references through clearPty', async () => { + const source = join(root, 'source') + mkdirSync(source) + writeFileSync(join(source, 'opencode.json'), '{}') + const service = new OpenCodeHookService(() => 'export default {}') + const directory = service.buildPtyEnv('pane', source).OPENCODE_CONFIG_DIR + if (!directory) { + throw new Error('Expected overlay') + } + rmSync(source, { recursive: true }) + age(directory) + service.clearPty('pane') + expect((await service.configDirGc.run(async () => [])).keptReferenced).toBe(1) + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', source) + expect((await new OpenCodeHookService().configDirGc.run(async () => [])).keptReferenced).toBe(1) +}) diff --git a/src/main/opencode/overlay-dir-gc.ts b/src/main/opencode/overlay-dir-gc.ts new file mode 100644 index 00000000000..6961c0c3c1a --- /dev/null +++ b/src/main/opencode/overlay-dir-gc.ts @@ -0,0 +1,206 @@ +import { lstat, readdir } from 'node:fs/promises' +import { join, resolve, sep } from 'node:path' +import { yieldToEventLoop } from '../../shared/event-loop-yield' +import { isSafeDescendCandidate, safeRemoveOverlay } from '../pty/overlay-mirror' +import { + OPENCODE_OVERLAY_MANIFEST_FILE, + readOpenCodeOverlayManifest +} from './opencode-overlay-manifest' +import { inspectSourceDirectory, resolveOwnedOverlaySource } from './overlay-source-ownership' + +export const OPENCODE_DIR_GC_MIN_AGE_MS = 30 * 24 * 60 * 60 * 1000 +export const OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP = 500 + +export type OpenCodeDirGcOptions = { + overlayRoot: string + pluginFileName: string + referencedConfigDirs: ReadonlySet + readLivePtyIds: () => Promise + now?: number + minAgeMs?: number + maxRemovals?: number + removeTree?: (directory: string, root: string) => void + yieldBetweenRemovals?: () => Promise +} + +export type OpenCodeDirGcResult = { + scanned: number + removed: number + failed: number + keptReferenced: number + keptYoung: number + keptSourcePresent: number + keptUnverifiable: number +} + +function normalized(path: string): string { + const absolute = resolve(path) + return process.platform === 'win32' ? absolute.toLowerCase() : absolute +} + +function isReferenced(directory: string, references: ReadonlySet): boolean { + const candidate = normalized(directory) + for (const reference of references) { + const value = normalized(reference) + if (value === candidate || value.startsWith(candidate + sep)) { + return true + } + } + return false +} + +async function oldEnough(directory: string, plugin: string, now: number, minAge: number) { + let newest = 0 + for (const path of [ + directory, + join(directory, OPENCODE_OVERLAY_MANIFEST_FILE), + join(directory, 'plugins'), + join(directory, 'plugins', plugin) + ]) { + try { + const stats = await lstat(path) + if (stats.isSymbolicLink()) { + return false + } + newest = Math.max(newest, stats.mtimeMs) + } catch { + return false + } + } + return newest > 0 && now - newest >= minAge +} + +async function hasNoServiceConfig(directory: string): Promise { + try { + const names = await readdir(directory) + // Service registration lives in a different profile; config absence proves no PID verdict. + return !names.some((name) => /^service(?:-[\w.-]+)?\.json$/i.test(name)) + } catch { + return false + } +} + +export async function sweepOrphanedOpenCodeDirs( + options: OpenCodeDirGcOptions +): Promise { + const result: OpenCodeDirGcResult = { + scanned: 0, + removed: 0, + failed: 0, + keptReferenced: 0, + keptYoung: 0, + keptSourcePresent: 0, + keptUnverifiable: 0 + } + if ((await inspectSourceDirectory(options.overlayRoot)) !== 'present') { + return result + } + const now = options.now ?? Date.now() + const minAge = options.minAgeMs ?? OPENCODE_DIR_GC_MIN_AGE_MS + const requestedLimit = options.maxRemovals ?? OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP + const limit = Number.isFinite(requestedLimit) + ? Math.min(OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP, Math.max(0, Math.floor(requestedLimit))) + : OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP + const removeTree = options.removeTree ?? safeRemoveOverlay + const yieldBetween = options.yieldBetweenRemovals ?? yieldToEventLoop + let entries + try { + entries = await readdir(options.overlayRoot, { withFileTypes: true }) + } catch { + return result + } + for (const entry of entries) { + if (result.removed + result.failed >= limit) { + break + } + result.scanned += 1 + const directory = join(options.overlayRoot, entry.name) + if (!/^[0-9a-f]{32}$/.test(entry.name) || !isSafeDescendCandidate(entry)) { + result.keptUnverifiable += 1 + continue + } + if (isReferenced(directory, options.referencedConfigDirs)) { + result.keptReferenced += 1 + continue + } + try { + const manifestStats = await lstat(join(directory, OPENCODE_OVERLAY_MANIFEST_FILE)) + if ( + !manifestStats.isFile() || + manifestStats.isSymbolicLink() || + manifestStats.size > 64 * 1024 + ) { + result.keptUnverifiable += 1 + continue + } + const source = await resolveOwnedOverlaySource( + directory, + readOpenCodeOverlayManifest(directory) + ) + if (!source) { + result.keptUnverifiable += 1 + continue + } + const presence = await inspectSourceDirectory(source) + if (presence === 'present') { + result.keptSourcePresent += 1 + continue + } + if (presence !== 'absent' || !(await hasNoServiceConfig(directory))) { + result.keptUnverifiable += 1 + continue + } + if (!(await oldEnough(directory, options.pluginFileName, now, minAge))) { + result.keptYoung += 1 + continue + } + // A restarted app's empty cache cannot establish surviving daemon terminals are gone. + const liveIds = await options.readLivePtyIds().catch(() => null) + if (liveIds === null || liveIds.length !== 0) { + result.keptUnverifiable += 1 + continue + } + if ( + isReferenced(directory, options.referencedConfigDirs) || + isReferenced(source, options.referencedConfigDirs) + ) { + result.keptReferenced += 1 + continue + } + if ( + (await inspectSourceDirectory(source)) !== 'absent' || + (await inspectSourceDirectory(options.overlayRoot)) !== 'present' || + !(await hasNoServiceConfig(directory)) + ) { + result.keptUnverifiable += 1 + continue + } + if ( + isReferenced(directory, options.referencedConfigDirs) || + isReferenced(source, options.referencedConfigDirs) + ) { + result.keptReferenced += 1 + continue + } + try { + removeTree(directory, options.overlayRoot) + try { + await lstat(directory) + result.failed += 1 + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + result.removed += 1 + } else { + result.failed += 1 + } + } + } catch { + result.failed += 1 + } + await yieldBetween() + } catch { + result.keptUnverifiable += 1 + } + } + return result +} diff --git a/src/main/opencode/overlay-dir-names.ts b/src/main/opencode/overlay-dir-names.ts new file mode 100644 index 00000000000..1e3a3a9a9f0 --- /dev/null +++ b/src/main/opencode/overlay-dir-names.ts @@ -0,0 +1,12 @@ +import { createHash } from 'node:crypto' + +export const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' +export const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' + +export function toSafeDirName(id: string): string { + return createHash('sha256').update(id).digest('hex').slice(0, 32) +} + +export function sourceOverlayDirName(sourceConfigDir: string): string { + return toSafeDirName(`source:${sourceConfigDir}`) +} diff --git a/src/main/opencode/overlay-source-ownership.test.ts b/src/main/opencode/overlay-source-ownership.test.ts new file mode 100644 index 00000000000..78c5d1c02ce --- /dev/null +++ b/src/main/opencode/overlay-source-ownership.test.ts @@ -0,0 +1,90 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import * as filesystem from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { inspectSourceDirectory, resolveOwnedOverlaySource } from './overlay-source-ownership' +import { sourceOverlayDirName } from './overlay-dir-names' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, lstat: vi.fn(actual.lstat) } +}) + +let root: string +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-overlay-source-'))) +}) +afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +it('distinguishes a missing source from an inaccessible source and a linked ancestor', async () => { + const source = join(root, 'source') + mkdirSync(source) + expect(await inspectSourceDirectory(source)).toBe('present') + expect(await inspectSourceDirectory(join(root, 'missing', 'config'))).toBe('absent') + expect(await inspectSourceDirectory('relative-config')).toBe('unverifiable') + writeFileSync(join(root, 'file'), '') + expect(await inspectSourceDirectory(join(root, 'file'))).toBe('unverifiable') + symlinkSync(source, join(root, 'link'), process.platform === 'win32' ? 'junction' : 'dir') + expect(await inspectSourceDirectory(join(root, 'link', 'missing'))).toBe('unverifiable') + const error = Object.assign(new Error('Denied'), { code: 'EACCES' }) + vi.mocked(filesystem.lstat).mockRejectedValueOnce(error) + expect(await inspectSourceDirectory(source)).toBe('unverifiable') +}) + +it('requires an absolute source whose hash matches the owned overlay', async () => { + const source = join(root, 'source') + const overlay = join(root, sourceOverlayDirName(source)) + const manifest = { topLevelEntries: [], pluginEntries: [], sourceConfigDir: source } + expect(await resolveOwnedOverlaySource(overlay, manifest)).toBe(source) + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, sourceConfigDir: join(root, 'other') }) + ).toBeUndefined() + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, sourceConfigDir: 'relative' }) + ).toBeUndefined() + expect( + await resolveOwnedOverlaySource(join(root, '123'), { + topLevelEntries: [], + pluginEntries: [], + sourceConfigDir: source + }) + ).toBeUndefined() +}) + +it.skipIf(process.platform === 'win32')( + 'recognizes an older source-scoped manifest only through its named mirrored link', + async () => { + const source = join(root, 'retired-source') + const overlay = join(root, sourceOverlayDirName(source)) + mkdirSync(overlay) + symlinkSync(join(source, 'opencode.json'), join(overlay, 'opencode.json')) + const manifest = { topLevelEntries: ['opencode.json'], pluginEntries: [] } + expect(await resolveOwnedOverlaySource(overlay, manifest)).toBe(source) + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, topLevelEntries: ['../other'] }) + ).toBeUndefined() + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, topLevelEntries: [] }) + ).toBeUndefined() + rmSync(join(overlay, 'opencode.json')) + writeFileSync(join(overlay, 'opencode.json'), '{}') + expect(await resolveOwnedOverlaySource(overlay, manifest)).toBeUndefined() + } +) + +it.skipIf(process.platform === 'win32')('rejects arbitrary legacy source links', async () => { + const source = join(root, 'source') + const overlay = join(root, sourceOverlayDirName(source)) + mkdirSync(overlay) + symlinkSync(join(root, 'other', 'opencode.json'), join(overlay, 'opencode.json')) + expect( + await resolveOwnedOverlaySource(overlay, { + topLevelEntries: ['opencode.json'], + pluginEntries: [] + }) + ).toBeUndefined() +}) diff --git a/src/main/opencode/overlay-source-ownership.ts b/src/main/opencode/overlay-source-ownership.ts new file mode 100644 index 00000000000..4f71cd191da --- /dev/null +++ b/src/main/opencode/overlay-source-ownership.ts @@ -0,0 +1,63 @@ +import { lstat, readlink } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path' +import { isSafeDescendCandidate } from '../pty/overlay-mirror' +import type { OpenCodeOverlayManifest } from './opencode-overlay-manifest' +import { sourceOverlayDirName } from './overlay-dir-names' + +export type SourceDirectoryPresence = 'present' | 'absent' | 'unverifiable' + +function isAbsent(error: unknown): boolean { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' +} + +export async function inspectSourceDirectory(path: string): Promise { + if (!isAbsolute(path)) { + return 'unverifiable' + } + const absolute = resolve(path) + let current = parse(absolute).root + const segments = relative(current, absolute).split(sep).filter(Boolean) + for (const segment of ['', ...segments]) { + current = join(current, segment) + try { + if (!isSafeDescendCandidate(await lstat(current))) { + return 'unverifiable' + } + } catch (error) { + return isAbsent(error) ? 'absent' : 'unverifiable' + } + } + return 'present' +} + +export async function resolveOwnedOverlaySource( + directory: string, + manifest: OpenCodeOverlayManifest +): Promise { + const matches = (source: string): boolean => + isAbsolute(source) && sourceOverlayDirName(source) === basename(directory) + if (manifest.sourceConfigDir !== undefined) { + return matches(manifest.sourceConfigDir) ? manifest.sourceConfigDir : undefined + } + // Older manifests prove a source only through a named, source-hash-matching mirror. + let source: string | undefined + for (const entry of manifest.topLevelEntries) { + if (!entry || basename(entry) !== entry || entry === '.' || entry === '..') { + return undefined + } + try { + const target = await readlink(join(directory, entry)) + const candidate = dirname(target) + if (!isAbsolute(target) || basename(target) !== entry || !matches(candidate)) { + return undefined + } + if (source !== undefined && source !== candidate) { + return undefined + } + source = candidate + } catch { + return undefined + } + } + return source +} diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 7bd3e41b378..b448681857e 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -26,6 +26,8 @@ import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/brows import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' import type { PreQuitCleanupFailureMode, UpdateInstallMode } from '../updater' import { scheduleHistoryGc } from '../terminal-history-gc' +import { openCodeHookService, openCode2HookService } from '../opencode/hook-service' +import { listLiveDaemonPtyIds } from '../daemon/daemon-provider-state' import { hydrateLocalPtyRegistryAtBoot } from '../memory/hydrate-local-pty-registry' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { getKnownWorktreeIdsForHistoryGc } from './history-gc-worktree-ids' @@ -107,6 +109,8 @@ export function attachMainWindowServices( scheduleHistoryGc(async () => { return getKnownWorktreeIdsForHistoryGc(store) }) + openCodeHookService.configDirGc.schedule(listLiveDaemonPtyIds) + openCode2HookService.configDirGc.schedule(listLiveDaemonPtyIds) const localPtyProviderStartupReady = options?.awaitLocalPtyProviderStartup?.() if (localPtyProviderStartupReady) { void localPtyProviderStartupReady