mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
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:
co-authored by
Brennan Benson
Adnan Khan
Ahmed Nagy
Orca startup hydration review
OpenCode Campaign
parent
87bc51d371
commit
70cf91299b
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user