Clean up retired OpenCode configuration copies safely (#25222)

* fix: wait for OpenCode worker composer before first dispatch

Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors.

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>

* feat(opencode): probe execution-host CLI capabilities

* fix(opencode): select plugin default for execution host loader

* fix(opencode): limit prompt prefill capability to verified release

* feat(opencode): probe launch capabilities on the execution host

* fix(opencode): select plugin loader for the launched host binary

* fix(opencode): match WSL probe cwd and declared guest environment

* fix(opencode): preserve launch environment deletion boundaries

* wip(opencode): authorize native startup prompt intent at execution owner

* fix(opencode): atomically replace status plugin entrypoints

* fix(opencode): retain plugin permissions across restrictive umasks

* test(opencode): resolve permission fixture from primary cwd

* feat(opencode): install startup prompt plugin independently of status hooks

* fix(opencode): wait for admitted startup intent and preserve failed-launch briefs

* fix(opencode): confine overlay manifest cleanup to owned directories

Co-authored-by: Adnan Khan <adnank11427@gmail.com>

* fix: wait for OpenCode worker composer before first dispatch

Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors.

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>

* feat(opencode): probe execution-host CLI capabilities

* fix(opencode): select plugin default for execution host loader

* fix(opencode): limit prompt prefill capability to verified release

* feat(opencode): probe launch capabilities on the execution host

* fix(opencode): select plugin loader for the launched host binary

* fix(opencode): match WSL probe cwd and declared guest environment

* fix(opencode): preserve launch environment deletion boundaries

* wip(opencode): authorize native startup prompt intent at execution owner

* fix(opencode): atomically replace status plugin entrypoints

* fix(opencode): retain plugin permissions across restrictive umasks

* test(opencode): resolve permission fixture from primary cwd

* feat(opencode): install startup prompt plugin independently of status hooks

* fix(opencode): wait for admitted startup intent and preserve failed-launch briefs

* fix(opencode): unsubscribe hook settings during async host shutdown

* STRICT launch CI contract correction

* CAPS launch CI contract correction

* INTENT launch CI contract correction

* test: initialize Claude prompt state in output retention fixture

* Wait for OpenCode location hydration in intent startup

* Bind OpenCode startup readiness to the current location in intent startup

* Collect retired source-scoped OpenCode configuration overlays conservatively

Credit brennanb2025 for the original bounded, delayed overlay garbage-collection contribution in PR #7627. Preserve ambiguous legacy and shared-service state.

* Correct inaccessible-source fixture without spying on native ESM exports

* Keep delayed OpenCode cleanup within existing file limits

* Reuse the overlay manifest module for existing owned-entry operations

* Use the existing filesystem import in the ownership mock

* test(opencode): keep overlay GC link tests portable

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Ahmed Nagy <ahmednagy25t@gmail.com>
Co-authored-by: Adnan Khan <adnank11427@gmail.com>
Co-authored-by: Orca startup hydration review <agents@stably.ai>
Co-authored-by: OpenCode Campaign <opencode-campaign@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-04 05:40:05 -07:00
committed by GitHub
co-authored by Brennan Benson Adnan Khan Ahmed Nagy Orca startup hydration review OpenCode Campaign
parent 87bc51d371
commit 70cf91299b
11 changed files with 767 additions and 40 deletions
+23 -38
View File
@@ -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 {
@@ -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 })
}
})
+37 -2
View File
@@ -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)
}
}
@@ -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)
})
@@ -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<readonly string[] | null>
export class OpenCodeDirGcLifecycle {
private readonly references = new Set<string>()
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<OpenCodeDirGcResult> {
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
})
}
}
+227
View File
@@ -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<Parameters<typeof sweepOrphanedOpenCodeDirs>[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<string>()
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)
})
+206
View File
@@ -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<string>
readLivePtyIds: () => Promise<readonly string[] | null>
now?: number
minAgeMs?: number
maxRemovals?: number
removeTree?: (directory: string, root: string) => void
yieldBetweenRemovals?: () => Promise<void>
}
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<string>): 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<boolean> {
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<OpenCodeDirGcResult> {
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
}
+12
View File
@@ -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}`)
}
@@ -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<typeof filesystem>()
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()
})
@@ -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<SourceDirectoryPresence> {
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<string | undefined> {
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
}
@@ -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