mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES,
|
||||
CONFIG_OVERLAY_MAX_SOURCE_ENTRIES,
|
||||
ConfigOverlayCapacityError
|
||||
} from '../pty/config-overlay-mirroring'
|
||||
import { _configOverlayManifestInternals } from './config-overlay-manifest'
|
||||
|
||||
const { parseOverlayManifest } = _configOverlayManifestInternals
|
||||
|
||||
describe('OpenCode overlay manifest bounds', () => {
|
||||
it('accepts the exact entry limit and rejects one more retained entry', () => {
|
||||
const exact = parseOverlayManifest(
|
||||
JSON.stringify({
|
||||
topLevelEntries: Array.from({ length: CONFIG_OVERLAY_MAX_SOURCE_ENTRIES }, () => 'same'),
|
||||
pluginEntries: []
|
||||
})
|
||||
)
|
||||
expect(exact.topLevelEntries).toHaveLength(CONFIG_OVERLAY_MAX_SOURCE_ENTRIES)
|
||||
|
||||
expect(() =>
|
||||
parseOverlayManifest(
|
||||
JSON.stringify({
|
||||
topLevelEntries: Array.from(
|
||||
{ length: CONFIG_OVERLAY_MAX_SOURCE_ENTRIES + 1 },
|
||||
() => 'same'
|
||||
),
|
||||
pluginEntries: []
|
||||
})
|
||||
)
|
||||
).toThrowError(
|
||||
new ConfigOverlayCapacityError(
|
||||
'entries',
|
||||
CONFIG_OVERLAY_MAX_SOURCE_ENTRIES + 1,
|
||||
CONFIG_OVERLAY_MAX_SOURCE_ENTRIES
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts the exact encoded-name budget and rejects the next name', () => {
|
||||
const name = 'a'.repeat(4_094)
|
||||
const names = Array.from({ length: CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES / 4_096 }, () => name)
|
||||
expect(
|
||||
parseOverlayManifest(JSON.stringify({ topLevelEntries: names })).topLevelEntries
|
||||
).toHaveLength(names.length)
|
||||
|
||||
expect(() =>
|
||||
parseOverlayManifest(JSON.stringify({ topLevelEntries: [...names, 'a'] }))
|
||||
).toThrowError(
|
||||
new ConfigOverlayCapacityError(
|
||||
'retained-name-bytes',
|
||||
CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES + 3,
|
||||
CONFIG_OVERLAY_MAX_RETAINED_NAME_BYTES
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('never authorizes cleanup of reserved overlay-owned paths', () => {
|
||||
expect(
|
||||
parseOverlayManifest(
|
||||
JSON.stringify({
|
||||
topLevelEntries: ['plugins', '.orca-opencode-overlay-manifest.json', 'auth.json'],
|
||||
pluginEntries: ['orca-opencode-status.js', 'user-plugin.js']
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
topLevelEntries: ['auth.json'],
|
||||
pluginEntries: ['user-plugin.js']
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,164 +0,0 @@
|
||||
import { unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify'
|
||||
import {
|
||||
NodeFileReadTooLargeError,
|
||||
readNodeFileSyncWithinLimit
|
||||
} from '../../shared/node-bounded-file-reader'
|
||||
import {
|
||||
CONFIG_OVERLAY_MAX_SOURCE_ENTRIES,
|
||||
ConfigOverlayCapacityError,
|
||||
ConfigOverlayEntryBudget,
|
||||
applyConfigOverlayPlan,
|
||||
createConfigOverlayPlan,
|
||||
type AppliedConfigOverlayEntries
|
||||
} from '../pty/config-overlay-mirroring'
|
||||
import { safeRemoveTree } from '../pty/overlay-mirror'
|
||||
|
||||
export const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js'
|
||||
export const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json'
|
||||
export const OPENCODE_OVERLAY_MANIFEST_MAX_BYTES = 2 * 1_024 * 1_024
|
||||
|
||||
type OpenCodeOverlayManifest = {
|
||||
topLevelEntries: string[]
|
||||
pluginEntries: string[]
|
||||
}
|
||||
|
||||
const RESERVED_TOP_LEVEL_ENTRIES = new Set([OPENCODE_OVERLAY_MANIFEST_FILE])
|
||||
|
||||
function emptyManifest(): OpenCodeOverlayManifest {
|
||||
return { topLevelEntries: [], pluginEntries: [] }
|
||||
}
|
||||
|
||||
function parseManifestEntryNames(
|
||||
value: unknown,
|
||||
kind: 'top-level' | 'plugin',
|
||||
budget: ConfigOverlayEntryBudget
|
||||
): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const names: string[] = []
|
||||
for (const candidate of value) {
|
||||
if (typeof candidate !== 'string') {
|
||||
continue
|
||||
}
|
||||
budget.reserve(candidate)
|
||||
if (
|
||||
(kind === 'top-level' &&
|
||||
(candidate === 'plugins' || candidate === OPENCODE_OVERLAY_MANIFEST_FILE)) ||
|
||||
(kind === 'plugin' && candidate === ORCA_OPENCODE_PLUGIN_FILE)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
names.push(candidate)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
function parseOverlayManifest(contents: string): OpenCodeOverlayManifest {
|
||||
const parsed = JSON.parse(contents) as Partial<OpenCodeOverlayManifest>
|
||||
const topLevelCount = Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries.length : 0
|
||||
const pluginCount = Array.isArray(parsed.pluginEntries) ? parsed.pluginEntries.length : 0
|
||||
if (topLevelCount + pluginCount > CONFIG_OVERLAY_MAX_SOURCE_ENTRIES) {
|
||||
throw new ConfigOverlayCapacityError(
|
||||
'entries',
|
||||
topLevelCount + pluginCount,
|
||||
CONFIG_OVERLAY_MAX_SOURCE_ENTRIES
|
||||
)
|
||||
}
|
||||
|
||||
const budget = new ConfigOverlayEntryBudget()
|
||||
return {
|
||||
topLevelEntries: parseManifestEntryNames(parsed.topLevelEntries, 'top-level', budget),
|
||||
pluginEntries: parseManifestEntryNames(parsed.pluginEntries, 'plugin', budget)
|
||||
}
|
||||
}
|
||||
|
||||
function readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest {
|
||||
try {
|
||||
const contents = readNodeFileSyncWithinLimit(
|
||||
join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE),
|
||||
OPENCODE_OVERLAY_MANIFEST_MAX_BYTES
|
||||
).buffer.toString('utf8')
|
||||
return parseOverlayManifest(contents)
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigOverlayCapacityError || error instanceof NodeFileReadTooLargeError) {
|
||||
throw error
|
||||
}
|
||||
return emptyManifest()
|
||||
}
|
||||
}
|
||||
|
||||
function removePathBeforeWrite(path: string): void {
|
||||
try {
|
||||
unlinkSync(path)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void {
|
||||
const manifestPath = join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE)
|
||||
const { serialized } = stringifyJsonWithinByteLimit(
|
||||
manifest,
|
||||
OPENCODE_OVERLAY_MANIFEST_MAX_BYTES - 1
|
||||
)
|
||||
removePathBeforeWrite(manifestPath)
|
||||
writeFileSync(manifestPath, `${serialized}\n`)
|
||||
}
|
||||
|
||||
function clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void {
|
||||
for (const entryName of manifest.topLevelEntries) {
|
||||
if (!safeRemoveTree(join(overlayDir, entryName))) {
|
||||
throw new Error('Unable to clear an OpenCode config overlay entry')
|
||||
}
|
||||
}
|
||||
|
||||
const overlayPluginsDir = join(overlayDir, 'plugins')
|
||||
for (const entryName of manifest.pluginEntries) {
|
||||
if (!safeRemoveTree(join(overlayPluginsDir, entryName))) {
|
||||
throw new Error('Unable to clear an OpenCode plugin overlay entry')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toManifest(applied: AppliedConfigOverlayEntries): OpenCodeOverlayManifest {
|
||||
return {
|
||||
topLevelEntries: applied.topLevelEntryNames,
|
||||
pluginEntries: applied.pluginEntryNames
|
||||
}
|
||||
}
|
||||
|
||||
export function mirrorOpenCodeConfigWithManifest(sourceDir: string, overlayDir: string): void {
|
||||
const plan = createConfigOverlayPlan(sourceDir, {
|
||||
reservedPluginFile: ORCA_OPENCODE_PLUGIN_FILE,
|
||||
reservedTopLevelEntryNames: RESERVED_TOP_LEVEL_ENTRIES
|
||||
})
|
||||
const previousManifest = readOverlayManifest(overlayDir)
|
||||
clearManifestEntries(overlayDir, previousManifest)
|
||||
|
||||
const applied: AppliedConfigOverlayEntries = {
|
||||
topLevelEntryNames: [],
|
||||
pluginEntryNames: []
|
||||
}
|
||||
try {
|
||||
applyConfigOverlayPlan(plan, overlayDir, applied)
|
||||
writeOverlayManifest(overlayDir, toManifest(applied))
|
||||
} catch (error) {
|
||||
try {
|
||||
clearManifestEntries(overlayDir, toManifest(applied))
|
||||
} catch {
|
||||
// Preserve the original mirror/write failure; a later spawn can retry cleanup.
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const _configOverlayManifestInternals = {
|
||||
parseOverlayManifest,
|
||||
writeOverlayManifest
|
||||
}
|
||||
@@ -8,12 +8,10 @@ import {
|
||||
readdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
truncateSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { GENERATED_NODE_MANAGED_FILE_MAX_BYTES } from '../generated-node-bounded-file-reader'
|
||||
|
||||
const { getPathMock } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn<(name: string) => string>()
|
||||
@@ -26,10 +24,6 @@ vi.mock('electron', () => ({
|
||||
}))
|
||||
|
||||
import { OpenCodeHookService, _internals } from './hook-service'
|
||||
import {
|
||||
OPENCODE_OVERLAY_MANIFEST_FILE,
|
||||
OPENCODE_OVERLAY_MANIFEST_MAX_BYTES
|
||||
} from './config-overlay-manifest'
|
||||
|
||||
const { isUsableId, toSafeDirName } = _internals
|
||||
|
||||
@@ -71,11 +65,6 @@ describe('OpenCode hook plugin source', () => {
|
||||
expect(source).toContain('const coords = resolveHookCoords();')
|
||||
expect(source).toContain('`http://127.0.0.1:${coords.port}/hook/opencode`')
|
||||
expect(source).toContain('"X-Orca-Agent-Hook-Token": coords.token')
|
||||
expect(source).toContain(
|
||||
`function readOrcaManagedFileWithinLimit(fs, path, maxBytes = ${GENERATED_NODE_MANAGED_FILE_MAX_BYTES})`
|
||||
)
|
||||
expect(source).toContain('readOrcaManagedFileWithinLimit(fs, path)')
|
||||
expect(source).not.toContain('fs.readFileSync')
|
||||
})
|
||||
|
||||
it('caches the parsed endpoint file on mtime+size+inode to skip re-reads per post', () => {
|
||||
@@ -366,81 +355,6 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () =
|
||||
expectUserConfigIntact()
|
||||
})
|
||||
|
||||
it('does not mirror or overwrite a user file named like the internal manifest', () => {
|
||||
const userManifest = join(userConfigDir, OPENCODE_OVERLAY_MANIFEST_FILE)
|
||||
writeFileSync(userManifest, 'USER MANIFEST SENTINEL')
|
||||
|
||||
const env = new OpenCodeHookService().buildPtyEnv(ptyId, userConfigDir)
|
||||
const overlayManifest = join(env.OPENCODE_CONFIG_DIR!, OPENCODE_OVERLAY_MANIFEST_FILE)
|
||||
|
||||
expect(readFileSync(userManifest, 'utf8')).toBe('USER MANIFEST SENTINEL')
|
||||
expect(lstatSync(overlayManifest).isSymbolicLink()).toBe(false)
|
||||
expect(JSON.parse(readFileSync(overlayManifest, 'utf8'))).toMatchObject({
|
||||
topLevelEntries: expect.arrayContaining(['auth.json', 'opencode.json'])
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back before mutation when a retained manifest exceeds its byte limit', () => {
|
||||
const service = new OpenCodeHookService()
|
||||
const first = service.buildPtyEnv(ptyId, userConfigDir)
|
||||
const overlayDir = first.OPENCODE_CONFIG_DIR!
|
||||
const overlayManifest = join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE)
|
||||
truncateSync(overlayManifest, OPENCODE_OVERLAY_MANIFEST_MAX_BYTES + 1)
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
expect(service.buildPtyEnv(ptyId, userConfigDir)).toEqual({
|
||||
OPENCODE_CONFIG_DIR: userConfigDir
|
||||
})
|
||||
expect(readFileSync(join(overlayDir, 'auth.json'), 'utf8')).toBe('user-auth-token')
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[opencode-hooks] config overlay exceeded its memory limit; using the original OPENCODE_CONFIG_DIR without Orca status integration'
|
||||
)
|
||||
expectUserConfigIntact()
|
||||
})
|
||||
|
||||
it('ignores forged manifest traversal entries during cleanup', () => {
|
||||
const overlayDir = join(
|
||||
userDataDir,
|
||||
'opencode-config-overlays',
|
||||
toSafeDirName(`source:${userConfigDir}`)
|
||||
)
|
||||
mkdirSync(overlayDir, { recursive: true })
|
||||
const outsideMarker = join(userDataDir, 'outside-manifest-marker')
|
||||
writeFileSync(outsideMarker, 'keep')
|
||||
writeFileSync(
|
||||
join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE),
|
||||
JSON.stringify({ topLevelEntries: ['../outside-manifest-marker'] })
|
||||
)
|
||||
|
||||
const env = new OpenCodeHookService().buildPtyEnv(ptyId, userConfigDir)
|
||||
|
||||
expect(env.OPENCODE_CONFIG_DIR).toBe(overlayDir)
|
||||
expect(readFileSync(outsideMarker, 'utf8')).toBe('keep')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'replaces a stale manifest symlink without writing through to the user file',
|
||||
() => {
|
||||
const userManifest = join(userConfigDir, OPENCODE_OVERLAY_MANIFEST_FILE)
|
||||
writeFileSync(userManifest, 'USER MANIFEST SENTINEL')
|
||||
const overlayDir = join(
|
||||
userDataDir,
|
||||
'opencode-config-overlays',
|
||||
toSafeDirName(`source:${userConfigDir}`)
|
||||
)
|
||||
mkdirSync(overlayDir, { recursive: true })
|
||||
symlinkSync(userManifest, join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'file')
|
||||
|
||||
const env = new OpenCodeHookService().buildPtyEnv(ptyId, userConfigDir)
|
||||
|
||||
expect(env.OPENCODE_CONFIG_DIR).toBe(overlayDir)
|
||||
expect(readFileSync(userManifest, 'utf8')).toBe('USER MANIFEST SENTINEL')
|
||||
expect(lstatSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE)).isSymbolicLink()).toBe(
|
||||
false
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'does not write through a symlinked plugins/ directory into the user filesystem',
|
||||
() => {
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
/* eslint-disable max-lines -- Why: holds an inline JS plugin source emitted as one file; splitting across TS modules would scatter tightly coupled string-template logic. */
|
||||
import { app } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { NodeFileReadTooLargeError } from '../../shared/node-bounded-file-reader'
|
||||
import { getGeneratedNodeBoundedFileReaderSourceLines } from '../generated-node-bounded-file-reader'
|
||||
import { ConfigOverlayCapacityError } from '../pty/config-overlay-mirroring'
|
||||
import {
|
||||
ORCA_OPENCODE_PLUGIN_FILE,
|
||||
mirrorOpenCodeConfigWithManifest
|
||||
} from './config-overlay-manifest'
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror'
|
||||
|
||||
const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js'
|
||||
const OPENCODE_LEGACY_HOOKS_DIR = 'opencode-hooks'
|
||||
const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays'
|
||||
const OPENCODE_SHARED_CONFIG_DIR = 'shared'
|
||||
const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json'
|
||||
|
||||
type OpenCodeOverlayManifest = {
|
||||
topLevelEntries: string[]
|
||||
pluginEntries: string[]
|
||||
}
|
||||
|
||||
// Why: bounds-check only — the id is a daemon sessionId with path separators, hashed downstream to a filesystem-safe name (an old regex rejecting "/"/":" broke every such id, #1148); 1024 just caps pathological hash input.
|
||||
function isUsableId(id: string): boolean {
|
||||
@@ -38,13 +48,12 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string {
|
||||
"// OpenCode's Node process (not Orca's) and has no access to server.ts's",
|
||||
'// equivalent warnedVersions / warnedEnvs Sets.',
|
||||
'let warnedBadEndpoint = false;',
|
||||
...getGeneratedNodeBoundedFileReaderSourceLines(),
|
||||
'',
|
||||
'// Why: message.part.updated can fire many times per second during a',
|
||||
'// streaming assistant reply, and each post() calls resolveHookCoords()',
|
||||
'// which reads the endpoint file. The file only changes on Orca restart',
|
||||
'// (rare), so a stat+mtime check is substantially cheaper than a full',
|
||||
'// bounded read+parse on every streamed part. On stat error we fall',
|
||||
'// readFileSync+parse on every streamed part. On stat error we fall',
|
||||
'// through to parse so the fail-open behavior is preserved.',
|
||||
'let cachedEndpointKey = "";',
|
||||
'let cachedEndpointValues = null;',
|
||||
@@ -65,7 +74,7 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string {
|
||||
' if (cacheKey === cachedEndpointKey && cachedEndpointValues) {',
|
||||
' return cachedEndpointValues;',
|
||||
' }',
|
||||
' const contents = readOrcaManagedFileWithinLimit(fs, path);',
|
||||
' const contents = fs.readFileSync(path, "utf8");',
|
||||
' const out = {};',
|
||||
' for (const line of contents.split(/\\r?\\n/)) {',
|
||||
' // Why: Windows endpoint.cmd uses `set KEY=VALUE`; Unix endpoint.env',
|
||||
@@ -82,7 +91,7 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string {
|
||||
' return out;',
|
||||
' } catch (ioErr) {',
|
||||
' // Why: any stat or read failure (file yanked mid-read, permission',
|
||||
' // race, unlink between stat and bounded read) must invalidate the',
|
||||
' // race, unlink between stat and readFileSync) must invalidate the',
|
||||
' // cache so a transient failure does not lock in a stale parse for',
|
||||
' // the remaining process lifetime; rethrow to the outer catch.',
|
||||
' cachedEndpointKey = "";',
|
||||
@@ -368,8 +377,6 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string {
|
||||
|
||||
// Why: installs the plugin into OPENCODE_CONFIG_DIR so it POSTs to the shared agent-hooks server, unifying OpenCode status with Claude/Codex/Gemini (the old loopback-IPC path never reached agentStatusByPaneKey).
|
||||
export class OpenCodeHookService {
|
||||
private warnedOverlayCapacity = false
|
||||
|
||||
clearPty(_ptyId: string): void {
|
||||
// Why: no-op — config dirs are app/source-scoped now, and recursive delete on the main-process hot path could freeze on Windows.
|
||||
}
|
||||
@@ -400,16 +407,7 @@ export class OpenCodeHookService {
|
||||
mkdirSync(overlayDir, { recursive: true })
|
||||
this.mirrorUserConfig(existingConfigDir, overlayDir)
|
||||
this.writePluginIntoOverlay(overlayDir)
|
||||
} catch (error) {
|
||||
if (
|
||||
!this.warnedOverlayCapacity &&
|
||||
(error instanceof ConfigOverlayCapacityError || error instanceof NodeFileReadTooLargeError)
|
||||
) {
|
||||
this.warnedOverlayCapacity = true
|
||||
console.warn(
|
||||
'[opencode-hooks] config overlay exceeded its memory limit; using the original OPENCODE_CONFIG_DIR without Orca status integration'
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Why: best-effort — symlink creation needs Windows developer mode (else EPERM) and userData may be read-only; preserve the user's config over dropping their auth/models/keymap.
|
||||
return { OPENCODE_CONFIG_DIR: existingConfigDir }
|
||||
}
|
||||
@@ -429,8 +427,90 @@ export class OpenCodeHookService {
|
||||
return join(app.getPath('userData'), OPENCODE_LEGACY_HOOKS_DIR, OPENCODE_SHARED_CONFIG_DIR)
|
||||
}
|
||||
|
||||
private readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8')
|
||||
) as Partial<OpenCodeOverlayManifest>
|
||||
return {
|
||||
topLevelEntries: Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries : [],
|
||||
pluginEntries: Array.isArray(parsed.pluginEntries) ? parsed.pluginEntries : []
|
||||
}
|
||||
} catch {
|
||||
return { topLevelEntries: [], pluginEntries: [] }
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
safeRemoveTree(join(overlayDir, entryName))
|
||||
}
|
||||
|
||||
const overlayPluginsDir = join(overlayDir, 'plugins')
|
||||
for (const entryName of manifest.pluginEntries) {
|
||||
if (entryName === ORCA_OPENCODE_PLUGIN_FILE) {
|
||||
continue
|
||||
}
|
||||
safeRemoveTree(join(overlayPluginsDir, entryName))
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
mirrorOpenCodeConfigWithManifest(sourceDir, overlayDir)
|
||||
const previousManifest = this.readOverlayManifest(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)
|
||||
|
||||
const nextManifest: OpenCodeOverlayManifest = { topLevelEntries: [], pluginEntries: [] }
|
||||
|
||||
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
|
||||
const sourcePath = join(sourceDir, entry.name)
|
||||
|
||||
if (entry.name === 'plugins') {
|
||||
// Why: check isSymbolicLink before isDirectory — a Windows junction reports both, and the symlink branch must win.
|
||||
const isSymlink = entry.isSymbolicLink()
|
||||
let isLinkPointingToDir = false
|
||||
if (isSymlink) {
|
||||
try {
|
||||
isLinkPointingToDir = statSync(sourcePath).isDirectory()
|
||||
} catch {
|
||||
// Why: broken/inaccessible symlink — mirror the dangling link verbatim instead of resolving through it.
|
||||
isLinkPointingToDir = false
|
||||
}
|
||||
}
|
||||
|
||||
if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) {
|
||||
// Why: resolve a symlinked plugins/ to its real target so <overlay>/plugins stays a real dir and writePluginIntoOverlay can't write through the user's link.
|
||||
const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath
|
||||
const overlayPluginsDir = join(overlayDir, 'plugins')
|
||||
mkdirSync(overlayPluginsDir, { recursive: true })
|
||||
for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) {
|
||||
// Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file.
|
||||
if (pluginEntry.name === ORCA_OPENCODE_PLUGIN_FILE) {
|
||||
continue
|
||||
}
|
||||
mirrorEntry(
|
||||
join(resolvedSource, pluginEntry.name),
|
||||
join(overlayPluginsDir, pluginEntry.name)
|
||||
)
|
||||
nextManifest.pluginEntries.push(pluginEntry.name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
mirrorEntry(sourcePath, join(overlayDir, entry.name))
|
||||
nextManifest.topLevelEntries.push(entry.name)
|
||||
}
|
||||
|
||||
this.writeOverlayManifest(overlayDir, nextManifest)
|
||||
}
|
||||
|
||||
// Why: pre-write unlink guards against POSIX writeFileSync writing through a mirrored symlink and clobbering a same-named user plugin.
|
||||
@@ -440,10 +520,8 @@ export class OpenCodeHookService {
|
||||
const pluginPath = join(pluginsDir, ORCA_OPENCODE_PLUGIN_FILE)
|
||||
try {
|
||||
unlinkSync(pluginPath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
} catch {
|
||||
// File may not exist on a fresh overlay; a real failure surfaces on writeFileSync below.
|
||||
}
|
||||
writeFileSync(pluginPath, getOpenCodePluginSource())
|
||||
}
|
||||
@@ -453,7 +531,7 @@ export class OpenCodeHookService {
|
||||
const pluginsDir = join(configDir, 'plugins')
|
||||
try {
|
||||
mkdirSync(pluginsDir, { recursive: true })
|
||||
this.writePluginIntoOverlay(configDir)
|
||||
writeFileSync(join(pluginsDir, ORCA_OPENCODE_PLUGIN_FILE), getOpenCodePluginSource())
|
||||
} catch {
|
||||
// Why: userData can be locked on Windows (EPERM/EBUSY); plugin is non-critical, so spawn without it.
|
||||
return null
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { collectOpenCodeDatabaseFiles } from './opencode-database-files'
|
||||
|
||||
function entry(name: string, file = true): { name: string; isFile(): boolean } {
|
||||
return { name, isFile: () => file }
|
||||
}
|
||||
|
||||
describe('OpenCode database file discovery', () => {
|
||||
it('preserves and sorts every matching file within the limit', async () => {
|
||||
const result = await collectOpenCodeDatabaseFiles(
|
||||
'/data',
|
||||
(async function* () {
|
||||
yield entry('opencode-z.db')
|
||||
yield entry('notes.txt')
|
||||
yield entry('opencode.db')
|
||||
yield entry('opencode-directory.db', false)
|
||||
})(),
|
||||
2
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
paths: ['/data/opencode-z.db', '/data/opencode.db'].sort(),
|
||||
truncated: false
|
||||
})
|
||||
})
|
||||
|
||||
it('stops retaining names immediately after the database count limit', async () => {
|
||||
let enumerated = 0
|
||||
const result = await collectOpenCodeDatabaseFiles(
|
||||
'/data',
|
||||
(async function* () {
|
||||
for (const name of ['opencode-a.db', 'opencode-b.db', 'opencode-c.db', 'opencode-d.db']) {
|
||||
enumerated += 1
|
||||
yield entry(name)
|
||||
}
|
||||
})(),
|
||||
2
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
paths: ['/data/opencode-a.db', '/data/opencode-b.db'],
|
||||
truncated: true
|
||||
})
|
||||
expect(enumerated).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import { opendir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export const OPENCODE_DATABASE_FILE_LIMIT = 256
|
||||
|
||||
type OpenCodeDatabaseDirectoryEntry = {
|
||||
name: string
|
||||
isFile(): boolean
|
||||
}
|
||||
|
||||
export type OpenCodeDatabaseFiles = {
|
||||
paths: string[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export async function listOpenCodeDatabaseFiles(dataDir: string): Promise<OpenCodeDatabaseFiles> {
|
||||
try {
|
||||
return collectOpenCodeDatabaseFiles(dataDir, await opendir(dataDir))
|
||||
} catch {
|
||||
return { paths: [], truncated: false }
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectOpenCodeDatabaseFiles(
|
||||
dataDir: string,
|
||||
directory: AsyncIterable<OpenCodeDatabaseDirectoryEntry>,
|
||||
maxFiles = OPENCODE_DATABASE_FILE_LIMIT
|
||||
): Promise<OpenCodeDatabaseFiles> {
|
||||
const paths: string[] = []
|
||||
for await (const entry of directory) {
|
||||
if (!entry.isFile() || !/^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) {
|
||||
continue
|
||||
}
|
||||
if (paths.length >= maxFiles) {
|
||||
return { paths: paths.sort(), truncated: true }
|
||||
}
|
||||
paths.push(join(dataDir, entry.name))
|
||||
}
|
||||
return { paths: paths.sort(), truncated: false }
|
||||
}
|
||||
Reference in New Issue
Block a user