mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(main): move freeze-prone sync fs off the main thread
A synchronous fs syscall on the Electron main thread parks the whole app when its target is on a stalled mount (SMB/NFS, VPN drop, sleeping NAS, unmounted drive). The app stops repainting and stops answering Force Quit. This converts 28 such paths, covering the ones users actually hit: - orca.yaml hook config, read behind the POLLED git:status path, so a stalled repo froze the app on a timer with no user action - agent hook status/install for 14 agents, incl. the shared reader that 10 of them funnel through and a serial install loop with sync fs per body - credential status for Codex/Grok/MiniMax/speech, also called off-IPC by RateLimitService on a refresh timer -- froze a fully idle app - external path authorization and agent trust setup - profile index, keybindings, diagnostics discovery Measured first, since it shapes the fix: libuv's threadpool defaults to 4 threads, and at exactly poolSize concurrent stalled ops every other async fs call app-wide stops progressing. But the event loop stayed alive in all configurations (max timer gap 4ms). So async conversion does not bound a hung mount -- it keeps the main thread responsive, which is the reported symptom. UV_THREADPOOL_SIZE is raised to 16 as headroom, not as a bound. No timeout machinery was added; it cannot bound a block on its own thread. Where a sync twin had to stay (CLI processes, pre-window startup, callers that cannot await), the write path keeps single-thread serialization rather than relying on a promise chain a sync writer walks straight through: canonicalization moves off-thread while the read-modify-write stays sync. Where both twins must coexist, the async writer publishes its temp path and the sync writer unlinks it, turning the parked rename into a swallowed ENOENT -- a generation guard alone is spent once the writer parks. observability/local-file-sink.ts is deliberately untouched; its writeSync is a crash-durability contract that needs a process boundary, not an await.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
// Does converting a main-thread sync fs call to fs/promises actually fix a stalled-mount
|
||||
// freeze, or just move the block into libuv's (default 4) threadpool threads?
|
||||
//
|
||||
// A FIFO with no writer stands in for a hung mount: open(2) blocks and cannot be cancelled,
|
||||
// which is the same shape as an SMB stat in D-state.
|
||||
//
|
||||
// Usage: node config/scripts/libuv-threadpool-starvation-probe.mjs [stuckCount]
|
||||
// UV_THREADPOOL_SIZE=N to vary the pool.
|
||||
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const stuckCount = Number(process.argv[2] ?? 4)
|
||||
const poolSize = process.env.UV_THREADPOOL_SIZE ?? '4 (default)'
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-threadpool-probe-'))
|
||||
|
||||
const fifos = Array.from({ length: stuckCount }, (_, i) => {
|
||||
const p = join(dir, `stuck-${i}.fifo`)
|
||||
execFileSync('mkfifo', [p])
|
||||
return p
|
||||
})
|
||||
|
||||
// Event-loop liveness: a timer firing on schedule proves the main thread itself is free.
|
||||
let maxTimerGapMs = 0
|
||||
let lastTick = performance.now()
|
||||
const ticker = setInterval(() => {
|
||||
const now = performance.now()
|
||||
maxTimerGapMs = Math.max(maxTimerGapMs, now - lastTick - 25)
|
||||
lastTick = now
|
||||
}, 25)
|
||||
|
||||
// Park `stuckCount` threadpool threads on opens that never return.
|
||||
for (const fifo of fifos) {
|
||||
void readFile(fifo, 'utf-8').catch(() => {})
|
||||
}
|
||||
|
||||
// Give libuv a moment to dispatch them onto threads.
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
// The question: can an UNRELATED async fs call still make progress?
|
||||
const startedAt = performance.now()
|
||||
let healthyOpMs = null
|
||||
let healthyOpTimedOut = false
|
||||
await Promise.race([
|
||||
stat(process.cwd()).then(() => {
|
||||
healthyOpMs = performance.now() - startedAt
|
||||
}),
|
||||
new Promise((r) => setTimeout(r, 3000)).then(() => {
|
||||
healthyOpTimedOut = true
|
||||
})
|
||||
])
|
||||
|
||||
clearInterval(ticker)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
poolSize,
|
||||
stuckOps: stuckCount,
|
||||
eventLoopAlive: maxTimerGapMs < 250,
|
||||
maxTimerGapMs: Math.round(maxTimerGapMs),
|
||||
unrelatedAsyncFsCompleted: !healthyOpTimedOut,
|
||||
unrelatedAsyncFsMs: healthyOpMs === null ? null : Math.round(healthyOpMs),
|
||||
verdict: healthyOpTimedOut
|
||||
? 'THREADPOOL STARVED — async conversion relocates the hang, it does not bound it'
|
||||
: 'async fs still progressing — main thread and threadpool both healthy'
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
// Why no cleanup here: unlinking a FIFO that still has a blocked opener wedges this process
|
||||
// after the measurement is already printed. The caller removes the directory instead.
|
||||
console.error(`probe scratch dir (remove after): ${dir}`)
|
||||
process.exit(0)
|
||||
@@ -146,7 +146,7 @@ async function setAgentHooksEnabled(
|
||||
const offlineUpdate = updatedRuntime ? null : updateEnabledOnDisk(enabled)
|
||||
const settingsPath = offlineUpdate?.settingsPath ?? getDataPath()
|
||||
const statuses = updatedRuntime
|
||||
? getManagedAgentHookStatuses()
|
||||
? await getManagedAgentHookStatuses()
|
||||
: await applyAgentStatusHooksEnabled(enabled, offlineUpdate?.settings)
|
||||
return {
|
||||
enabled,
|
||||
@@ -162,7 +162,7 @@ export const AGENT_HOOK_HANDLERS: Record<string, CommandHandler> = {
|
||||
enabled: readEnabledFromDisk(),
|
||||
settingsPath: getDataPath(),
|
||||
appliedBy: 'offline',
|
||||
statuses: getManagedAgentHookStatuses()
|
||||
statuses: await getManagedAgentHookStatuses()
|
||||
}
|
||||
printResult(localSuccess(result), json, formatAgentHookCommandResult)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import type * as NodeFsModule from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as NodeOsModule from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createManagedCommandMatcher,
|
||||
MANAGED_HOOK_TIMEOUT_MILLISECONDS,
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
} from './installer-utils'
|
||||
|
||||
// Why: a stalled SMB/NFS HOME turns any *Sync fs call on the Electron main
|
||||
// thread into an uninterruptible wait — the app stops repainting and Force Quit
|
||||
// stops working. These tests fail if the agent-hook install path regains one.
|
||||
const state = vi.hoisted(() => ({
|
||||
home: '',
|
||||
syncCalls: [] as { name: string; target: string }[],
|
||||
blockAsyncFs: false
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFsModule>()
|
||||
const wrapped: Record<string, unknown> = { ...actual }
|
||||
for (const [name, value] of Object.entries(actual)) {
|
||||
if (!name.endsWith('Sync') || typeof value !== 'function') {
|
||||
continue
|
||||
}
|
||||
const original = value as ((...args: unknown[]) => unknown) & Record<string, unknown>
|
||||
const recorder = (...args: unknown[]): unknown => {
|
||||
state.syncCalls.push({ name, target: typeof args[0] === 'string' ? args[0] : '' })
|
||||
return original(...args)
|
||||
}
|
||||
// Why: realpathSync.native is a property of the function; a bare spread drops it.
|
||||
Object.assign(recorder, original)
|
||||
wrapped[name] = recorder
|
||||
}
|
||||
return { ...wrapped, default: wrapped }
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFsPromisesModule>()
|
||||
return {
|
||||
...actual,
|
||||
default: actual,
|
||||
readFile: (...args: Parameters<typeof actual.readFile>) =>
|
||||
state.blockAsyncFs ? new Promise<never>(() => {}) : actual.readFile(...args)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeOsModule>()
|
||||
const patched = { ...actual, homedir: () => state.home }
|
||||
return { ...patched, default: patched }
|
||||
})
|
||||
|
||||
function syncCallsUnder(...dirs: string[]): string[] {
|
||||
return state.syncCalls
|
||||
.filter((call) => dirs.some((dir) => call.target.startsWith(dir)))
|
||||
.map((call) => `${call.name}(${call.target})`)
|
||||
}
|
||||
|
||||
describe('agent hook install stays off the main thread', () => {
|
||||
beforeEach(async () => {
|
||||
state.home = await mkdtemp(join(tmpdir(), 'orca-agent-hooks-'))
|
||||
state.blockAsyncFs = false
|
||||
state.syncCalls = []
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
state.blockAsyncFs = false
|
||||
await rm(state.home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('installs the Gemini managed hook with no synchronous fs call under HOME', async () => {
|
||||
const { geminiHookService } = await import('../gemini/hook-service')
|
||||
state.syncCalls = []
|
||||
|
||||
const status = await geminiHookService.installAsync()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(syncCallsUnder(join(state.home, '.gemini'), join(state.home, '.orca'))).toEqual([])
|
||||
const written = JSON.parse(
|
||||
await readFile(join(state.home, '.gemini', 'settings.json'), 'utf-8')
|
||||
)
|
||||
expect(Object.keys(written.hooks)).toContain('AfterTool')
|
||||
})
|
||||
|
||||
it('reports status with no synchronous fs call under HOME', async () => {
|
||||
const { claudeHookService } = await import('../claude/hook-service')
|
||||
state.syncCalls = []
|
||||
|
||||
const status = await claudeHookService.getStatusAsync()
|
||||
|
||||
expect(status.state).toBe('not_installed')
|
||||
expect(syncCallsUnder(join(state.home, '.claude'))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the event loop alive while the hooks read is stuck', async () => {
|
||||
const { geminiHookService } = await import('../gemini/hook-service')
|
||||
state.blockAsyncFs = true
|
||||
|
||||
const install = geminiHookService.installAsync()
|
||||
let settled = false
|
||||
void install.then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
const timerFired = await new Promise<boolean>((resolve) => {
|
||||
setTimeout(() => resolve(true), 5)
|
||||
})
|
||||
|
||||
expect(timerFired).toBe(true)
|
||||
expect(settled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
type AsyncHookService = {
|
||||
installAsync: () => Promise<{ state: string }>
|
||||
removeAsync: () => Promise<{ state: string }>
|
||||
getStatusAsync: () => Promise<{ state: string }>
|
||||
}
|
||||
|
||||
// Why: every managed agent funnels through the same reader/writer pair, so one
|
||||
// service keeping a stray *Sync probe reintroduces the whole freeze class.
|
||||
const ASYNC_HOOK_SERVICES: readonly [string, () => Promise<AsyncHookService>][] = [
|
||||
['claude', async () => (await import('../claude/hook-service')).claudeHookService],
|
||||
['openclaude', async () => (await import('../openclaude/hook-service')).openClaudeHookService],
|
||||
['gemini', async () => (await import('../gemini/hook-service')).geminiHookService],
|
||||
['antigravity', async () => (await import('../antigravity/hook-service')).antigravityHookService],
|
||||
['cursor', async () => (await import('../cursor/hook-service')).cursorHookService],
|
||||
['droid', async () => (await import('../droid/hook-service')).droidHookService],
|
||||
[
|
||||
'command-code',
|
||||
async () => (await import('../command-code/hook-service')).commandCodeHookService
|
||||
],
|
||||
['copilot', async () => (await import('../copilot/hook-service')).copilotHookService],
|
||||
['grok', async () => (await import('../grok/hook-service')).grokHookService]
|
||||
]
|
||||
|
||||
describe.each(ASYNC_HOOK_SERVICES)('%s managed hooks', (_agent, load) => {
|
||||
beforeEach(async () => {
|
||||
state.home = await mkdtemp(join(tmpdir(), 'orca-agent-hooks-'))
|
||||
state.blockAsyncFs = false
|
||||
state.syncCalls = []
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
state.blockAsyncFs = false
|
||||
await rm(state.home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('runs status, install and remove with no synchronous fs call under HOME', async () => {
|
||||
const service = await load()
|
||||
state.syncCalls = []
|
||||
|
||||
expect((await service.getStatusAsync()).state).toBe('not_installed')
|
||||
expect((await service.installAsync()).state).toBe('installed')
|
||||
expect((await service.removeAsync()).state).toBe('not_installed')
|
||||
|
||||
expect(syncCallsUnder(state.home)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// Why: the *Async twins are the only ones production calls, but the merge/sweep
|
||||
// rules were only ever asserted against the sync twins. Re-assert the semantics
|
||||
// (user entries preserved, stale managed entries swept, retired buckets dropped,
|
||||
// repeat installs idempotent) on the path the app actually runs.
|
||||
const CURSOR_EVENTS = [
|
||||
'beforeSubmitPrompt',
|
||||
'stop',
|
||||
'preToolUse',
|
||||
'postToolUse',
|
||||
'postToolUseFailure',
|
||||
'beforeShellExecution',
|
||||
'beforeMCPExecution',
|
||||
'afterAgentResponse'
|
||||
]
|
||||
const GEMINI_EVENTS = ['BeforeAgent', 'AfterAgent', 'AfterTool', 'BeforeTool']
|
||||
const CLAUDE_MATCHER_EVENTS = [
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'PermissionRequest'
|
||||
]
|
||||
const CLAUDE_EVENT_NAMES = [
|
||||
'UserPromptSubmit',
|
||||
'Stop',
|
||||
'StopFailure',
|
||||
'SubagentStart',
|
||||
'SubagentStop',
|
||||
'TeammateIdle',
|
||||
...CLAUDE_MATCHER_EVENTS
|
||||
]
|
||||
|
||||
const STALE_MANAGED = (scriptStem: string): string => `/old/path/.orca/agent-hooks/${scriptStem}.sh`
|
||||
|
||||
async function seedConfig(dirName: string, config: unknown): Promise<string> {
|
||||
const configPath = join(state.home, dirName, 'settings.json')
|
||||
await mkdir(join(state.home, dirName), { recursive: true })
|
||||
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8')
|
||||
return configPath
|
||||
}
|
||||
|
||||
async function readConfig(configPath: string): Promise<HooksConfig> {
|
||||
return JSON.parse(await readFile(configPath, 'utf-8')) as HooksConfig
|
||||
}
|
||||
|
||||
// Why: nested (`hooks[].command`) and top-level (`definition.command`) shapes both
|
||||
// ship; the oracle has to see either, exactly as the installers' matcher does.
|
||||
function commandsOf(definitions: HookDefinition[] | undefined): string[] {
|
||||
return (definitions ?? []).flatMap((definition) => [
|
||||
...(typeof definition.command === 'string' ? [definition.command] : []),
|
||||
...(definition.hooks ?? []).map((hook) => hook.command)
|
||||
])
|
||||
}
|
||||
|
||||
function managedCommandsOf(definitions: HookDefinition[] | undefined, stem: string): string[] {
|
||||
const isManaged = createManagedCommandMatcher(`${stem}.sh`)
|
||||
return commandsOf(definitions).filter((command) => isManaged(command))
|
||||
}
|
||||
|
||||
describe('async agent hook twins keep the documented merge and sweep rules', () => {
|
||||
beforeEach(async () => {
|
||||
state.home = await mkdtemp(join(tmpdir(), 'orca-agent-hooks-'))
|
||||
state.blockAsyncFs = false
|
||||
state.syncCalls = []
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
state.blockAsyncFs = false
|
||||
await rm(state.home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('cursor installAsync keeps user entries, converges managed ones and drops retired buckets', async () => {
|
||||
const configPath = join(state.home, '.cursor', 'hooks.json')
|
||||
await mkdir(join(state.home, '.cursor'), { recursive: true })
|
||||
await writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify({
|
||||
hooks: {
|
||||
beforeSubmitPrompt: [
|
||||
{ command: '/usr/local/bin/user-hook' },
|
||||
{ command: STALE_MANAGED('cursor-hook') },
|
||||
{ hooks: [{ type: 'command', command: STALE_MANAGED('cursor-hook') }] }
|
||||
],
|
||||
retiredEvent: [{ command: STALE_MANAGED('cursor-hook') }],
|
||||
keptEvent: [
|
||||
{ command: STALE_MANAGED('cursor-hook') },
|
||||
{ command: '/usr/local/bin/kept-user-hook' }
|
||||
]
|
||||
}
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
const { cursorHookService } = await import('../cursor/hook-service')
|
||||
state.syncCalls = []
|
||||
|
||||
expect((await cursorHookService.installAsync()).state).toBe('installed')
|
||||
expect((await cursorHookService.installAsync()).state).toBe('installed')
|
||||
|
||||
const config = await readConfig(configPath)
|
||||
const hooks = config.hooks!
|
||||
// Why: cursor-agent rejects a hooks.json without the top-level version.
|
||||
expect(config.version).toBe(1)
|
||||
expect(Object.keys(hooks).sort()).toEqual([...CURSOR_EVENTS, 'keptEvent'].sort())
|
||||
for (const eventName of CURSOR_EVENTS) {
|
||||
expect(managedCommandsOf(hooks[eventName], 'cursor-hook'), eventName).toHaveLength(1)
|
||||
// Cursor's schema puts `command` on the definition, not under `hooks`.
|
||||
expect(hooks[eventName]!.at(-1)!.hooks, eventName).toBeUndefined()
|
||||
expect(typeof hooks[eventName]!.at(-1)!.command, eventName).toBe('string')
|
||||
}
|
||||
expect(commandsOf(hooks.beforeSubmitPrompt)).toContain('/usr/local/bin/user-hook')
|
||||
expect(commandsOf(hooks.keptEvent)).toEqual(['/usr/local/bin/kept-user-hook'])
|
||||
|
||||
expect((await cursorHookService.removeAsync()).state).toBe('not_installed')
|
||||
const afterRemove = await readConfig(configPath)
|
||||
expect(commandsOf(afterRemove.hooks!.beforeSubmitPrompt)).toEqual(['/usr/local/bin/user-hook'])
|
||||
expect(managedCommandsOf(afterRemove.hooks!.beforeSubmitPrompt, 'cursor-hook')).toEqual([])
|
||||
expect(syncCallsUnder(state.home)).toEqual([])
|
||||
})
|
||||
|
||||
it('gemini installAsync appends one managed entry per live event and sweeps retired ones', async () => {
|
||||
const configPath = await seedConfig('.gemini', {
|
||||
theme: 'dark',
|
||||
hooks: {
|
||||
AfterTool: [{ hooks: [{ type: 'command', command: '/usr/local/bin/user-gemini-hook' }] }],
|
||||
PreToolUse: [{ hooks: [{ type: 'command', command: STALE_MANAGED('gemini-hook') }] }]
|
||||
}
|
||||
})
|
||||
const { geminiHookService } = await import('../gemini/hook-service')
|
||||
state.syncCalls = []
|
||||
|
||||
expect((await geminiHookService.installAsync()).state).toBe('installed')
|
||||
expect((await geminiHookService.installAsync()).state).toBe('installed')
|
||||
|
||||
const config = await readConfig(configPath)
|
||||
const hooks = config.hooks!
|
||||
expect(config.theme).toBe('dark')
|
||||
expect(Object.keys(hooks).sort()).toEqual([...GEMINI_EVENTS].sort())
|
||||
for (const eventName of GEMINI_EVENTS) {
|
||||
expect(managedCommandsOf(hooks[eventName], 'gemini-hook'), eventName).toHaveLength(1)
|
||||
// Why: Gemini's hook timeout is milliseconds, unlike Claude/Codex.
|
||||
expect(hooks[eventName]!.at(-1)!.hooks![0].timeout, eventName).toBe(
|
||||
MANAGED_HOOK_TIMEOUT_MILLISECONDS
|
||||
)
|
||||
}
|
||||
// User definition stays first; the managed one is appended.
|
||||
expect(commandsOf(hooks.AfterTool)[0]).toBe('/usr/local/bin/user-gemini-hook')
|
||||
|
||||
expect((await geminiHookService.removeAsync()).state).toBe('not_installed')
|
||||
const afterRemove = await readConfig(configPath)
|
||||
expect(commandsOf(afterRemove.hooks!.AfterTool)).toEqual(['/usr/local/bin/user-gemini-hook'])
|
||||
expect(afterRemove.hooks!.BeforeAgent).toBeUndefined()
|
||||
expect(syncCallsUnder(state.home)).toEqual([])
|
||||
})
|
||||
|
||||
it('claude installAsync preserves matchers, user hooks and a user-owned statusLine', async () => {
|
||||
const configPath = await seedConfig('.claude', {
|
||||
hooks: {
|
||||
Stop: [
|
||||
{ hooks: [{ type: 'command', command: '/usr/local/bin/user-stop-hook' }] },
|
||||
{ hooks: [{ type: 'command', command: STALE_MANAGED('claude-hook') }] }
|
||||
]
|
||||
},
|
||||
statusLine: { type: 'command', command: '/usr/local/bin/user-statusline' }
|
||||
})
|
||||
const { claudeHookService } = await import('../claude/hook-service')
|
||||
state.syncCalls = []
|
||||
|
||||
expect((await claudeHookService.installAsync()).state).toBe('installed')
|
||||
expect((await claudeHookService.installAsync()).state).toBe('installed')
|
||||
|
||||
const config = await readConfig(configPath)
|
||||
const hooks = config.hooks!
|
||||
expect(Object.keys(hooks).sort()).toEqual([...CLAUDE_EVENT_NAMES].sort())
|
||||
for (const eventName of CLAUDE_EVENT_NAMES) {
|
||||
expect(managedCommandsOf(hooks[eventName], 'claude-hook'), eventName).toHaveLength(1)
|
||||
expect(hooks[eventName]!.at(-1)!.matcher, eventName).toBe(
|
||||
CLAUDE_MATCHER_EVENTS.includes(eventName) ? '*' : undefined
|
||||
)
|
||||
}
|
||||
expect(commandsOf(hooks.Stop)[0]).toBe('/usr/local/bin/user-stop-hook')
|
||||
// Why: the statusline slot is single-valued — a user-owned command is never replaced.
|
||||
expect((config.statusLine as { command: string }).command).toBe(
|
||||
'/usr/local/bin/user-statusline'
|
||||
)
|
||||
|
||||
expect((await claudeHookService.removeAsync()).state).toBe('not_installed')
|
||||
const afterRemove = await readConfig(configPath)
|
||||
expect(commandsOf(afterRemove.hooks!.Stop)).toEqual(['/usr/local/bin/user-stop-hook'])
|
||||
expect((afterRemove.statusLine as { command: string }).command).toBe(
|
||||
'/usr/local/bin/user-statusline'
|
||||
)
|
||||
expect(syncCallsUnder(state.home)).toEqual([])
|
||||
})
|
||||
|
||||
it('claude installAsync claims an empty statusLine slot and removeAsync gives it back', async () => {
|
||||
const configPath = await seedConfig('.claude', { hooks: {} })
|
||||
const { claudeHookService } = await import('../claude/hook-service')
|
||||
state.syncCalls = []
|
||||
|
||||
expect((await claudeHookService.installAsync()).state).toBe('installed')
|
||||
|
||||
const config = await readConfig(configPath)
|
||||
const statusLineCommand = (config.statusLine as { command: string }).command
|
||||
expect(createManagedCommandMatcher('claude-statusline.sh')(statusLineCommand)).toBe(true)
|
||||
|
||||
expect((await claudeHookService.removeAsync()).state).toBe('not_installed')
|
||||
expect((await readConfig(configPath)).statusLine).toBeUndefined()
|
||||
expect(syncCallsUnder(state.home)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import type * as NodeFsPromisesModule from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as NodeOsModule from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { writeHooksJsonAsync, writeManagedScriptAsync } from './hooks-json-async-write'
|
||||
|
||||
// Why: the single main thread used to serialize every tmp->rename for free.
|
||||
// Async writes must keep that ordering or two overlapping installs can publish
|
||||
// their renames out of order and leave the older config on disk.
|
||||
const state = vi.hoisted(() => ({ home: '' }))
|
||||
|
||||
// Why: without a stall the writers finish in call order anyway, so the test
|
||||
// would pass with no serialization at all. Delaying only the first rename makes
|
||||
// an unserialized second writer overtake it.
|
||||
const renameGate = vi.hoisted(() => ({ delayFirstRenameMs: 0, renames: 0 }))
|
||||
|
||||
// Why: chmod 000 is not a portable way to make a read fail (root ignores it,
|
||||
// Windows has no such mode), so inject the EACCES the guard has to survive.
|
||||
const readGate = vi.hoisted(() => ({ failPath: '' }))
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeOsModule>()
|
||||
const patched = { ...actual, homedir: () => state.home }
|
||||
return { ...patched, default: patched }
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFsPromisesModule>()
|
||||
const rename = async (from: string, to: string): Promise<void> => {
|
||||
renameGate.renames += 1
|
||||
if (renameGate.renames === 1 && renameGate.delayFirstRenameMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, renameGate.delayFirstRenameMs))
|
||||
}
|
||||
await actual.rename(from, to)
|
||||
}
|
||||
const readFile = (path: unknown, ...rest: unknown[]): unknown => {
|
||||
if (readGate.failPath !== '' && path === readGate.failPath) {
|
||||
return Promise.reject(Object.assign(new Error(`EACCES: ${String(path)}`), { code: 'EACCES' }))
|
||||
}
|
||||
return (actual.readFile as (...args: unknown[]) => unknown)(path, ...rest)
|
||||
}
|
||||
const patched = { ...actual, readFile, rename }
|
||||
return { ...patched, default: patched }
|
||||
})
|
||||
|
||||
describe('async hooks.json writes stay serialized per path', () => {
|
||||
beforeEach(async () => {
|
||||
state.home = await mkdtemp(join(tmpdir(), 'orca-hooks-write-'))
|
||||
renameGate.delayFirstRenameMs = 0
|
||||
renameGate.renames = 0
|
||||
readGate.failPath = ''
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
readGate.failPath = ''
|
||||
await rm(state.home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('applies overlapping writes to the same path in call order', async () => {
|
||||
const configPath = join(state.home, 'settings.json')
|
||||
const writes = Array.from({ length: 8 }, (_, index) =>
|
||||
writeHooksJsonAsync(configPath, { generation: index })
|
||||
)
|
||||
await Promise.all(writes)
|
||||
|
||||
const written = JSON.parse(await readFile(configPath, 'utf-8'))
|
||||
expect(written.generation).toBe(7)
|
||||
expect(await readdir(state.home)).toEqual(
|
||||
expect.arrayContaining(['settings.json', 'settings.json.bak'])
|
||||
)
|
||||
// Why: a torn write leaves a `.tmp` behind; the finally-unlink must always run.
|
||||
expect((await readdir(state.home)).filter((name) => name.endsWith('.tmp'))).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves the previous config recoverable in the rolling backup', async () => {
|
||||
const configPath = join(state.home, 'settings.json')
|
||||
await writeHooksJsonAsync(configPath, { hooks: { Stop: [] } })
|
||||
await writeHooksJsonAsync(configPath, { hooks: { Stop: [{ command: 'next' }] } })
|
||||
|
||||
const backup = JSON.parse(await readFile(`${configPath}.bak`, 'utf-8'))
|
||||
expect(backup.hooks.Stop).toEqual([])
|
||||
})
|
||||
|
||||
it('skips the write and the backup rotation when content is unchanged', async () => {
|
||||
const configPath = join(state.home, 'settings.json')
|
||||
await writeHooksJsonAsync(configPath, { hooks: {} })
|
||||
await writeHooksJsonAsync(configPath, { hooks: {} })
|
||||
|
||||
await expect(readFile(`${configPath}.bak`, 'utf-8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('still backs up and keeps the mode of a config it could not read', async () => {
|
||||
const configPath = join(state.home, 'settings.json')
|
||||
await writeHooksJsonAsync(configPath, { hooks: { Stop: [] } })
|
||||
const { chmod, stat } = await import('node:fs/promises')
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(configPath, 0o600)
|
||||
}
|
||||
|
||||
readGate.failPath = configPath
|
||||
await writeHooksJsonAsync(
|
||||
configPath,
|
||||
{ hooks: { Stop: [{ command: 'next' }] } },
|
||||
{
|
||||
preserveMode: true
|
||||
}
|
||||
)
|
||||
readGate.failPath = ''
|
||||
|
||||
// A read that fails for EACCES/EIO is not a missing file: the sync twin
|
||||
// gates both of these on existsSync, which still says the file is there.
|
||||
expect(JSON.parse(await readFile(`${configPath}.bak`, 'utf-8')).hooks.Stop).toEqual([])
|
||||
if (process.platform !== 'win32') {
|
||||
expect((await stat(configPath)).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
})
|
||||
|
||||
it('rewrites a managed script whose content drifted and keeps it executable', async () => {
|
||||
const scriptPath = join(state.home, '.orca', 'agent-hooks', 'probe-hook.sh')
|
||||
await writeManagedScriptAsync(scriptPath, '#!/bin/sh\necho one\n')
|
||||
await writeManagedScriptAsync(scriptPath, '#!/bin/sh\necho two\n')
|
||||
|
||||
expect(await readFile(scriptPath, 'utf-8')).toBe('#!/bin/sh\necho two\n')
|
||||
expect(
|
||||
(await readdir(join(state.home, '.orca', 'agent-hooks'))).filter((name) =>
|
||||
name.endsWith('.tmp')
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('follows a symlinked config to its real path instead of replacing the link', async () => {
|
||||
const { symlink } = await import('node:fs/promises')
|
||||
const realPath = join(state.home, 'dotfiles-settings.json')
|
||||
const linkPath = join(state.home, 'settings.json')
|
||||
await writeFile(realPath, '{}\n', 'utf-8')
|
||||
await symlink(realPath, linkPath)
|
||||
|
||||
await writeHooksJsonAsync(linkPath, { hooks: { Stop: [] } })
|
||||
|
||||
const { lstat } = await import('node:fs/promises')
|
||||
expect((await lstat(linkPath)).isSymbolicLink()).toBe(true)
|
||||
expect(JSON.parse(await readFile(realPath, 'utf-8')).hooks).toEqual({ Stop: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('a stalled publish still holds the per-path critical section', () => {
|
||||
beforeEach(async () => {
|
||||
state.home = await mkdtemp(join(tmpdir(), 'orca-hooks-install-'))
|
||||
renameGate.delayFirstRenameMs = 0
|
||||
renameGate.renames = 0
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
renameGate.delayFirstRenameMs = 0
|
||||
await rm(state.home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('does not let a later write overtake a rename parked on a slow mount', async () => {
|
||||
const configPath = join(state.home, 'settings.json')
|
||||
renameGate.delayFirstRenameMs = 60
|
||||
|
||||
await Promise.all([
|
||||
writeHooksJsonAsync(configPath, { generation: 0 }),
|
||||
writeHooksJsonAsync(configPath, { generation: 1 })
|
||||
])
|
||||
|
||||
// Unserialized, the second writer reads before the first rename lands, then
|
||||
// publishes first — leaving generation 0 on disk with no backup at all.
|
||||
expect(JSON.parse(await readFile(configPath, 'utf-8')).generation).toBe(1)
|
||||
expect(JSON.parse(await readFile(`${configPath}.bak`, 'utf-8')).generation).toBe(0)
|
||||
})
|
||||
|
||||
// Why: a second per-path chain in this module could not see a write the
|
||||
// codex-accounts chain already holds, so both writers must use the same map.
|
||||
it('shares the critical section with the atomic-write chain in codex-accounts', async () => {
|
||||
const configPath = join(state.home, 'settings.json')
|
||||
const { serializeAtomicFileWrite, writeFileAtomicallyAsync } =
|
||||
await import('../codex-accounts/fs-utils')
|
||||
renameGate.delayFirstRenameMs = 60
|
||||
|
||||
await Promise.all([
|
||||
serializeAtomicFileWrite(configPath, () =>
|
||||
writeFileAtomicallyAsync(configPath, '{\n "generation": 0\n}\n')
|
||||
),
|
||||
writeHooksJsonAsync(configPath, { generation: 1 })
|
||||
])
|
||||
|
||||
expect(JSON.parse(await readFile(configPath, 'utf-8')).generation).toBe(1)
|
||||
expect(JSON.parse(await readFile(`${configPath}.bak`, 'utf-8')).generation).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { chmod, lstat, mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
copyFileWithWindowsRetryAsync,
|
||||
realpathNativeAsync,
|
||||
renameFileWithWindowsRetryAsync,
|
||||
serializeAtomicFileWrite
|
||||
} from '../codex-accounts/fs-utils'
|
||||
import { grantDirAcl, isPermissionError } from '../win32-utils'
|
||||
import type { HooksConfig } from './installer-utils'
|
||||
|
||||
// Why: main-thread twins of writeHooksJson / writeManagedScript. The sync
|
||||
// versions stay for the CLI process and Codex's launch-prep path; these exist
|
||||
// so a stalled HOME mount parks a threadpool slot instead of the UI thread.
|
||||
//
|
||||
// Why serializeAtomicFileWrite and not a local chain: a second per-path map in
|
||||
// this module could not see writes the codex-accounts one is already holding,
|
||||
// so the ordering invariant would only hold while no file is written by both.
|
||||
|
||||
async function readFileOrNull(path: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(path, 'utf-8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
type ExistingConfig = { exists: boolean; content: string | null }
|
||||
|
||||
// Why: the sync twin gates the backup and the mode probe on existsSync, which
|
||||
// is false only when stat fails. A file that stats but fails to read (EACCES,
|
||||
// EIO) still exists and must not lose its .bak or its mode.
|
||||
async function readExistingConfig(path: string): Promise<ExistingConfig> {
|
||||
try {
|
||||
return { exists: true, content: await readFile(path, 'utf-8') }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { exists: false, content: null }
|
||||
}
|
||||
return { exists: await statSucceeds(path), content: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function statSucceeds(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkIfPresent(path: string): Promise<void> {
|
||||
try {
|
||||
await unlink(path)
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
// Why: async twin of resolveHooksJsonWritePath — renaming over the link path
|
||||
// would disconnect a dotfiles-managed config; a dangling link must fail closed.
|
||||
async function resolveWritePath(configPath: string): Promise<string> {
|
||||
let isSymlink = false
|
||||
try {
|
||||
isSymlink = (await lstat(configPath)).isSymbolicLink()
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
return configPath
|
||||
}
|
||||
return isSymlink ? await realpathNativeAsync(configPath) : configPath
|
||||
}
|
||||
|
||||
// Why: async twin of writeRollingFileBackup — a fresh inode cannot mutate
|
||||
// another file through a hard link, and a failed rename keeps the prior backup.
|
||||
async function writeRollingBackup(sourcePath: string, backupPath: string): Promise<void> {
|
||||
try {
|
||||
if ((await lstat(backupPath)).isSymbolicLink()) {
|
||||
throw new Error(`Refusing to overwrite symlinked backup: ${backupPath}`)
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const tempPath = `${backupPath}.${process.pid}.${randomUUID()}.tmp`
|
||||
try {
|
||||
await copyFileWithWindowsRetryAsync(sourcePath, tempPath)
|
||||
await renameFileWithWindowsRetryAsync(tempPath, backupPath)
|
||||
} finally {
|
||||
await unlinkIfPresent(tempPath)
|
||||
}
|
||||
}
|
||||
|
||||
async function writeWithAclRetry(path: string, content: string, mode?: number): Promise<void> {
|
||||
try {
|
||||
await writeFile(path, content, { encoding: 'utf-8', mode })
|
||||
} catch (error) {
|
||||
if (isPermissionError(error) && process.platform === 'win32') {
|
||||
try {
|
||||
grantDirAcl(dirname(path))
|
||||
await writeFile(path, content, { encoding: 'utf-8', mode })
|
||||
return
|
||||
} catch {
|
||||
// icacls failure is not actionable; re-throw the original EPERM
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function writeManagedScriptAsync(scriptPath: string, content: string): Promise<void> {
|
||||
return serializeAtomicFileWrite(scriptPath, () => writeManagedScriptNow(scriptPath, content))
|
||||
}
|
||||
|
||||
async function writeManagedScriptNow(scriptPath: string, content: string): Promise<void> {
|
||||
const dir = dirname(scriptPath)
|
||||
await mkdir(dir, { recursive: true })
|
||||
|
||||
// Why: one read answers both "does it exist" and "is it already current".
|
||||
if ((await readFileOrNull(scriptPath)) === content) {
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(scriptPath, 0o755)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
|
||||
try {
|
||||
await writeWithAclRetry(tmpPath, content)
|
||||
// Why: chmod before rename so the canonical path is never visible non-executable, else the POSIX guard skips the hook.
|
||||
if (process.platform !== 'win32') {
|
||||
await chmod(tmpPath, 0o755)
|
||||
}
|
||||
await renameFileWithWindowsRetryAsync(tmpPath, scriptPath)
|
||||
} finally {
|
||||
await unlinkIfPresent(tmpPath)
|
||||
}
|
||||
}
|
||||
|
||||
export function writeHooksJsonAsync(
|
||||
configPath: string,
|
||||
config: HooksConfig,
|
||||
options?: { preserveMode?: boolean }
|
||||
): Promise<void> {
|
||||
// Why: key on the caller's path, not the resolved one — the symlink probe is
|
||||
// itself part of the critical section.
|
||||
return serializeAtomicFileWrite(configPath, () => writeHooksJsonNow(configPath, config, options))
|
||||
}
|
||||
|
||||
async function writeHooksJsonNow(
|
||||
configPath: string,
|
||||
config: HooksConfig,
|
||||
options?: { preserveMode?: boolean }
|
||||
): Promise<void> {
|
||||
const writePath = await resolveWritePath(configPath)
|
||||
const dir = dirname(writePath)
|
||||
await mkdir(dir, { recursive: true })
|
||||
|
||||
const serialized = `${JSON.stringify(config, null, 2)}\n`
|
||||
const existing = await readExistingConfig(writePath)
|
||||
// Why: skip the write (and the .bak rotation) when the on-disk content is
|
||||
// already identical, so a repeated install can't roll the last recoverable
|
||||
// copy forward and destroy it.
|
||||
if (existing.content === serialized) {
|
||||
return
|
||||
}
|
||||
const existingMode =
|
||||
options?.preserveMode === true && existing.exists
|
||||
? await statModeOrUndefined(writePath)
|
||||
: undefined
|
||||
|
||||
// Why: temp+rename leaves the original untouched on a crash/disk-full mid-write.
|
||||
// Why randomUUID: avoids tmp-path collisions when two install() calls fire in the same millisecond.
|
||||
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
|
||||
try {
|
||||
await writeFile(tmpPath, serialized, { encoding: 'utf-8', mode: existingMode })
|
||||
// Why: single rolling backup so a merge-logic bug producing bad JSON is always recoverable.
|
||||
if (existing.exists) {
|
||||
await writeRollingBackup(writePath, `${writePath}.bak`)
|
||||
}
|
||||
await renameFileWithWindowsRetryAsync(tmpPath, writePath)
|
||||
} finally {
|
||||
await unlinkIfPresent(tmpPath)
|
||||
}
|
||||
}
|
||||
|
||||
async function statModeOrUndefined(path: string): Promise<number | undefined> {
|
||||
try {
|
||||
return (await stat(path)).mode
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { HooksConfig } from './installer-utils'
|
||||
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
@@ -11,19 +12,16 @@ export type HooksJsonSnapshot = {
|
||||
config: HooksConfig | null
|
||||
}
|
||||
|
||||
// Why: generation guards abort a mutation when the file no longer matches the
|
||||
// bytes it was derived from; the raw snapshot and the parse must come from one
|
||||
// read or a concurrent save can slip between them unnoticed.
|
||||
export function readHooksJsonWithRaw(configPath: string): HooksJsonSnapshot {
|
||||
if (!existsSync(configPath)) {
|
||||
return { raw: null, config: {} }
|
||||
}
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(configPath, 'utf-8')
|
||||
} catch {
|
||||
return { raw: null, config: null }
|
||||
}
|
||||
// Why: fresh objects per call — callers mutate the returned config in place.
|
||||
function missingConfig(): HooksJsonSnapshot {
|
||||
return { raw: null, config: {} }
|
||||
}
|
||||
|
||||
function unreadableConfig(): HooksJsonSnapshot {
|
||||
return { raw: null, config: null }
|
||||
}
|
||||
|
||||
function parseHooksJsonSnapshot(raw: string): HooksJsonSnapshot {
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return { raw, config: isPlainObject(parsed) ? parsed : null }
|
||||
@@ -32,6 +30,39 @@ export function readHooksJsonWithRaw(configPath: string): HooksJsonSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: generation guards abort a mutation when the file no longer matches the
|
||||
// bytes it was derived from; the raw snapshot and the parse must come from one
|
||||
// read or a concurrent save can slip between them unnoticed.
|
||||
export function readHooksJsonWithRaw(configPath: string): HooksJsonSnapshot {
|
||||
if (!existsSync(configPath)) {
|
||||
return missingConfig()
|
||||
}
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(configPath, 'utf-8')
|
||||
} catch {
|
||||
return unreadableConfig()
|
||||
}
|
||||
return parseHooksJsonSnapshot(raw)
|
||||
}
|
||||
|
||||
export function readHooksJson(configPath: string): HooksConfig | null {
|
||||
return readHooksJsonWithRaw(configPath).config
|
||||
}
|
||||
|
||||
// Why: main-thread twin of readHooksJsonWithRaw. The existsSync probe is gone —
|
||||
// ENOENT from the single read carries the same answer, and one syscall instead
|
||||
// of two is what matters when HOME sits on a stalled network mount.
|
||||
async function readHooksJsonWithRawAsync(configPath: string): Promise<HooksJsonSnapshot> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(configPath, 'utf-8')
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === 'ENOENT' ? missingConfig() : unreadableConfig()
|
||||
}
|
||||
return parseHooksJsonSnapshot(raw)
|
||||
}
|
||||
|
||||
export async function readHooksJsonAsync(configPath: string): Promise<HooksConfig | null> {
|
||||
return (await readHooksJsonWithRawAsync(configPath)).config
|
||||
}
|
||||
|
||||
@@ -60,10 +60,13 @@ export function buildManagedCommandDefinition(command: string): HookDefinition {
|
||||
export {
|
||||
isPlainObject,
|
||||
readHooksJson,
|
||||
readHooksJsonAsync,
|
||||
readHooksJsonWithRaw,
|
||||
type HooksJsonSnapshot
|
||||
} from './hooks-json-read'
|
||||
|
||||
export { writeHooksJsonAsync, writeManagedScriptAsync } from './hooks-json-async-write'
|
||||
|
||||
// Why: match by script file name, not exact command, so a fresh install sweeps stale entries from old/parallel installs.
|
||||
export function createManagedCommandMatcher(
|
||||
scriptFileName: string
|
||||
|
||||
@@ -69,13 +69,13 @@ function selectedInstallers(options: InstallOptions): readonly ManagedAgentHookI
|
||||
return MANAGED_AGENT_HOOK_INSTALLERS.filter(([agent]) => allowed.has(agent))
|
||||
}
|
||||
|
||||
function runInstaller(
|
||||
async function runInstaller(
|
||||
entry: ManagedAgentHookInstaller,
|
||||
onInstallError: InstallOptions['onInstallError']
|
||||
): AgentHookInstallStatus {
|
||||
): Promise<AgentHookInstallStatus> {
|
||||
const [agent, install] = entry
|
||||
try {
|
||||
return install()
|
||||
return await install()
|
||||
} catch (error) {
|
||||
console.error(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
|
||||
try {
|
||||
@@ -87,9 +87,31 @@ function runInstaller(
|
||||
}
|
||||
}
|
||||
|
||||
export async function installManagedAgentHooks(
|
||||
// Why: each agent's install/remove used to be one uninterruptible sync block.
|
||||
// Async reopens a read-modify-write window on the same hooks files, so keep
|
||||
// every managed-hook mutation single-file — a startup install and a Settings
|
||||
// toggle must not interleave their reads and writes.
|
||||
let pendingMutation: Promise<unknown> = Promise.resolve()
|
||||
|
||||
function serializeMutation<T>(run: () => Promise<T>): Promise<T> {
|
||||
const result = pendingMutation.then(run, run)
|
||||
pendingMutation = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
export function installManagedAgentHooks(
|
||||
settings: ManagedHookSettings = null,
|
||||
options: InstallOptions = {}
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
return serializeMutation(() => runInstallers(settings, options))
|
||||
}
|
||||
|
||||
async function runInstallers(
|
||||
settings: ManagedHookSettings,
|
||||
options: InstallOptions
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
const installers = selectedInstallers(options)
|
||||
const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents))
|
||||
@@ -140,46 +162,69 @@ export async function installManagedAgentHooks(
|
||||
)
|
||||
continue
|
||||
}
|
||||
results.push(runInstaller(entry, options.onInstallError))
|
||||
results.push(await runInstaller(entry, options.onInstallError))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export function removeManagedAgentHooks(options: RemoveOptions = {}): AgentHookInstallStatus[] {
|
||||
export function removeManagedAgentHooks(
|
||||
options: RemoveOptions = {}
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
return serializeMutation(() => runRemovers(options))
|
||||
}
|
||||
|
||||
// Why: serial, not Promise.all — libuv's threadpool is 4 threads, so fanning 14
|
||||
// agent configs out at once just starves every other async fs caller.
|
||||
async function runRemovers(options: RemoveOptions): Promise<AgentHookInstallStatus[]> {
|
||||
const allowed = options.agents ? new Set(options.agents) : null
|
||||
return MANAGED_AGENT_HOOK_REMOVERS.filter(
|
||||
([agent]) => allowed === null || allowed.has(agent)
|
||||
).map(([agent, remove]) => {
|
||||
try {
|
||||
return remove()
|
||||
} catch (error) {
|
||||
return errorStatus(agent, error)
|
||||
const results: AgentHookInstallStatus[] = []
|
||||
for (const [agent, remove] of MANAGED_AGENT_HOOK_REMOVERS) {
|
||||
if (allowed !== null && !allowed.has(agent)) {
|
||||
continue
|
||||
}
|
||||
})
|
||||
try {
|
||||
results.push(await remove())
|
||||
} catch (error) {
|
||||
results.push(errorStatus(agent, error))
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export function getManagedAgentHookStatuses(): AgentHookInstallStatus[] {
|
||||
return MANAGED_AGENT_HOOK_STATUS_READERS.map(([agent, getStatus]) => {
|
||||
export async function getManagedAgentHookStatuses(): Promise<AgentHookInstallStatus[]> {
|
||||
const results: AgentHookInstallStatus[] = []
|
||||
for (const [agent, getStatus] of MANAGED_AGENT_HOOK_STATUS_READERS) {
|
||||
try {
|
||||
return getStatus()
|
||||
results.push(await getStatus())
|
||||
} catch (error) {
|
||||
return errorStatus(agent, error)
|
||||
results.push(errorStatus(agent, error))
|
||||
}
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export async function applyAgentStatusHooksEnabled(
|
||||
// Why: one critical section for the whole apply — the install pass and the
|
||||
// disabled-agent sweep below must not have another toggle land between them.
|
||||
export function applyAgentStatusHooksEnabled(
|
||||
enabled: boolean,
|
||||
settings: ManagedHookSettings = null,
|
||||
options: InstallOptions = {}
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
return serializeMutation(() => applyHooksEnabled(enabled, settings, options))
|
||||
}
|
||||
|
||||
async function applyHooksEnabled(
|
||||
enabled: boolean,
|
||||
settings: ManagedHookSettings,
|
||||
options: InstallOptions
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
if (!enabled) {
|
||||
return removeManagedAgentHooks()
|
||||
return await runRemovers({})
|
||||
}
|
||||
const disabled = normalizeDisabledTuiAgents(settings?.disabledTuiAgents).filter(
|
||||
isManagedAgentHookTarget
|
||||
)
|
||||
const installed = await installManagedAgentHooks(settings, options)
|
||||
const installed = await runInstallers(settings, options)
|
||||
const disabledToRemove = options.shouldContinue
|
||||
? disabled.filter((agent) => !options.shouldContinue?.(agent))
|
||||
: disabled
|
||||
@@ -187,7 +232,7 @@ export async function applyAgentStatusHooksEnabled(
|
||||
return installed
|
||||
}
|
||||
const removed = new Map(
|
||||
removeManagedAgentHooks({ agents: disabledToRemove }).map((status) => [status.agent, status])
|
||||
(await runRemovers({ agents: disabledToRemove })).map((status) => [status.agent, status])
|
||||
)
|
||||
return installed.map((status) => removed.get(status.agent) ?? status)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Why: install/remove per agent used to be one uninterruptible sync block. Now
|
||||
// that both await real fs, an overlapping startup install and Settings toggle
|
||||
// could interleave their read-modify-write of the same hooks.json.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
detect: vi.fn(),
|
||||
order: [] as string[],
|
||||
installClaude: vi.fn(),
|
||||
removeClaude: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./local-agent-cli-presence', () => ({
|
||||
detectLocalManagedAgentCliPresence: mocks.detect
|
||||
}))
|
||||
|
||||
vi.mock('./managed-agent-hook-registry', () => ({
|
||||
MANAGED_AGENT_HOOK_INSTALLERS: [['claude', mocks.installClaude]],
|
||||
MANAGED_AGENT_HOOK_REMOVERS: [['claude', mocks.removeClaude]],
|
||||
MANAGED_AGENT_HOOK_STATUS_READERS: [['claude', vi.fn()]]
|
||||
}))
|
||||
|
||||
import { applyAgentStatusHooksEnabled } from './managed-agent-hook-controls'
|
||||
|
||||
function status(state: 'installed' | 'not_installed') {
|
||||
return {
|
||||
agent: 'claude',
|
||||
state,
|
||||
configPath: '/claude',
|
||||
managedHooksPresent: state === 'installed',
|
||||
detail: null
|
||||
} as const
|
||||
}
|
||||
|
||||
// Why: an install that yields mid-flight is exactly what a real fs write does.
|
||||
function tick(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('managed hook mutations never interleave', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.order = []
|
||||
mocks.detect.mockImplementation(async () => {
|
||||
await tick()
|
||||
return { claude: { state: 'found' } }
|
||||
})
|
||||
mocks.installClaude.mockImplementation(async () => {
|
||||
mocks.order.push('install:start')
|
||||
await tick()
|
||||
await tick()
|
||||
mocks.order.push('install:end')
|
||||
return status('installed')
|
||||
})
|
||||
mocks.removeClaude.mockImplementation(async () => {
|
||||
mocks.order.push('remove:start')
|
||||
await tick()
|
||||
mocks.order.push('remove:end')
|
||||
return status('not_installed')
|
||||
})
|
||||
})
|
||||
|
||||
it('runs a concurrent disable after the in-flight install finishes', async () => {
|
||||
const install = applyAgentStatusHooksEnabled(true, { agentCmdOverrides: {} })
|
||||
const disable = applyAgentStatusHooksEnabled(false)
|
||||
|
||||
await Promise.all([install, disable])
|
||||
|
||||
expect(mocks.order).toEqual(['install:start', 'install:end', 'remove:start', 'remove:end'])
|
||||
})
|
||||
|
||||
it('keeps queued mutations running after an earlier one rejects', async () => {
|
||||
mocks.installClaude.mockRejectedValueOnce(new Error('write failed'))
|
||||
|
||||
const install = applyAgentStatusHooksEnabled(true, { agentCmdOverrides: {} })
|
||||
const disable = applyAgentStatusHooksEnabled(false)
|
||||
|
||||
const [installed, removed] = await Promise.all([install, disable])
|
||||
|
||||
expect(installed[0]?.state).toBe('error')
|
||||
expect(removed[0]?.state).toBe('not_installed')
|
||||
expect(mocks.order).toEqual(['remove:start', 'remove:end'])
|
||||
})
|
||||
})
|
||||
@@ -15,56 +15,63 @@ import { hermesHookService } from '../hermes/hook-service'
|
||||
import { kimiHookService } from '../kimi/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
|
||||
export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => AgentHookInstallStatus]
|
||||
export type ManagedAgentHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus]
|
||||
export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus]
|
||||
// Why: awaited by every caller, but stays sync-tolerant — Codex's install still
|
||||
// runs sync because its trust/TOML layer has no async twin yet.
|
||||
export type ManagedAgentHookAction = readonly [
|
||||
HookInstallAgent,
|
||||
() => AgentHookInstallStatus | Promise<AgentHookInstallStatus>
|
||||
]
|
||||
|
||||
export type ManagedAgentHookInstaller = ManagedAgentHookAction
|
||||
export type ManagedAgentHookRemover = ManagedAgentHookAction
|
||||
export type ManagedAgentHookStatusReader = ManagedAgentHookAction
|
||||
|
||||
export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [
|
||||
['claude', () => claudeHookService.install()],
|
||||
['openclaude', () => openClaudeHookService.install()],
|
||||
['claude', () => claudeHookService.installAsync()],
|
||||
['openclaude', () => openClaudeHookService.installAsync()],
|
||||
['codex', () => codexHookService.install()],
|
||||
['gemini', () => geminiHookService.install()],
|
||||
['antigravity', () => antigravityHookService.install()],
|
||||
['gemini', () => geminiHookService.installAsync()],
|
||||
['antigravity', () => antigravityHookService.installAsync()],
|
||||
['amp', () => ampHookService.install()],
|
||||
['cursor', () => cursorHookService.install()],
|
||||
['droid', () => droidHookService.install()],
|
||||
['command-code', () => commandCodeHookService.install()],
|
||||
['grok', () => grokHookService.install()],
|
||||
['copilot', () => copilotHookService.install()],
|
||||
['cursor', () => cursorHookService.installAsync()],
|
||||
['droid', () => droidHookService.installAsync()],
|
||||
['command-code', () => commandCodeHookService.installAsync()],
|
||||
['grok', () => grokHookService.installAsync()],
|
||||
['copilot', () => copilotHookService.installAsync()],
|
||||
['hermes', () => hermesHookService.install()],
|
||||
['devin', () => devinHookService.install()],
|
||||
['kimi', () => kimiHookService.install()]
|
||||
]
|
||||
|
||||
export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
|
||||
['claude', () => claudeHookService.remove()],
|
||||
['openclaude', () => openClaudeHookService.remove()],
|
||||
['claude', () => claudeHookService.removeAsync()],
|
||||
['openclaude', () => openClaudeHookService.removeAsync()],
|
||||
['codex', () => codexHookService.remove()],
|
||||
['gemini', () => geminiHookService.remove()],
|
||||
['antigravity', () => antigravityHookService.remove()],
|
||||
['gemini', () => geminiHookService.removeAsync()],
|
||||
['antigravity', () => antigravityHookService.removeAsync()],
|
||||
['amp', () => ampHookService.remove()],
|
||||
['cursor', () => cursorHookService.remove()],
|
||||
['droid', () => droidHookService.remove()],
|
||||
['command-code', () => commandCodeHookService.remove()],
|
||||
['grok', () => grokHookService.remove()],
|
||||
['copilot', () => copilotHookService.remove()],
|
||||
['cursor', () => cursorHookService.removeAsync()],
|
||||
['droid', () => droidHookService.removeAsync()],
|
||||
['command-code', () => commandCodeHookService.removeAsync()],
|
||||
['grok', () => grokHookService.removeAsync()],
|
||||
['copilot', () => copilotHookService.removeAsync()],
|
||||
['hermes', () => hermesHookService.remove()],
|
||||
['devin', () => devinHookService.remove()],
|
||||
['kimi', () => kimiHookService.remove()]
|
||||
]
|
||||
|
||||
export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusReader[] = [
|
||||
['claude', () => claudeHookService.getStatus()],
|
||||
['openclaude', () => openClaudeHookService.getStatus()],
|
||||
['claude', () => claudeHookService.getStatusAsync()],
|
||||
['openclaude', () => openClaudeHookService.getStatusAsync()],
|
||||
['codex', () => codexHookService.getStatus()],
|
||||
['gemini', () => geminiHookService.getStatus()],
|
||||
['antigravity', () => antigravityHookService.getStatus()],
|
||||
['gemini', () => geminiHookService.getStatusAsync()],
|
||||
['antigravity', () => antigravityHookService.getStatusAsync()],
|
||||
['amp', () => ampHookService.getStatus()],
|
||||
['cursor', () => cursorHookService.getStatus()],
|
||||
['droid', () => droidHookService.getStatus()],
|
||||
['grok', () => grokHookService.getStatus()],
|
||||
['command-code', () => commandCodeHookService.getStatus()],
|
||||
['copilot', () => copilotHookService.getStatus()],
|
||||
['cursor', () => cursorHookService.getStatusAsync()],
|
||||
['droid', () => droidHookService.getStatusAsync()],
|
||||
['grok', () => grokHookService.getStatusAsync()],
|
||||
['command-code', () => commandCodeHookService.getStatusAsync()],
|
||||
['copilot', () => copilotHookService.getStatusAsync()],
|
||||
['hermes', () => hermesHookService.getStatus()],
|
||||
['devin', () => devinHookService.getStatus()],
|
||||
['kimi', () => kimiHookService.getStatus()]
|
||||
|
||||
@@ -138,18 +138,21 @@ const REMOTE_INSTALLERS = [
|
||||
}
|
||||
] as const
|
||||
|
||||
// Why: mirror MANAGED_AGENT_HOOK_INSTALLERS exactly — the *Async twin wherever
|
||||
// one exists. The sync twins no longer have a production caller, so driving them
|
||||
// here would assert the script contract on scripts the app never writes.
|
||||
const LOCAL_INSTALLERS = [
|
||||
{ agent: 'antigravity', install: () => new AntigravityHookService().install() },
|
||||
{ agent: 'claude', install: () => new ClaudeHookService().install() },
|
||||
{ agent: 'openclaude', install: () => openClaudeHookService.install() },
|
||||
{ agent: 'antigravity', install: () => new AntigravityHookService().installAsync() },
|
||||
{ agent: 'claude', install: () => new ClaudeHookService().installAsync() },
|
||||
{ agent: 'openclaude', install: () => openClaudeHookService.installAsync() },
|
||||
{ agent: 'codex', install: () => new CodexHookService().install() },
|
||||
{ agent: 'command-code', install: () => new CommandCodeHookService().install() },
|
||||
{ agent: 'copilot', install: () => new CopilotHookService().install() },
|
||||
{ agent: 'cursor', install: () => new CursorHookService().install() },
|
||||
{ agent: 'command-code', install: () => new CommandCodeHookService().installAsync() },
|
||||
{ agent: 'copilot', install: () => new CopilotHookService().installAsync() },
|
||||
{ agent: 'cursor', install: () => new CursorHookService().installAsync() },
|
||||
{ agent: 'devin', install: () => new DevinHookService().install() },
|
||||
{ agent: 'droid', install: () => new DroidHookService().install() },
|
||||
{ agent: 'gemini', install: () => new GeminiHookService().install() },
|
||||
{ agent: 'grok', install: () => new GrokHookService().install() },
|
||||
{ agent: 'droid', install: () => new DroidHookService().installAsync() },
|
||||
{ agent: 'gemini', install: () => new GeminiHookService().installAsync() },
|
||||
{ agent: 'grok', install: () => new GrokHookService().installAsync() },
|
||||
{ agent: 'kimi', install: () => new KimiHookService().install() }
|
||||
] as const
|
||||
|
||||
@@ -222,11 +225,13 @@ async function generatePosixScripts(): Promise<Map<string, string>> {
|
||||
return scripts
|
||||
}
|
||||
|
||||
function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
|
||||
// Why: several installs are async now, so the stub has to outlive the awaited
|
||||
// work — restoring at the first await would run the install on the host platform.
|
||||
async function withPlatform<T>(platform: NodeJS.Platform, run: () => T | Promise<T>): Promise<T> {
|
||||
const original = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
||||
try {
|
||||
return run()
|
||||
return await run()
|
||||
} finally {
|
||||
if (original) {
|
||||
Object.defineProperty(process, 'platform', original)
|
||||
@@ -235,7 +240,7 @@ function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
|
||||
}
|
||||
|
||||
describe('Windows managed hook stdin structure', () => {
|
||||
it('routes every batch guard to a shared drain epilogue', () => {
|
||||
it('routes every batch guard to a shared drain epilogue', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-windows-'))
|
||||
homedirMock.mockReturnValue(home)
|
||||
const previousGrokHome = process.env.GROK_HOME
|
||||
@@ -243,9 +248,9 @@ describe('Windows managed hook stdin structure', () => {
|
||||
delete process.env.GROK_HOME
|
||||
delete process.env.KIMI_CODE_HOME
|
||||
try {
|
||||
withPlatform('win32', () => {
|
||||
await withPlatform('win32', async () => {
|
||||
for (const entry of LOCAL_INSTALLERS) {
|
||||
expect(entry.install().state, `${entry.agent} install status`).toBe('installed')
|
||||
expect((await entry.install()).state, `${entry.agent} install status`).toBe('installed')
|
||||
}
|
||||
})
|
||||
const hooksDir = join(home, '.orca', 'agent-hooks')
|
||||
@@ -307,7 +312,7 @@ describe('Windows managed hook stdin structure', () => {
|
||||
try {
|
||||
const gitBash = findGitBash()
|
||||
for (const entry of LOCAL_INSTALLERS) {
|
||||
expect(entry.install().state, `${entry.agent} install status`).toBe('installed')
|
||||
expect((await entry.install()).state, `${entry.agent} install status`).toBe('installed')
|
||||
}
|
||||
const hooksDir = join(home, '.orca', 'agent-hooks')
|
||||
const mainScripts = readdirSync(hooksDir).filter(
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const testState = {
|
||||
fakeHomeDir: '',
|
||||
userDataDir: '',
|
||||
workspaceDir: '',
|
||||
previousUserDataPath: undefined as string | undefined
|
||||
}
|
||||
|
||||
const syncFsCalls: { name: string; paths: string[] }[] = []
|
||||
const hangNativeRealpath = { value: false }
|
||||
// Why: parks the async realpath of specific paths so a synchronous writer can be
|
||||
// injected at a chosen point of an in-flight async trust write.
|
||||
const deferredNativeRealpaths: { paths: Set<string>; resume: (() => void)[] } = {
|
||||
paths: new Set(),
|
||||
resume: []
|
||||
}
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- importOriginal requires inline import()
|
||||
const actual = await importOriginal<typeof import('node:fs')>()
|
||||
type AnyFn = (...args: unknown[]) => unknown
|
||||
type NativeFn = AnyFn & { native?: AnyFn }
|
||||
const record = (name: string, args: unknown[]): void => {
|
||||
syncFsCalls.push({ name, paths: args.filter((arg) => typeof arg === 'string') })
|
||||
}
|
||||
const recorded: Record<string, unknown> = { ...actual }
|
||||
for (const [name, value] of Object.entries(actual)) {
|
||||
if (!name.endsWith('Sync') || typeof value !== 'function') {
|
||||
continue
|
||||
}
|
||||
const original = value as NativeFn
|
||||
const wrapper: NativeFn = (...args: unknown[]): unknown => {
|
||||
record(name, args)
|
||||
return original(...args)
|
||||
}
|
||||
Object.assign(wrapper, original)
|
||||
if (typeof original.native === 'function') {
|
||||
const native = original.native
|
||||
wrapper.native = (...args: unknown[]): unknown => {
|
||||
record(`${name}.native`, args)
|
||||
return native(...args)
|
||||
}
|
||||
}
|
||||
recorded[name] = wrapper
|
||||
}
|
||||
// Why: lets a test model an uninterruptible realpath on a dead network mount.
|
||||
const realpath = ((...args: unknown[]) =>
|
||||
(actual.realpath as unknown as AnyFn)(...args)) as unknown as typeof actual.realpath
|
||||
realpath.native = ((...args: unknown[]) => {
|
||||
if (hangNativeRealpath.value) {
|
||||
return undefined
|
||||
}
|
||||
const target = typeof args[0] === 'string' ? args[0] : ''
|
||||
if (deferredNativeRealpaths.paths.has(target)) {
|
||||
deferredNativeRealpaths.resume.push(() => {
|
||||
;(actual.realpath.native as unknown as AnyFn)(...args)
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
return (actual.realpath.native as unknown as AnyFn)(...args)
|
||||
}) as unknown as typeof actual.realpath.native
|
||||
recorded.realpath = realpath
|
||||
return recorded
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: (name: string) => {
|
||||
if (name === 'userData') {
|
||||
return testState.userDataDir
|
||||
}
|
||||
throw new Error(`unexpected app.getPath(${name})`)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- importOriginal requires inline import()
|
||||
const actual = await importOriginal<typeof import('node:os')>()
|
||||
return { ...actual, homedir: () => testState.fakeHomeDir }
|
||||
})
|
||||
|
||||
const {
|
||||
markCodexProjectTrusted,
|
||||
markCodexProjectTrustedAsync,
|
||||
markCopilotFolderTrusted,
|
||||
markCopilotFolderTrustedAsync,
|
||||
markCursorWorkspaceTrusted,
|
||||
markCursorWorkspaceTrustedAsync
|
||||
} = await import('./agent-trust-presets')
|
||||
const { upsertHookTrustEntries } = await import('./codex/config-toml-trust')
|
||||
|
||||
function syncCallsTouching(...roots: string[]): string[] {
|
||||
return syncFsCalls
|
||||
.filter(({ paths }) =>
|
||||
paths.some((path) => roots.some((root) => root && path.startsWith(root)))
|
||||
)
|
||||
.map(({ name, paths }) => `${name}(${paths.join(', ')})`)
|
||||
}
|
||||
|
||||
async function waitForDeferredRealpath(): Promise<void> {
|
||||
for (let attempt = 0; attempt < 50 && deferredNativeRealpaths.resume.length === 0; attempt++) {
|
||||
await new Promise((fulfill) => setTimeout(fulfill, 0))
|
||||
}
|
||||
}
|
||||
|
||||
function releaseDeferredRealpaths(): void {
|
||||
const pending = deferredNativeRealpaths.resume.splice(0)
|
||||
deferredNativeRealpaths.paths.clear()
|
||||
for (const resume of pending) {
|
||||
resume()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
testState.fakeHomeDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-trust-async-home-')))
|
||||
testState.userDataDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-trust-async-data-')))
|
||||
testState.workspaceDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-trust-async-ws-')))
|
||||
testState.previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
process.env.ORCA_USER_DATA_PATH = testState.userDataDir
|
||||
syncFsCalls.length = 0
|
||||
hangNativeRealpath.value = false
|
||||
deferredNativeRealpaths.paths.clear()
|
||||
deferredNativeRealpaths.resume.length = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of [testState.fakeHomeDir, testState.userDataDir, testState.workspaceDir]) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
if (testState.previousUserDataPath === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = testState.previousUserDataPath
|
||||
}
|
||||
testState.previousUserDataPath = undefined
|
||||
})
|
||||
|
||||
function cursorTrustFile(workspacePath: string): string {
|
||||
const slug = workspacePath.replace(/^[\\/]+/, '').replace(/[\\/:*?"<>|]+/g, '-')
|
||||
return join(testState.fakeHomeDir, '.cursor', 'projects', slug, '.workspace-trusted')
|
||||
}
|
||||
|
||||
function copilotConfigPath(): string {
|
||||
return join(testState.fakeHomeDir, '.copilot', 'config.json')
|
||||
}
|
||||
|
||||
function readCopilotTrustedFolders(): unknown[] {
|
||||
return JSON.parse(readFileSync(copilotConfigPath(), 'utf-8')).trustedFolders
|
||||
}
|
||||
|
||||
describe('agent trust presets off the main thread', () => {
|
||||
it('marks a cursor workspace trusted without touching the workspace synchronously', async () => {
|
||||
await markCursorWorkspaceTrustedAsync(testState.workspaceDir)
|
||||
|
||||
expect(syncCallsTouching(testState.workspaceDir)).toEqual([])
|
||||
const payload = JSON.parse(readFileSync(cursorTrustFile(testState.workspaceDir), 'utf-8'))
|
||||
expect(payload.workspacePath).toBe(testState.workspaceDir)
|
||||
})
|
||||
|
||||
it('publishes the cursor trust marker by rename so a reader never sees a partial file', async () => {
|
||||
await markCursorWorkspaceTrustedAsync(testState.workspaceDir)
|
||||
|
||||
const trustFile = cursorTrustFile(testState.workspaceDir)
|
||||
const renamedInto = syncFsCalls.filter(
|
||||
({ name, paths }) => name === 'renameSync' && paths[1] === trustFile
|
||||
)
|
||||
expect(renamedInto).toHaveLength(1)
|
||||
// Why: the marker path itself must never be opened for writing — cursor-agent
|
||||
// reads it concurrently and a torn write reads back as untrusted.
|
||||
expect(
|
||||
syncFsCalls.filter(({ name, paths }) => name === 'writeFileSync' && paths[0] === trustFile)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves an existing cursor trust marker instead of rewriting it', async () => {
|
||||
await markCursorWorkspaceTrustedAsync(testState.workspaceDir)
|
||||
const trustFile = cursorTrustFile(testState.workspaceDir)
|
||||
const first = readFileSync(trustFile, 'utf-8')
|
||||
|
||||
await markCursorWorkspaceTrustedAsync(testState.workspaceDir)
|
||||
|
||||
expect(readFileSync(trustFile, 'utf-8')).toBe(first)
|
||||
})
|
||||
|
||||
it('agrees with the synchronous cursor twin on the marker location', async () => {
|
||||
await markCursorWorkspaceTrustedAsync(testState.workspaceDir)
|
||||
const asyncMarker = readFileSync(cursorTrustFile(testState.workspaceDir), 'utf-8')
|
||||
|
||||
rmSync(join(testState.fakeHomeDir, '.cursor'), { recursive: true, force: true })
|
||||
markCursorWorkspaceTrusted(testState.workspaceDir)
|
||||
|
||||
expect(
|
||||
JSON.parse(readFileSync(cursorTrustFile(testState.workspaceDir), 'utf-8'))
|
||||
).toMatchObject({ workspacePath: JSON.parse(asyncMarker).workspacePath })
|
||||
})
|
||||
|
||||
it('marks a copilot folder trusted without touching the workspace synchronously', async () => {
|
||||
await markCopilotFolderTrustedAsync(testState.workspaceDir)
|
||||
|
||||
expect(syncCallsTouching(testState.workspaceDir)).toEqual([])
|
||||
expect(readCopilotTrustedFolders()).toEqual([testState.workspaceDir])
|
||||
})
|
||||
|
||||
it('does not lose a trusted folder when two copilot writes overlap', async () => {
|
||||
const otherWorkspace = realpathSync(mkdtempSync(join(tmpdir(), 'orca-trust-async-ws2-')))
|
||||
try {
|
||||
await Promise.all([
|
||||
markCopilotFolderTrustedAsync(testState.workspaceDir),
|
||||
markCopilotFolderTrustedAsync(otherWorkspace)
|
||||
])
|
||||
|
||||
expect([...readCopilotTrustedFolders()].sort()).toEqual(
|
||||
[testState.workspaceDir, otherWorkspace].sort()
|
||||
)
|
||||
} finally {
|
||||
rmSync(otherWorkspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not lose a synchronous copilot write that lands mid async trust write', async () => {
|
||||
const listedWorkspace = realpathSync(mkdtempSync(join(tmpdir(), 'orca-trust-async-listed-')))
|
||||
const syncWorkspace = realpathSync(mkdtempSync(join(tmpdir(), 'orca-trust-async-sync-')))
|
||||
mkdirSync(join(testState.fakeHomeDir, '.copilot'), { recursive: true })
|
||||
writeFileSync(copilotConfigPath(), JSON.stringify({ trustedFolders: [listedWorkspace] }))
|
||||
// Why: park on the already-listed folder's realpath, which is where an async
|
||||
// read-modify-write sits between reading the array and writing it back.
|
||||
deferredNativeRealpaths.paths.add(listedWorkspace)
|
||||
|
||||
try {
|
||||
const trust = markCopilotFolderTrustedAsync(testState.workspaceDir)
|
||||
await waitForDeferredRealpath()
|
||||
markCopilotFolderTrusted(syncWorkspace)
|
||||
releaseDeferredRealpaths()
|
||||
await trust
|
||||
|
||||
expect([...readCopilotTrustedFolders()].sort()).toEqual(
|
||||
[listedWorkspace, syncWorkspace, testState.workspaceDir].sort()
|
||||
)
|
||||
} finally {
|
||||
rmSync(listedWorkspace, { recursive: true, force: true })
|
||||
rmSync(syncWorkspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('marks a codex project trusted without touching the workspace synchronously', async () => {
|
||||
await markCodexProjectTrustedAsync(testState.workspaceDir)
|
||||
|
||||
expect(syncCallsTouching(testState.workspaceDir)).toEqual([])
|
||||
const config = readFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), 'utf-8')
|
||||
expect(config).toContain(`[projects."${testState.workspaceDir}"]`)
|
||||
expect(config).toContain('trust_level = "trusted"')
|
||||
})
|
||||
|
||||
it('agrees with the synchronous codex twin on the trust block it writes', async () => {
|
||||
await markCodexProjectTrustedAsync(testState.workspaceDir)
|
||||
const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
|
||||
const fromAsync = readFileSync(configPath, 'utf-8')
|
||||
|
||||
rmSync(join(testState.fakeHomeDir, '.codex'), { recursive: true, force: true })
|
||||
markCodexProjectTrusted(testState.workspaceDir)
|
||||
|
||||
expect(readFileSync(configPath, 'utf-8')).toBe(fromAsync)
|
||||
})
|
||||
|
||||
// Why: symlinked config.toml gives an async writer an await window (realpath of
|
||||
// the link) between its read and its rename — exactly where a sync hook-trust
|
||||
// write from codex/hook-service.ts would be silently overwritten.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'does not lose a synchronous hook-trust write that lands mid codex trust write',
|
||||
async () => {
|
||||
const dotfilesDir = join(testState.fakeHomeDir, 'dotfiles')
|
||||
const codexDir = join(testState.fakeHomeDir, '.codex')
|
||||
mkdirSync(dotfilesDir, { recursive: true })
|
||||
mkdirSync(codexDir, { recursive: true })
|
||||
const realConfigPath = join(dotfilesDir, 'config.toml')
|
||||
const configPath = join(codexDir, 'config.toml')
|
||||
writeFileSync(realConfigPath, 'model = "gpt-5"\n')
|
||||
symlinkSync(realConfigPath, configPath)
|
||||
deferredNativeRealpaths.paths.add(configPath)
|
||||
|
||||
const trust = markCodexProjectTrustedAsync(testState.workspaceDir)
|
||||
await waitForDeferredRealpath()
|
||||
upsertHookTrustEntries(configPath, [
|
||||
{
|
||||
sourcePath: join(codexDir, 'hooks.json'),
|
||||
eventLabel: 'session_start',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: 'orca-hook'
|
||||
}
|
||||
])
|
||||
releaseDeferredRealpaths()
|
||||
await trust
|
||||
|
||||
const content = readFileSync(realConfigPath, 'utf-8')
|
||||
expect(content).toContain('trusted_hash')
|
||||
expect(content).toContain(`[projects."${testState.workspaceDir}"]`)
|
||||
expect(content).toContain('model = "gpt-5"')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the event loop alive while the workspace realpath hangs', async () => {
|
||||
hangNativeRealpath.value = true
|
||||
|
||||
const trust = markCodexProjectTrustedAsync(testState.workspaceDir)
|
||||
const timerFired = await new Promise<boolean>((fulfill) => {
|
||||
setTimeout(() => fulfill(true), 5)
|
||||
})
|
||||
|
||||
expect(timerFired).toBe(true)
|
||||
expect(syncCallsTouching(testState.workspaceDir, testState.fakeHomeDir)).toEqual([])
|
||||
void trust
|
||||
})
|
||||
})
|
||||
+143
-17
@@ -1,7 +1,8 @@
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { writeFileAtomically } from './codex-accounts/fs-utils'
|
||||
import { realpathNativeAsync, writeFileAtomically } from './codex-accounts/fs-utils'
|
||||
import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths'
|
||||
import { upsertProjectTrustLevel } from './codex/config-toml-trust'
|
||||
|
||||
@@ -37,7 +38,20 @@ export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex'
|
||||
* derived via the same util that resolves `~/.cursor/projects/<slug>`).
|
||||
*/
|
||||
export function markCursorWorkspaceTrusted(workspacePath: string): void {
|
||||
const absPath = canonicalize(workspacePath)
|
||||
writeCursorTrustMarker(canonicalize(workspacePath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-thread (IPC) twin of markCursorWorkspaceTrusted. Only the workspace
|
||||
* realpath — the call that can park on a dead network mount — moves off-thread;
|
||||
* the marker write stays synchronous so the main thread remains the serializer
|
||||
* against the sync twin and tmp+rename atomicity is preserved.
|
||||
*/
|
||||
export async function markCursorWorkspaceTrustedAsync(workspacePath: string): Promise<void> {
|
||||
writeCursorTrustMarker(await canonicalizeAsync(workspacePath))
|
||||
}
|
||||
|
||||
function writeCursorTrustMarker(absPath: string): void {
|
||||
const slug = cursorWorkspaceSlug(absPath)
|
||||
if (!slug) {
|
||||
return
|
||||
@@ -53,6 +67,8 @@ export function markCursorWorkspaceTrusted(workspacePath: string): void {
|
||||
null,
|
||||
2
|
||||
)
|
||||
// Why: cursor-agent may be reading this marker concurrently, so publish via
|
||||
// tmp+rename — a torn in-place write would look like an empty trust file.
|
||||
writeFileAtomically(trustFile, `${payload}\n`)
|
||||
}
|
||||
|
||||
@@ -67,7 +83,55 @@ export function markCursorWorkspaceTrusted(workspacePath: string): void {
|
||||
* copilotTokens, etc.) survive untouched.
|
||||
*/
|
||||
export function markCopilotFolderTrusted(workspacePath: string): void {
|
||||
const absPath = canonicalize(workspacePath)
|
||||
addCopilotTrustedFolder(canonicalize(workspacePath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-thread (IPC) twin of markCopilotFolderTrusted. The realpath()s of the
|
||||
* workspace and of the already-listed folders (any of which can be a stalled
|
||||
* mount) run off-thread; the read-modify-write of ~/.copilot/config.json then
|
||||
* runs synchronously, so it cannot interleave with the sync twin.
|
||||
*/
|
||||
export async function markCopilotFolderTrustedAsync(workspacePath: string): Promise<void> {
|
||||
const absPath = await canonicalizeAsync(workspacePath)
|
||||
addCopilotTrustedFolder(absPath, await prefetchCanonicalTrustedFolders())
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort off-thread realpath() of the folders already in config.json. The
|
||||
* synchronous read below stays authoritative; this only pre-answers the lookups
|
||||
* so a listed dead mount cannot block the main thread.
|
||||
*/
|
||||
async function prefetchCanonicalTrustedFolders(): Promise<Map<string, string>> {
|
||||
const canonicalByEntry = new Map<string, string>()
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
await readFile(join(homedir(), '.copilot', 'config.json'), 'utf-8')
|
||||
)
|
||||
const trustedFolders =
|
||||
parsed && typeof parsed === 'object'
|
||||
? (parsed as Record<string, unknown>).trustedFolders
|
||||
: undefined
|
||||
if (!Array.isArray(trustedFolders)) {
|
||||
return canonicalByEntry
|
||||
}
|
||||
await Promise.all(
|
||||
trustedFolders.map(async (entry) => {
|
||||
if (typeof entry === 'string') {
|
||||
canonicalByEntry.set(entry, await canonicalizeAsync(entry))
|
||||
}
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
// Unreadable or corrupt config: the synchronous pass below decides what to do.
|
||||
}
|
||||
return canonicalByEntry
|
||||
}
|
||||
|
||||
function addCopilotTrustedFolder(
|
||||
absPath: string,
|
||||
canonicalByEntry?: ReadonlyMap<string, string>
|
||||
): void {
|
||||
const configDir = join(homedir(), '.copilot')
|
||||
const configPath = join(configDir, 'config.json')
|
||||
let config: Record<string, unknown> = {}
|
||||
@@ -87,7 +151,7 @@ export function markCopilotFolderTrusted(workspacePath: string): void {
|
||||
}
|
||||
const existing = Array.isArray(config.trustedFolders) ? (config.trustedFolders as unknown[]) : []
|
||||
const normalizedExisting = existing.map((entry) =>
|
||||
typeof entry === 'string' ? canonicalize(entry) : null
|
||||
typeof entry === 'string' ? (canonicalByEntry?.get(entry) ?? canonicalize(entry)) : null
|
||||
)
|
||||
if (normalizedExisting.includes(absPath)) {
|
||||
return
|
||||
@@ -109,12 +173,68 @@ export function markCopilotFolderTrusted(workspacePath: string): void {
|
||||
* codex-rs/core/src/config/config_tests.rs in the Codex CLI source.
|
||||
*/
|
||||
export function markCodexProjectTrusted(workspacePath: string): void {
|
||||
const absPath = resolveCodexProjectTrustRoot(workspacePath)
|
||||
const configPath = join(homedir(), '.codex', 'config.toml')
|
||||
upsertProjectTrustLevel(configPath, absPath, 'trusted')
|
||||
writeCodexProjectTrust(resolveCodexProjectTrustRoot(workspacePath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-thread (IPC) twin of markCodexProjectTrusted. The trust-root resolution
|
||||
* (realpath plus the workspace's own .git reads) runs off-thread; the
|
||||
* config.toml read-modify-write stays synchronous so it cannot interleave with
|
||||
* the sync writers in codex/hook-service.ts that share the same file.
|
||||
*/
|
||||
export async function markCodexProjectTrustedAsync(workspacePath: string): Promise<void> {
|
||||
writeCodexProjectTrust(await resolveCodexProjectTrustRootAsync(workspacePath))
|
||||
}
|
||||
|
||||
function writeCodexProjectTrust(absPath: string): void {
|
||||
// Why: absPath already went through realpath, so skip the extra realpath()s
|
||||
// the upsert would otherwise pay on a possibly-stalled workspace path.
|
||||
const options = { alreadyCanonical: true }
|
||||
upsertProjectTrustLevel(join(homedir(), '.codex', 'config.toml'), absPath, 'trusted', options)
|
||||
// Why: Orca-launched Codex runs with an Orca-owned CODEX_HOME, so the trust
|
||||
// preset must also update the runtime config Codex will actually read.
|
||||
upsertProjectTrustLevel(join(getOrcaManagedCodexHomePath(), 'config.toml'), absPath, 'trusted')
|
||||
upsertProjectTrustLevel(
|
||||
join(getOrcaManagedCodexHomePath(), 'config.toml'),
|
||||
absPath,
|
||||
'trusted',
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveCodexProjectTrustRootAsync(workspacePath: string): Promise<string> {
|
||||
const absPath = await canonicalizeAsync(workspacePath)
|
||||
try {
|
||||
const gitDirReference = (await readFile(join(absPath, '.git'), 'utf-8')).trim()
|
||||
if (!gitDirReference.startsWith('gitdir:')) {
|
||||
return absPath
|
||||
}
|
||||
const gitDirPath = gitDirReference.slice('gitdir:'.length).trim()
|
||||
if (!gitDirPath) {
|
||||
return absPath
|
||||
}
|
||||
const gitDir = resolve(absPath, gitDirPath)
|
||||
const worktreesDir = dirname(gitDir)
|
||||
if (basename(worktreesDir) !== 'worktrees') {
|
||||
return absPath
|
||||
}
|
||||
// Why: workspace-controlled .git metadata must not broaden trust without Git's reciprocal link.
|
||||
const gitDirBacklink = (await readFile(join(gitDir, 'gitdir'), 'utf-8')).trim()
|
||||
if (!gitDirBacklink) {
|
||||
return absPath
|
||||
}
|
||||
const resolvedBacklink = resolve(gitDir, gitDirBacklink)
|
||||
const workspaceGitFile = join(absPath, '.git')
|
||||
if (
|
||||
resolvedBacklink !== workspaceGitFile &&
|
||||
(await canonicalizeAsync(resolvedBacklink)) !== (await canonicalizeAsync(workspaceGitFile))
|
||||
) {
|
||||
return absPath
|
||||
}
|
||||
// Why: mirror Codex's validated .git/worktrees/<name> traversal instead of trusting arbitrary commondir contents.
|
||||
return await canonicalizeAsync(dirname(dirname(worktreesDir)))
|
||||
} catch {
|
||||
return absPath
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexProjectTrustRoot(workspacePath: string): string {
|
||||
@@ -153,19 +273,25 @@ function resolveCodexProjectTrustRoot(workspacePath: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but both
|
||||
// Cursor and Copilot's trust comparators run realpath() before the string
|
||||
// compare. Mirror that so a worktree under a symlinked parent (orca caches
|
||||
// realpath()'d worktree paths) matches the agent's lookup.
|
||||
function canonicalize(p: string): string {
|
||||
// Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but
|
||||
// both Cursor and Copilot's trust comparators run realpath() before the
|
||||
// string compare. Mirror that so a worktree under a symlinked parent
|
||||
// (orca caches realpath()'d worktree paths) matches the agent's lookup.
|
||||
try {
|
||||
if (existsSync(p)) {
|
||||
return realpathSync.native(p)
|
||||
}
|
||||
// Why: realpath already fails ENOENT on a missing path, so an existsSync pre-probe only doubles syscalls.
|
||||
return realpathSync.native(p)
|
||||
} catch {
|
||||
// Fall through to the raw input.
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
async function canonicalizeAsync(p: string): Promise<string> {
|
||||
try {
|
||||
return await realpathNativeAsync(p)
|
||||
} catch {
|
||||
return p
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
function cursorWorkspaceSlug(absPath: string): string {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
// Why: freeze #32. The Amp plugin probe ran existsSync + readFileSync against
|
||||
// ~/.config/amp on the Electron main thread; a stalled mount froze the whole app.
|
||||
const {
|
||||
syncFsCalls,
|
||||
hangReads,
|
||||
homedirMock,
|
||||
stalledRoot,
|
||||
STALLED_MOUNT_BLOCK_MS,
|
||||
TIMER_BUDGET_MS
|
||||
} = vi.hoisted(() => ({
|
||||
syncFsCalls: [] as string[],
|
||||
hangReads: { value: false },
|
||||
homedirMock: vi.fn<() => string>(),
|
||||
stalledRoot: { value: '' },
|
||||
STALLED_MOUNT_BLOCK_MS: 1_000,
|
||||
TIMER_BUDGET_MS: 500
|
||||
}))
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
const actual = (await vi.importActual('node:os')) as Record<string, unknown>
|
||||
return { ...actual, default: { ...actual, homedir: homedirMock }, homedir: homedirMock }
|
||||
})
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = (await vi.importActual('node:fs')) as Record<string, unknown>
|
||||
const wrapped: Record<string, unknown> = { ...actual }
|
||||
for (const [name, value] of Object.entries(actual)) {
|
||||
if (name.endsWith('Sync') && typeof value === 'function') {
|
||||
wrapped[name] = (...args: unknown[]) => {
|
||||
const target = String(args[0])
|
||||
syncFsCalls.push(`${name}:${target}`)
|
||||
if (hangReads.value && stalledRoot.value && target.startsWith(stalledRoot.value)) {
|
||||
// Why: a stalled SMB/NFS mount parks a *Sync call in the kernel with
|
||||
// the event loop still on the stack; Atomics.wait is the only faithful
|
||||
// way to reproduce that from JS.
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, STALLED_MOUNT_BLOCK_MS)
|
||||
}
|
||||
return (value as (...a: unknown[]) => unknown)(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...wrapped, default: wrapped }
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = (await vi.importActual('node:fs/promises')) as typeof FsPromises
|
||||
const readFile = (...args: Parameters<typeof actual.readFile>): Promise<unknown> =>
|
||||
hangReads.value ? new Promise<never>(() => {}) : actual.readFile(...args)
|
||||
return { ...actual, default: { ...actual, readFile }, readFile }
|
||||
})
|
||||
|
||||
import { AmpHookService, _internals } from './hook-service'
|
||||
|
||||
describe('AmpHookService main-thread fs', () => {
|
||||
let homeDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
homeDir = mkdtempSync(join(tmpdir(), 'orca-amp-sync-fs-'))
|
||||
homedirMock.mockReturnValue(homeDir)
|
||||
hangReads.value = false
|
||||
stalledRoot.value = homeDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
hangReads.value = false
|
||||
stalledRoot.value = ''
|
||||
vi.clearAllMocks()
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reads status without a single synchronous fs syscall', async () => {
|
||||
const pluginPath = _internals.getPluginPath()
|
||||
mkdirSync(dirname(pluginPath), { recursive: true })
|
||||
writeFileSync(pluginPath, `// ${_internals.AMP_PLUGIN_MARKER}\n`, 'utf-8')
|
||||
|
||||
syncFsCalls.length = 0
|
||||
await new AmpHookService().getStatus()
|
||||
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('installs and removes without a single synchronous fs syscall', async () => {
|
||||
const service = new AmpHookService()
|
||||
|
||||
syncFsCalls.length = 0
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
expect(syncFsCalls).toEqual([])
|
||||
|
||||
syncFsCalls.length = 0
|
||||
expect((await service.remove()).state).toBe('not_installed')
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
// Why: the point of the async conversion is that a stalled ~/.config/amp still
|
||||
// lets timers/repaints/menus run. Arm the timer *before* the call — a sync
|
||||
// reader blocks inside getStatus(), so lateness measured afterwards misses it.
|
||||
it('keeps the event loop alive while the plugin read hangs', async () => {
|
||||
hangReads.value = true
|
||||
syncFsCalls.length = 0
|
||||
|
||||
const armedAt = Date.now()
|
||||
let observedDelayMs = -1
|
||||
const timer = new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
observedDelayMs = Date.now() - armedAt
|
||||
resolve()
|
||||
}, 5)
|
||||
})
|
||||
|
||||
const pending = Promise.resolve(new AmpHookService().getStatus())
|
||||
void pending.catch(() => {})
|
||||
await timer
|
||||
|
||||
expect(observedDelayMs).toBeGreaterThanOrEqual(0)
|
||||
expect(observedDelayMs).toBeLessThan(TIMER_BUDGET_MS)
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('serializes overlapping installs so the plugin never renames out of order', async () => {
|
||||
const service = new AmpHookService()
|
||||
|
||||
const results = await Promise.all([service.install(), service.install()])
|
||||
|
||||
expect(results.map((status) => status.state)).toEqual(['installed', 'installed'])
|
||||
})
|
||||
})
|
||||
@@ -30,8 +30,8 @@ describe('AmpHookService', () => {
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('installs an Orca-managed Amp system plugin', () => {
|
||||
const status = new AmpHookService().install()
|
||||
it('installs an Orca-managed Amp system plugin', async () => {
|
||||
const status = await new AmpHookService().install()
|
||||
|
||||
expect(status).toMatchObject({
|
||||
agent: 'amp',
|
||||
@@ -61,12 +61,12 @@ describe('AmpHookService', () => {
|
||||
expect(source).toContain('process.env.ORCA_AGENT_HOOK_ENDPOINT')
|
||||
})
|
||||
|
||||
it('does not overwrite an existing user-authored Amp plugin file', () => {
|
||||
it('does not overwrite an existing user-authored Amp plugin file', async () => {
|
||||
const pluginPath = _internals.getPluginPath()
|
||||
mkdirSync(dirname(pluginPath), { recursive: true })
|
||||
writeFileSync(pluginPath, 'export default function userPlugin() {}\n', 'utf-8')
|
||||
|
||||
const status = new AmpHookService().install()
|
||||
const status = await new AmpHookService().install()
|
||||
|
||||
expect(status).toMatchObject({
|
||||
agent: 'amp',
|
||||
@@ -76,12 +76,12 @@ describe('AmpHookService', () => {
|
||||
expect(readFileSync(pluginPath, 'utf-8')).toBe('export default function userPlugin() {}\n')
|
||||
})
|
||||
|
||||
it('removes only Orca-managed Amp plugin files', () => {
|
||||
it('removes only Orca-managed Amp plugin files', async () => {
|
||||
const service = new AmpHookService()
|
||||
const installed = service.install()
|
||||
const installed = await service.install()
|
||||
expect(existsSync(installed.configPath)).toBe(true)
|
||||
|
||||
const removed = service.remove()
|
||||
const removed = await service.remove()
|
||||
|
||||
expect(removed.state).toBe('not_installed')
|
||||
expect(existsSync(installed.configPath)).toBe(false)
|
||||
@@ -90,18 +90,18 @@ describe('AmpHookService', () => {
|
||||
mkdirSync(dirname(pluginPath), { recursive: true })
|
||||
writeFileSync(pluginPath, 'export default function userPlugin() {}\n', 'utf-8')
|
||||
|
||||
const skipped = service.remove()
|
||||
const skipped = await service.remove()
|
||||
|
||||
expect(skipped.state).toBe('partial')
|
||||
expect(existsSync(pluginPath)).toBe(true)
|
||||
})
|
||||
|
||||
it('reports partial for stale managed plugin content missing required handlers', () => {
|
||||
it('reports partial for stale managed plugin content missing required handlers', async () => {
|
||||
const pluginPath = _internals.getPluginPath()
|
||||
mkdirSync(dirname(pluginPath), { recursive: true })
|
||||
writeFileSync(pluginPath, `// ${_internals.AMP_PLUGIN_MARKER}\n`, 'utf-8')
|
||||
|
||||
const status = new AmpHookService().getStatus()
|
||||
const status = await new AmpHookService().getStatus()
|
||||
|
||||
expect(status).toMatchObject({
|
||||
agent: 'amp',
|
||||
@@ -111,7 +111,7 @@ describe('AmpHookService', () => {
|
||||
expect(status.detail).toContain('missing required handlers')
|
||||
})
|
||||
|
||||
it('reports partial when a stale managed plugin is missing the session reset handler', () => {
|
||||
it('reports partial when a stale managed plugin is missing the session reset handler', async () => {
|
||||
const pluginPath = _internals.getPluginPath()
|
||||
mkdirSync(dirname(pluginPath), { recursive: true })
|
||||
writeFileSync(
|
||||
@@ -128,7 +128,7 @@ describe('AmpHookService', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const status = new AmpHookService().getStatus()
|
||||
const status = await new AmpHookService().getStatus()
|
||||
|
||||
expect(status).toMatchObject({
|
||||
agent: 'amp',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
emitted plugin bytes drift from the installer checks that protect user
|
||||
plugin files from being overwritten. */
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
@@ -48,19 +48,28 @@ function isCompleteManagedPlugin(content: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function readLocalPluginState(pluginPath: string): PluginFileState {
|
||||
if (!existsSync(pluginPath)) {
|
||||
return { kind: 'absent' }
|
||||
}
|
||||
// Why: async fs keeps a stalled network mount off the Electron main thread; a
|
||||
// hung read now parks a threadpool slot instead of freezing the whole app.
|
||||
function isMissingPath(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
return code === 'ENOENT' || code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
// Why: a single read replaces the existsSync probe + read; one syscall fewer on a stalled mount.
|
||||
async function readLocalPluginState(pluginPath: string): Promise<PluginFileState> {
|
||||
let content: string
|
||||
try {
|
||||
const content = readFileSync(pluginPath, 'utf-8')
|
||||
if (!isManagedPlugin(content)) {
|
||||
return { kind: 'unmanaged' }
|
||||
}
|
||||
return { kind: 'managed', complete: isCompleteManagedPlugin(content) }
|
||||
content = await readFile(pluginPath, 'utf-8')
|
||||
} catch (error) {
|
||||
if (isMissingPath(error)) {
|
||||
return { kind: 'absent' }
|
||||
}
|
||||
return { kind: 'error', detail: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
if (!isManagedPlugin(content)) {
|
||||
return { kind: 'unmanaged' }
|
||||
}
|
||||
return { kind: 'managed', complete: isCompleteManagedPlugin(content) }
|
||||
}
|
||||
|
||||
function statusFromState(pluginPath: string, state: PluginFileState): AgentHookInstallStatus {
|
||||
@@ -100,34 +109,37 @@ function statusFromState(pluginPath: string, state: PluginFileState): AgentHookI
|
||||
}
|
||||
}
|
||||
|
||||
function writeTextFileAtomic(filePath: string, content: string): void {
|
||||
async function writeTextFileAtomic(filePath: string, content: string): Promise<void> {
|
||||
const dir = dirname(filePath)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
if (readFileSync(filePath, 'utf-8') === content) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the atomic write path.
|
||||
await mkdir(dir, { recursive: true })
|
||||
try {
|
||||
if ((await readFile(filePath, 'utf-8')) === content) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Absent or unreadable: fall through to the atomic write path.
|
||||
}
|
||||
|
||||
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
|
||||
try {
|
||||
writeFileSync(tmpPath, content, 'utf-8')
|
||||
renameSync(tmpPath, filePath)
|
||||
await writeFile(tmpPath, content, 'utf-8')
|
||||
await rename(tmpPath, filePath)
|
||||
} finally {
|
||||
if (existsSync(tmpPath)) {
|
||||
try {
|
||||
unlinkSync(tmpPath)
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
// ENOENT after a successful rename is the normal case.
|
||||
await unlink(tmpPath).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// Why: sync fs serialized read-modify-write for free. Chain mutations so two
|
||||
// overlapping install/remove calls can never rename out of order.
|
||||
let pendingMutation: Promise<unknown> = Promise.resolve()
|
||||
|
||||
function serializePluginMutation<T>(run: () => Promise<T>): Promise<T> {
|
||||
const next = pendingMutation.then(run, run)
|
||||
pendingMutation = next.catch(() => {})
|
||||
return next
|
||||
}
|
||||
|
||||
function getAmpPluginSource(): string {
|
||||
return [
|
||||
"import { readFileSync, statSync } from 'fs'",
|
||||
@@ -328,19 +340,21 @@ function getAmpPluginSource(): string {
|
||||
}
|
||||
|
||||
export class AmpHookService {
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
async getStatus(): Promise<AgentHookInstallStatus> {
|
||||
const pluginPath = getPluginPath()
|
||||
return statusFromState(pluginPath, readLocalPluginState(pluginPath))
|
||||
return statusFromState(pluginPath, await readLocalPluginState(pluginPath))
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
const pluginPath = getPluginPath()
|
||||
const state = readLocalPluginState(pluginPath)
|
||||
if (state.kind === 'unmanaged' || state.kind === 'error') {
|
||||
return statusFromState(pluginPath, state)
|
||||
}
|
||||
writeTextFileAtomic(pluginPath, getAmpPluginSource())
|
||||
return this.getStatus()
|
||||
install(): Promise<AgentHookInstallStatus> {
|
||||
return serializePluginMutation(async () => {
|
||||
const pluginPath = getPluginPath()
|
||||
const state = await readLocalPluginState(pluginPath)
|
||||
if (state.kind === 'unmanaged' || state.kind === 'error') {
|
||||
return statusFromState(pluginPath, state)
|
||||
}
|
||||
await writeTextFileAtomic(pluginPath, getAmpPluginSource())
|
||||
return this.getStatus()
|
||||
})
|
||||
}
|
||||
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
@@ -369,14 +383,16 @@ export class AmpHookService {
|
||||
}
|
||||
}
|
||||
|
||||
remove(): AgentHookInstallStatus {
|
||||
const pluginPath = getPluginPath()
|
||||
const state = readLocalPluginState(pluginPath)
|
||||
if (state.kind === 'managed') {
|
||||
unlinkSync(pluginPath)
|
||||
return this.getStatus()
|
||||
}
|
||||
return statusFromState(pluginPath, state)
|
||||
remove(): Promise<AgentHookInstallStatus> {
|
||||
return serializePluginMutation(async () => {
|
||||
const pluginPath = getPluginPath()
|
||||
const state = await readLocalPluginState(pluginPath)
|
||||
if (state.kind === 'managed') {
|
||||
await unlink(pluginPath)
|
||||
return this.getStatus()
|
||||
}
|
||||
return statusFromState(pluginPath, state)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,14 @@ import {
|
||||
hookDefinitionHasManagedCommand,
|
||||
MANAGED_HOOK_TIMEOUT_SECONDS,
|
||||
readHooksJson,
|
||||
readHooksJsonAsync,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsCmdHookCommand,
|
||||
writeHooksJson,
|
||||
writeHooksJsonAsync,
|
||||
writeManagedScript,
|
||||
writeManagedScriptAsync,
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
@@ -295,61 +298,73 @@ function removeInstalledConfig(config: HooksConfig): void {
|
||||
config[ANTIGRAVITY_HOOK_BUNDLE_NAME] = bundle
|
||||
}
|
||||
|
||||
function parseErrorStatus(configPath: string): AgentHookInstallStatus {
|
||||
return {
|
||||
agent: 'antigravity',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Antigravity hooks.json'
|
||||
}
|
||||
}
|
||||
|
||||
function buildStatus(config: HooksConfig | null): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const bundle = getBundle(config)
|
||||
const isManagedCommand = createAntigravityManagedCommandMatcher()
|
||||
const currentCommands = new Set(
|
||||
ANTIGRAVITY_EVENTS.map((event) => getManagedCommand(scriptPath, event))
|
||||
)
|
||||
const staleManagedPresent = bundleHasStaleManagedCommand(
|
||||
bundle,
|
||||
isManagedCommand,
|
||||
currentCommands
|
||||
)
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
for (const event of ANTIGRAVITY_EVENTS) {
|
||||
const definitions = Array.isArray(bundle[event.eventName])
|
||||
? (bundle[event.eventName] as HookDefinition[])
|
||||
: []
|
||||
if (hasManagedCommand(definitions, getManagedCommand(scriptPath, event))) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(event.eventName)
|
||||
}
|
||||
}
|
||||
|
||||
const managedHooksPresent = presentCount > 0 || staleManagedPresent
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
if (missing.length === 0 && !staleManagedPresent) {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
} else if (presentCount === 0 && !staleManagedPresent) {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail =
|
||||
missing.length > 0
|
||||
? `Managed hook missing for events: ${missing.join(', ')}`
|
||||
: 'Stale managed hook entries need cleanup'
|
||||
}
|
||||
return { agent: 'antigravity', state, configPath, managedHooksPresent, detail }
|
||||
}
|
||||
|
||||
export class AntigravityHookService {
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'antigravity',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Antigravity hooks.json'
|
||||
}
|
||||
}
|
||||
return buildStatus(readHooksJson(getConfigPath()))
|
||||
}
|
||||
|
||||
const bundle = getBundle(config)
|
||||
const isManagedCommand = createAntigravityManagedCommandMatcher()
|
||||
const currentCommands = new Set(
|
||||
ANTIGRAVITY_EVENTS.map((event) => getManagedCommand(scriptPath, event))
|
||||
)
|
||||
const staleManagedPresent = bundleHasStaleManagedCommand(
|
||||
bundle,
|
||||
isManagedCommand,
|
||||
currentCommands
|
||||
)
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
for (const event of ANTIGRAVITY_EVENTS) {
|
||||
const definitions = Array.isArray(bundle[event.eventName])
|
||||
? (bundle[event.eventName] as HookDefinition[])
|
||||
: []
|
||||
if (hasManagedCommand(definitions, getManagedCommand(scriptPath, event))) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(event.eventName)
|
||||
}
|
||||
}
|
||||
|
||||
const managedHooksPresent = presentCount > 0 || staleManagedPresent
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
if (missing.length === 0 && !staleManagedPresent) {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
} else if (presentCount === 0 && !staleManagedPresent) {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail =
|
||||
missing.length > 0
|
||||
? `Managed hook missing for events: ${missing.join(', ')}`
|
||||
: 'Stale managed hook entries need cleanup'
|
||||
}
|
||||
return { agent: 'antigravity', state, configPath, managedHooksPresent, detail }
|
||||
// Why: main-thread twin — the sync one stays for the CLI process.
|
||||
async getStatusAsync(): Promise<AgentHookInstallStatus> {
|
||||
return buildStatus(await readHooksJsonAsync(getConfigPath()))
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
@@ -357,13 +372,7 @@ export class AntigravityHookService {
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'antigravity',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Antigravity hooks.json'
|
||||
}
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
buildInstalledConfig(
|
||||
@@ -386,6 +395,34 @@ export class AntigravityHookService {
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async installAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = await readHooksJsonAsync(configPath)
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
buildInstalledConfig(
|
||||
config,
|
||||
(event) => getManagedCommand(scriptPath, event),
|
||||
createAntigravityManagedCommandMatcher()
|
||||
)
|
||||
await writeManagedScriptAsync(scriptPath, getManagedScript())
|
||||
if (process.platform === 'win32') {
|
||||
// Why: Antigravity wraps hook commands in cmd.exe. Keeping event env
|
||||
// setup inside event-specific .cmd files avoids nested hooks.json quotes.
|
||||
for (const event of ANTIGRAVITY_EVENTS) {
|
||||
await writeManagedScriptAsync(
|
||||
getWindowsWrapperScriptPath(event),
|
||||
getWindowsWrapperScript(event.eventName)
|
||||
)
|
||||
}
|
||||
}
|
||||
await writeHooksJsonAsync(configPath, config)
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
const home = remoteHome.replace(/\/$/, '')
|
||||
const remoteConfigPath = `${home}/.gemini/config/hooks.json`
|
||||
@@ -433,19 +470,25 @@ export class AntigravityHookService {
|
||||
const configPath = getConfigPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'antigravity',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Antigravity hooks.json'
|
||||
}
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
removeInstalledConfig(config)
|
||||
writeHooksJson(configPath, config)
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async removeAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
const config = await readHooksJsonAsync(configPath)
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
removeInstalledConfig(config)
|
||||
await writeHooksJsonAsync(configPath, config)
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
}
|
||||
|
||||
export const antigravityHookService = new AntigravityHookService()
|
||||
|
||||
+134
-110
@@ -1,11 +1,14 @@
|
||||
import { existsSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { access, rm, writeFile } from 'node:fs/promises'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
buildWindowsAgentHookCurlPostCommand,
|
||||
readHooksJson,
|
||||
readHooksJsonAsync,
|
||||
writeHooksJson,
|
||||
writeHooksJsonAsync,
|
||||
writeManagedScript,
|
||||
writeManagedScriptAsync,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
@@ -13,12 +16,7 @@ import {
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue,
|
||||
WINDOWS_HOOK_STDIN_DRAIN_LABEL
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
import { getManagedScript } from './managed-hook-script'
|
||||
import { getManagedStatusLineScript } from './statusline-script'
|
||||
import {
|
||||
applyManagedHooks,
|
||||
@@ -53,66 +51,14 @@ const DEFAULT_CLAUDE_HOOK_SERVICE_OPTIONS: ClaudeHookServiceOptions = {
|
||||
settings: CLAUDE_HOOK_SETTINGS
|
||||
}
|
||||
|
||||
function getManagedScript(
|
||||
target: 'local' | 'posix' = 'local',
|
||||
options: { skipWhenDevinImportsClaude?: boolean } = {}
|
||||
): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
...(options.skipWhenDevinImportsClaude
|
||||
? [
|
||||
// Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin.
|
||||
`if not "%DEVIN_PROJECT_DIR%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`
|
||||
]
|
||||
: []),
|
||||
// Why: call the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls through to PTY env if missing.
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
// Why: post via curl.exe, not PowerShell — Claude's launcher is already encoded PowerShell, so a PS post would double interpreter startups per hook.
|
||||
buildWindowsAgentHookCurlPostCommand('claude'),
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
// Why: pure existence probe with no follow-up read, so access(F_OK) is the honest async twin of existsSync.
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
...(options.skipWhenDevinImportsClaude
|
||||
? [
|
||||
// Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin.
|
||||
'if [ -n "$DEVIN_PROJECT_DIR" ]; then',
|
||||
' exit 0',
|
||||
'fi'
|
||||
]
|
||||
: []),
|
||||
// Why: source the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls back to PTY env if missing.
|
||||
// Why: suppress stderr / || : so a stray parse error (TOCTOU or CRLF) can't leak into hook output or trip an outer set -e.
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
'fi',
|
||||
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
// Why: paths can hold quotes/newlines, so hand-building JSON in shell is unsafe; post the raw payload + metadata as form fields for the receiver to parse.
|
||||
// Why: pipe payload to curl stdin (`payload@-`), not an inline arg, so large tool output stays off the command line (EDR false positives).
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export class ClaudeHookService {
|
||||
@@ -124,16 +70,29 @@ export class ClaudeHookService {
|
||||
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath(this.options.settings)
|
||||
return this.buildStatus(configPath, readHooksJson(configPath))
|
||||
}
|
||||
|
||||
// Why: main-thread twin — the sync one stays for the CLI process.
|
||||
async getStatusAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath(this.options.settings)
|
||||
return this.buildStatus(configPath, await readHooksJsonAsync(configPath))
|
||||
}
|
||||
|
||||
private parseErrorStatus(configPath: string): AgentHookInstallStatus {
|
||||
return {
|
||||
agent: this.options.agent,
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: `Could not parse ${this.options.displayName} settings.json`
|
||||
}
|
||||
}
|
||||
|
||||
private buildStatus(configPath: string, config: HooksConfig | null): AgentHookInstallStatus {
|
||||
const scriptPath = getManagedScriptPath(this.options.settings)
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: this.options.agent,
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: `Could not parse ${this.options.displayName} settings.json`
|
||||
}
|
||||
return this.parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
// Why: report `partial` when only some events are registered so the sidebar shows a degraded install, not a false-positive `installed`.
|
||||
@@ -174,25 +133,15 @@ export class ClaudeHookService {
|
||||
const scriptPath = getManagedScriptPath(this.options.settings)
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: this.options.agent,
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: `Could not parse ${this.options.displayName} settings.json`
|
||||
}
|
||||
return this.parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const command = getManagedCommand(scriptPath)
|
||||
let nextConfig = applyManagedHooks(
|
||||
config,
|
||||
command,
|
||||
getManagedCommand(scriptPath),
|
||||
getManagedScriptFileName(this.options.settings)
|
||||
)
|
||||
writeManagedScript(
|
||||
scriptPath,
|
||||
getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' })
|
||||
)
|
||||
writeManagedScript(scriptPath, this.getLocalManagedScript())
|
||||
// Why: the statusline usage feed is Claude-only — OpenClaude data would be misattributed to the Claude provider.
|
||||
if (this.options.agent === 'claude') {
|
||||
nextConfig = this.installManagedStatusLine(nextConfig)
|
||||
@@ -201,22 +150,44 @@ export class ClaudeHookService {
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async installAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath(this.options.settings)
|
||||
const scriptPath = getManagedScriptPath(this.options.settings)
|
||||
const config = await readHooksJsonAsync(configPath)
|
||||
if (!config) {
|
||||
return this.parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
let nextConfig = applyManagedHooks(
|
||||
config,
|
||||
getManagedCommand(scriptPath),
|
||||
getManagedScriptFileName(this.options.settings)
|
||||
)
|
||||
await writeManagedScriptAsync(scriptPath, this.getLocalManagedScript())
|
||||
if (this.options.agent === 'claude') {
|
||||
nextConfig = await this.installManagedStatusLineAsync(nextConfig)
|
||||
}
|
||||
await writeHooksJsonAsync(configPath, nextConfig)
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
|
||||
private getLocalManagedScript(): string {
|
||||
return getManagedScript('local', {
|
||||
skipWhenDevinImportsClaude: this.options.agent === 'claude'
|
||||
})
|
||||
}
|
||||
|
||||
// Why: the statusline feed is opportunistic (usage display, not agent status); a user who deleted the
|
||||
// managed entry has opted out, and the marker distinguishes that deletion from a first install.
|
||||
private installManagedStatusLine(config: HooksConfig): HooksConfig {
|
||||
const scriptFileName = getStatusLineScriptFileName(this.options.settings)
|
||||
const markerPath = getStatusLineInstallMarkerPath(this.options.settings)
|
||||
const slot = getStatusLineSlotState(config, scriptFileName)
|
||||
if (slot === 'user' || (slot === 'empty' && existsSync(markerPath))) {
|
||||
if (this.shouldSkipManagedStatusLine(config, scriptFileName, existsSync(markerPath))) {
|
||||
return config
|
||||
}
|
||||
const statusLineScriptPath = getStatusLineScriptPath(this.options.settings)
|
||||
writeManagedScript(statusLineScriptPath, getManagedStatusLineScript('local'))
|
||||
const next = applyManagedStatusLine(
|
||||
config,
|
||||
getManagedCommand(statusLineScriptPath),
|
||||
scriptFileName
|
||||
)
|
||||
const next = this.withManagedStatusLine(config, statusLineScriptPath, scriptFileName)
|
||||
try {
|
||||
writeFileSync(markerPath, '')
|
||||
} catch {
|
||||
@@ -225,6 +196,40 @@ export class ClaudeHookService {
|
||||
return next
|
||||
}
|
||||
|
||||
private async installManagedStatusLineAsync(config: HooksConfig): Promise<HooksConfig> {
|
||||
const scriptFileName = getStatusLineScriptFileName(this.options.settings)
|
||||
const markerPath = getStatusLineInstallMarkerPath(this.options.settings)
|
||||
if (this.shouldSkipManagedStatusLine(config, scriptFileName, await pathExists(markerPath))) {
|
||||
return config
|
||||
}
|
||||
const statusLineScriptPath = getStatusLineScriptPath(this.options.settings)
|
||||
await writeManagedScriptAsync(statusLineScriptPath, getManagedStatusLineScript('local'))
|
||||
const next = this.withManagedStatusLine(config, statusLineScriptPath, scriptFileName)
|
||||
try {
|
||||
await writeFile(markerPath, '')
|
||||
} catch {
|
||||
// Best-effort: a missing marker only means one future user deletion gets re-installed once.
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
private shouldSkipManagedStatusLine(
|
||||
config: HooksConfig,
|
||||
scriptFileName: string,
|
||||
markerExists: boolean
|
||||
): boolean {
|
||||
const slot = getStatusLineSlotState(config, scriptFileName)
|
||||
return slot === 'user' || (slot === 'empty' && markerExists)
|
||||
}
|
||||
|
||||
private withManagedStatusLine(
|
||||
config: HooksConfig,
|
||||
statusLineScriptPath: string,
|
||||
scriptFileName: string
|
||||
): HooksConfig {
|
||||
return applyManagedStatusLine(config, getManagedCommand(statusLineScriptPath), scriptFileName)
|
||||
}
|
||||
|
||||
// Why: install the Claude hook on the remote box (via SFTP); POSIX-only by design (Windows-remote deferred).
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
// Why: remote-Windows is out of scope; ship POSIX paths. process.platform here is the local box, not the remote, so it can't gate this.
|
||||
@@ -282,23 +287,10 @@ export class ClaudeHookService {
|
||||
const configPath = getConfigPath(this.options.settings)
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: this.options.agent,
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: `Could not parse ${this.options.displayName} settings.json`
|
||||
}
|
||||
return this.parseErrorStatus(configPath)
|
||||
}
|
||||
const { config: hooksRemoved, changed: hooksChanged } = removeManagedHooks(
|
||||
config,
|
||||
getManagedScriptFileName(this.options.settings)
|
||||
)
|
||||
const { config: nextConfig, changed: statusLineChanged } = removeManagedStatusLine(
|
||||
hooksRemoved,
|
||||
getStatusLineScriptFileName(this.options.settings)
|
||||
)
|
||||
if (hooksChanged || statusLineChanged) {
|
||||
const { config: nextConfig, changed } = this.planRemoval(config)
|
||||
if (changed) {
|
||||
writeHooksJson(configPath, nextConfig)
|
||||
}
|
||||
if (this.options.agent === 'claude') {
|
||||
@@ -311,6 +303,38 @@ export class ClaudeHookService {
|
||||
}
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async removeAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath(this.options.settings)
|
||||
const config = await readHooksJsonAsync(configPath)
|
||||
if (!config) {
|
||||
return this.parseErrorStatus(configPath)
|
||||
}
|
||||
const { config: nextConfig, changed } = this.planRemoval(config)
|
||||
if (changed) {
|
||||
await writeHooksJsonAsync(configPath, nextConfig)
|
||||
}
|
||||
if (this.options.agent === 'claude') {
|
||||
try {
|
||||
await rm(getStatusLineInstallMarkerPath(this.options.settings), { force: true })
|
||||
} catch {
|
||||
// ignore — marker cleanup is best-effort
|
||||
}
|
||||
}
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
|
||||
private planRemoval(config: HooksConfig): { config: HooksConfig; changed: boolean } {
|
||||
const { config: hooksRemoved, changed: hooksChanged } = removeManagedHooks(
|
||||
config,
|
||||
getManagedScriptFileName(this.options.settings)
|
||||
)
|
||||
const { config: nextConfig, changed: statusLineChanged } = removeManagedStatusLine(
|
||||
hooksRemoved,
|
||||
getStatusLineScriptFileName(this.options.settings)
|
||||
)
|
||||
return { config: nextConfig, changed: hooksChanged || statusLineChanged }
|
||||
}
|
||||
}
|
||||
|
||||
export const claudeHookService = new ClaudeHookService()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { buildWindowsAgentHookCurlPostCommand } from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue,
|
||||
WINDOWS_HOOK_STDIN_DRAIN_LABEL
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
// Why: the managed hook body is shared by the local install and the SSH remote
|
||||
// install, so it lives apart from the service that decides where to write it.
|
||||
export function getManagedScript(
|
||||
target: 'local' | 'posix' = 'local',
|
||||
options: { skipWhenDevinImportsClaude?: boolean } = {}
|
||||
): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
...(options.skipWhenDevinImportsClaude
|
||||
? [
|
||||
// Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin.
|
||||
`if not "%DEVIN_PROJECT_DIR%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`
|
||||
]
|
||||
: []),
|
||||
// Why: call the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls through to PTY env if missing.
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
// Why: post via curl.exe, not PowerShell — Claude's launcher is already encoded PowerShell, so a PS post would double interpreter startups per hook.
|
||||
buildWindowsAgentHookCurlPostCommand('claude'),
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
...(options.skipWhenDevinImportsClaude
|
||||
? [
|
||||
// Why: Devin imports .claude hooks by default; skip Orca's managed hook there so status posts stay attributed to Devin.
|
||||
'if [ -n "$DEVIN_PROJECT_DIR" ]; then',
|
||||
' exit 0',
|
||||
'fi'
|
||||
]
|
||||
: []),
|
||||
// Why: source the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls back to PTY env if missing.
|
||||
// Why: suppress stderr / || : so a stray parse error (TOCTOU or CRLF) can't leak into hook output or trip an outer set -e.
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
'fi',
|
||||
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
// Why: paths can hold quotes/newlines, so hand-building JSON in shell is unsafe; post the raw payload + metadata as form fields for the receiver to parse.
|
||||
// Why: pipe payload to curl stdin (`payload@-`), not an inline arg, so large tool output stays off the command line (EDR false positives).
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { copyFileSync, existsSync, linkSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
linkSync,
|
||||
realpath as realpathCallback,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { copyFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { grantDirAcl, isPermissionError } from '../win32-utils'
|
||||
import { nodeFileContentsEqualSync } from '../../shared/node-file-content-equality'
|
||||
|
||||
@@ -222,9 +232,111 @@ function runFileOperationWithWindowsRetry(operation: () => void): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: writeFileAtomically is a sync API called from sync paths, so the retry
|
||||
// backoff must park the thread instead of burning CPU in a Date.now() loop.
|
||||
// Why: writeFileAtomically is a sync API called from sync paths (CLI, startup,
|
||||
// crash handlers), so the retry backoff must park that thread instead of
|
||||
// burning CPU in a Date.now() loop. Main-thread callers use the async twins.
|
||||
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4))
|
||||
function sleepSync(ms: number): void {
|
||||
Atomics.wait(sleepBuffer, 0, 0, ms)
|
||||
}
|
||||
|
||||
/**
|
||||
* Async twin of writeFileAtomically for main-thread callers: a stalled network
|
||||
* mount blocks a threadpool slot instead of freezing the whole app.
|
||||
*/
|
||||
export async function writeFileAtomicallyAsync(
|
||||
targetPath: string,
|
||||
contents: string,
|
||||
options?: { mode?: number }
|
||||
): Promise<void> {
|
||||
const tmpPath = `${targetPath}.${process.pid}.${randomUUID()}.tmp`
|
||||
try {
|
||||
await writeFile(tmpPath, contents, { encoding: 'utf-8', mode: options?.mode })
|
||||
await renameFileWithWindowsRetryAsync(tmpPath, targetPath)
|
||||
} catch (error) {
|
||||
await rm(tmpPath, { force: true })
|
||||
// Why: mirrors the sync path's Chromium-DACL recovery (see writeFileAtomically).
|
||||
if (isPermissionError(error) && process.platform === 'win32') {
|
||||
try {
|
||||
grantDirAcl(dirname(targetPath))
|
||||
const retryTmpPath = `${targetPath}.${process.pid}.${randomUUID()}.tmp`
|
||||
try {
|
||||
await writeFile(retryTmpPath, contents, { encoding: 'utf-8', mode: options?.mode })
|
||||
await renameFileWithWindowsRetryAsync(retryTmpPath, targetPath)
|
||||
return
|
||||
} catch {
|
||||
await rm(retryTmpPath, { force: true })
|
||||
}
|
||||
} catch {
|
||||
// icacls failure is not actionable; re-throw the original EPERM
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function renameFileWithWindowsRetryAsync(source: string, target: string): Promise<void> {
|
||||
return runFileOperationWithWindowsRetryAsync(() => rename(source, target))
|
||||
}
|
||||
|
||||
export function copyFileWithWindowsRetryAsync(source: string, target: string): Promise<void> {
|
||||
return runFileOperationWithWindowsRetryAsync(() => copyFile(source, target))
|
||||
}
|
||||
|
||||
async function runFileOperationWithWindowsRetryAsync(
|
||||
operation: () => Promise<void>
|
||||
): Promise<void> {
|
||||
const maxAttempts = process.platform === 'win32' ? 6 : 1
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
await operation()
|
||||
return
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (attempt < maxAttempts && (code === 'EPERM' || code === 'EACCES' || code === 'EBUSY')) {
|
||||
// Why: an awaited timer keeps the main thread repainting; Atomics.wait would park it.
|
||||
await delay(attempt * 50)
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the single main thread used to serialize every read-modify-write and
|
||||
// tmp->rename for free. Async writers must chain per target file or two
|
||||
// overlapping updates can publish out of order / lose the earlier one.
|
||||
const fileWriteChains = new Map<string, Promise<void>>()
|
||||
|
||||
export function serializeAtomicFileWrite<T>(
|
||||
targetPath: string,
|
||||
write: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const previous = fileWriteChains.get(targetPath) ?? Promise.resolve()
|
||||
const current = previous.then(write)
|
||||
const settled = current.then(
|
||||
() => {},
|
||||
() => {}
|
||||
)
|
||||
fileWriteChains.set(targetPath, settled)
|
||||
void settled.then(() => {
|
||||
if (fileWriteChains.get(targetPath) === settled) {
|
||||
fileWriteChains.delete(targetPath)
|
||||
}
|
||||
})
|
||||
return current
|
||||
}
|
||||
|
||||
// Why: fs/promises has no realpath.native, and the OS resolver is what Codex,
|
||||
// Cursor, and Copilot compare against (8.3 names, drive casing, /tmp aliases).
|
||||
export function realpathNativeAsync(targetPath: string): Promise<string> {
|
||||
return new Promise((fulfill, reject) => {
|
||||
realpathCallback.native(targetPath, (error, resolved) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
fulfill(resolved)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { execFileSync, spawn, type ChildProcess } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve, sep } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { app } from 'electron'
|
||||
@@ -276,11 +277,17 @@ export class CodexAccountService {
|
||||
return next
|
||||
}
|
||||
|
||||
/** Sync twin kept for the runtime's synchronous accounts-snapshot builders (mobile/RPC). */
|
||||
listAccounts(): CodexRateLimitAccountsState {
|
||||
this.normalizeActiveSelection()
|
||||
return this.getSnapshot()
|
||||
}
|
||||
|
||||
async listAccountsAsync(): Promise<CodexRateLimitAccountsState> {
|
||||
this.normalizeActiveSelection()
|
||||
return this.getSnapshotAsync()
|
||||
}
|
||||
|
||||
async addAccount(target?: CodexAccountAddTarget): Promise<CodexRateLimitAccountsState> {
|
||||
return this.serializeMutation(() => this.doAddAccount(target))
|
||||
}
|
||||
@@ -980,6 +987,14 @@ export class CodexAccountService {
|
||||
}
|
||||
|
||||
private getSnapshot(): CodexRateLimitAccountsState {
|
||||
return this.buildSnapshot(this.resolveSystemDefaultIdentity())
|
||||
}
|
||||
|
||||
private async getSnapshotAsync(): Promise<CodexRateLimitAccountsState> {
|
||||
return this.buildSnapshot(await this.resolveSystemDefaultIdentityAsync())
|
||||
}
|
||||
|
||||
private buildSnapshot(systemDefault: CodexSystemDefaultIdentity): CodexRateLimitAccountsState {
|
||||
const settings = this.store.getSettings()
|
||||
return {
|
||||
accounts: settings.codexManagedAccounts
|
||||
@@ -987,47 +1002,68 @@ export class CodexAccountService {
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt),
|
||||
activeAccountId: normalizeCodexRuntimeSelection(settings).host,
|
||||
activeAccountIdsByRuntime: normalizeCodexRuntimeSelection(settings),
|
||||
systemDefault: this.resolveSystemDefaultIdentity()
|
||||
systemDefault
|
||||
}
|
||||
}
|
||||
|
||||
private getSystemDefaultAuthPath(): string {
|
||||
return join(homedir(), '.codex', 'auth.json')
|
||||
}
|
||||
|
||||
// Why: the system-default (activeAccountId:null) account has no stored
|
||||
// identity — its effective login is whatever the real ~/.codex/auth.json is
|
||||
// right now. Read it live and read-only so the switcher can display who the
|
||||
// system default is and attribute usage, without ever mutating ~/.codex.
|
||||
private resolveSystemDefaultIdentity(): CodexSystemDefaultIdentity {
|
||||
const authFilePath = join(homedir(), '.codex', 'auth.json')
|
||||
let contents: string
|
||||
try {
|
||||
// Why: a single read avoids an exists/read race and halves filesystem
|
||||
// probes whenever an accounts snapshot resolves this live identity.
|
||||
contents = readFileSync(authFilePath, 'utf-8')
|
||||
contents = readFileSync(this.getSystemDefaultAuthPath(), 'utf-8')
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
// Why: no auth.json means either a signed-out home or an env-key/custom
|
||||
// provider that authenticates via OPENAI_API_KEY instead of a token file.
|
||||
return {
|
||||
hasAuth: false,
|
||||
authKind: this.hasEnvApiKey() ? 'api-key' : 'none',
|
||||
email: null,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null
|
||||
}
|
||||
}
|
||||
console.warn(
|
||||
'[codex-accounts] Failed to read system-default Codex identity',
|
||||
code ?? 'unknown-error'
|
||||
)
|
||||
return this.systemDefaultIdentityForReadError(error)
|
||||
}
|
||||
return this.parseSystemDefaultIdentity(contents)
|
||||
}
|
||||
|
||||
/** Async twin: a stalled HOME must not park the main thread on an accounts snapshot. */
|
||||
private async resolveSystemDefaultIdentityAsync(): Promise<CodexSystemDefaultIdentity> {
|
||||
let contents: string
|
||||
try {
|
||||
contents = await readFile(this.getSystemDefaultAuthPath(), 'utf-8')
|
||||
} catch (error) {
|
||||
return this.systemDefaultIdentityForReadError(error)
|
||||
}
|
||||
return this.parseSystemDefaultIdentity(contents)
|
||||
}
|
||||
|
||||
private systemDefaultIdentityForReadError(error: unknown): CodexSystemDefaultIdentity {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
// Why: no auth.json means either a signed-out home or an env-key/custom
|
||||
// provider that authenticates via OPENAI_API_KEY instead of a token file.
|
||||
return {
|
||||
hasAuth: true,
|
||||
authKind: 'none',
|
||||
hasAuth: false,
|
||||
authKind: this.hasEnvApiKey() ? 'api-key' : 'none',
|
||||
email: null,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null
|
||||
}
|
||||
}
|
||||
console.warn(
|
||||
'[codex-accounts] Failed to read system-default Codex identity',
|
||||
code ?? 'unknown-error'
|
||||
)
|
||||
return {
|
||||
hasAuth: true,
|
||||
authKind: 'none',
|
||||
email: null,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null
|
||||
}
|
||||
}
|
||||
|
||||
private parseSystemDefaultIdentity(contents: string): CodexSystemDefaultIdentity {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(contents)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Why: freeze #18. `codexAccounts:list` resolved the live ~/.codex/auth.json identity
|
||||
// with readFileSync on the Electron main thread; a stalled HOME froze the whole app.
|
||||
const { syncFsCalls, hangReads, testState } = vi.hoisted(() => ({
|
||||
syncFsCalls: [] as string[],
|
||||
hangReads: { value: false },
|
||||
testState: { fakeHomeDir: '', userDataDir: '' }
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ app: { getPath: () => testState.userDataDir } }))
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
const actual = (await vi.importActual('node:os')) as Record<string, unknown>
|
||||
const homedir = (): string => testState.fakeHomeDir
|
||||
return { ...actual, default: { ...actual, homedir }, homedir }
|
||||
})
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = (await vi.importActual('node:fs')) as Record<string, unknown>
|
||||
const wrapped: Record<string, unknown> = { ...actual }
|
||||
for (const [name, value] of Object.entries(actual)) {
|
||||
if (name.endsWith('Sync') && typeof value === 'function') {
|
||||
wrapped[name] = (...args: unknown[]) => {
|
||||
syncFsCalls.push(`${name}:${String(args[0])}`)
|
||||
return (value as (...a: unknown[]) => unknown)(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...wrapped, default: wrapped }
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = (await vi.importActual('node:fs/promises')) as typeof FsPromises
|
||||
const readFile = (...args: Parameters<typeof actual.readFile>): Promise<unknown> =>
|
||||
hangReads.value ? new Promise<never>(() => {}) : actual.readFile(...args)
|
||||
return { ...actual, default: { ...actual, readFile }, readFile }
|
||||
})
|
||||
|
||||
import { CodexAccountService } from './service'
|
||||
|
||||
function createService(): CodexAccountService {
|
||||
const settings = {
|
||||
codexManagedAccounts: [],
|
||||
activeCodexManagedAccountId: null,
|
||||
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} }
|
||||
}
|
||||
const store = {
|
||||
getSettings: vi.fn(() => settings),
|
||||
updateSettings: vi.fn(() => settings),
|
||||
getCodexResetCreditAttemptLedger: vi.fn(() => ({ version: 1, attempts: [] })),
|
||||
replaceCodexResetCreditAttemptLedgerAndFlush: vi.fn()
|
||||
}
|
||||
const rateLimits = { refreshForCodexAccountChange: vi.fn(), evictInactiveCodexCache: vi.fn() }
|
||||
const runtimeHome = {
|
||||
syncForCurrentSelection: vi.fn(),
|
||||
clearLastWrittenAuthJson: vi.fn(),
|
||||
prepareForRateLimitFetch: vi.fn(() => null)
|
||||
}
|
||||
return new CodexAccountService(store as never, rateLimits as never, runtimeHome as never)
|
||||
}
|
||||
|
||||
function writeSystemAuthJson(contents: string): void {
|
||||
mkdirSync(join(testState.fakeHomeDir, '.codex'), { recursive: true })
|
||||
writeFileSync(join(testState.fakeHomeDir, '.codex', 'auth.json'), contents, 'utf-8')
|
||||
}
|
||||
|
||||
describe('codex system-default identity off the main thread', () => {
|
||||
beforeEach(() => {
|
||||
testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-home-'))
|
||||
testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-userdata-'))
|
||||
hangReads.value = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
hangReads.value = false
|
||||
rmSync(testState.fakeHomeDir, { recursive: true, force: true })
|
||||
rmSync(testState.userDataDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('lists accounts without a single synchronous fs syscall', async () => {
|
||||
writeSystemAuthJson(JSON.stringify({ OPENAI_API_KEY: 'sk-live-test' }))
|
||||
const service = createService()
|
||||
|
||||
syncFsCalls.length = 0
|
||||
const state = await service.listAccountsAsync()
|
||||
const recorded = [...syncFsCalls]
|
||||
|
||||
expect(recorded).toEqual([])
|
||||
expect(state.systemDefault).toEqual({
|
||||
hasAuth: true,
|
||||
authKind: 'api-key',
|
||||
email: null,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a signed-out home when auth.json is missing', async () => {
|
||||
const service = createService()
|
||||
delete process.env.OPENAI_API_KEY
|
||||
|
||||
syncFsCalls.length = 0
|
||||
const state = await service.listAccountsAsync()
|
||||
|
||||
expect(syncFsCalls).toEqual([])
|
||||
expect(state.systemDefault).toMatchObject({ hasAuth: false, authKind: 'none' })
|
||||
})
|
||||
|
||||
it('keeps the event loop alive while the auth read hangs', async () => {
|
||||
writeSystemAuthJson(JSON.stringify({ OPENAI_API_KEY: 'sk-live-test' }))
|
||||
const service = createService()
|
||||
hangReads.value = true
|
||||
|
||||
syncFsCalls.length = 0
|
||||
const pending = service.listAccountsAsync()
|
||||
void pending.catch(() => {})
|
||||
|
||||
let timerFired = false
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
timerFired = true
|
||||
resolve()
|
||||
}, 10)
|
||||
})
|
||||
|
||||
expect(timerFired).toBe(true)
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -370,10 +370,13 @@ export function upsertHookTrustEntriesInContent(
|
||||
export function upsertProjectTrustLevel(
|
||||
configPath: string,
|
||||
projectPath: string,
|
||||
trustLevel: CodexProjectTrustLevel
|
||||
trustLevel: CodexProjectTrustLevel,
|
||||
// Why: callers that already resolved the workspace path off-thread pass this
|
||||
// so a stalled mount is never realpath()'d again on the main thread.
|
||||
options?: { alreadyCanonical?: boolean }
|
||||
): void {
|
||||
const existing = existsSync(configPath) ? readTomlFile(configPath) : ''
|
||||
const updated = upsertProjectTrustLevelInContent(existing, projectPath, trustLevel)
|
||||
const updated = upsertProjectTrustLevelInContent(existing, projectPath, trustLevel, options)
|
||||
if (updated === existing) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,12 +7,16 @@ import {
|
||||
createManagedCommandMatcher,
|
||||
getSharedManagedScriptPath,
|
||||
readHooksJson,
|
||||
readHooksJsonAsync,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
writeHooksJson,
|
||||
writeHooksJsonAsync,
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
writeManagedScriptAsync,
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
@@ -51,11 +55,7 @@ function getManagedCommand(scriptPath: string): string {
|
||||
: wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
function buildInstalledConfig(
|
||||
config: NonNullable<ReturnType<typeof readHooksJson>>,
|
||||
command: string,
|
||||
scriptFileName: string
|
||||
): void {
|
||||
function buildInstalledConfig(config: HooksConfig, command: string, scriptFileName: string): void {
|
||||
const nextHooks = { ...config.hooks }
|
||||
const isManagedCommand = createManagedCommandMatcher(scriptFileName)
|
||||
const managedEvents = new Set<string>(COMMAND_CODE_EVENTS.map((event) => event.eventName))
|
||||
@@ -87,52 +87,80 @@ function buildInstalledConfig(
|
||||
config.hooks = nextHooks
|
||||
}
|
||||
|
||||
function parseErrorStatus(configPath: string): AgentHookInstallStatus {
|
||||
return {
|
||||
agent: 'command-code',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Command Code settings.json'
|
||||
}
|
||||
}
|
||||
|
||||
function stripManagedHooks(config: HooksConfig): void {
|
||||
const nextHooks = { ...config.hooks }
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
config.hooks = nextHooks
|
||||
}
|
||||
|
||||
function buildStatus(config: HooksConfig | null): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const command = getManagedCommand(getManagedScriptPath())
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
for (const event of COMMAND_CODE_EVENTS) {
|
||||
const definitions = Array.isArray(config.hooks?.[event.eventName])
|
||||
? config.hooks![event.eventName]!
|
||||
: []
|
||||
const hasCommand = definitions.some((definition) =>
|
||||
(definition.hooks ?? []).some((hook) => hook.command === command)
|
||||
)
|
||||
if (hasCommand) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(event.eventName)
|
||||
}
|
||||
}
|
||||
|
||||
const managedHooksPresent = presentCount > 0
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
if (missing.length === 0) {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
} else if (presentCount === 0) {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail = `Managed hook missing for events: ${missing.join(', ')}`
|
||||
}
|
||||
return { agent: 'command-code', state, configPath, managedHooksPresent, detail }
|
||||
}
|
||||
|
||||
export class CommandCodeHookService {
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'command-code',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Command Code settings.json'
|
||||
}
|
||||
}
|
||||
return buildStatus(readHooksJson(getConfigPath()))
|
||||
}
|
||||
|
||||
const command = getManagedCommand(scriptPath)
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
for (const event of COMMAND_CODE_EVENTS) {
|
||||
const definitions = Array.isArray(config.hooks?.[event.eventName])
|
||||
? config.hooks![event.eventName]!
|
||||
: []
|
||||
const hasCommand = definitions.some((definition) =>
|
||||
(definition.hooks ?? []).some((hook) => hook.command === command)
|
||||
)
|
||||
if (hasCommand) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(event.eventName)
|
||||
}
|
||||
}
|
||||
|
||||
const managedHooksPresent = presentCount > 0
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
if (missing.length === 0) {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
} else if (presentCount === 0) {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail = `Managed hook missing for events: ${missing.join(', ')}`
|
||||
}
|
||||
return { agent: 'command-code', state, configPath, managedHooksPresent, detail }
|
||||
// Why: main-thread twin — the sync one stays for the CLI process.
|
||||
async getStatusAsync(): Promise<AgentHookInstallStatus> {
|
||||
return buildStatus(await readHooksJsonAsync(getConfigPath()))
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
@@ -140,13 +168,7 @@ export class CommandCodeHookService {
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'command-code',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Command Code settings.json'
|
||||
}
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
buildInstalledConfig(config, getManagedCommand(scriptPath), getManagedScriptFileName())
|
||||
@@ -155,6 +177,20 @@ export class CommandCodeHookService {
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async installAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = await readHooksJsonAsync(configPath)
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
buildInstalledConfig(config, getManagedCommand(scriptPath), getManagedScriptFileName())
|
||||
await writeManagedScriptAsync(scriptPath, buildCommandCodeManagedScript())
|
||||
await writeHooksJsonAsync(configPath, config)
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
const home = remoteHome.replace(/\/$/, '')
|
||||
const remoteConfigPath = `${home}/.commandcode/settings.json`
|
||||
@@ -197,33 +233,23 @@ export class CommandCodeHookService {
|
||||
const configPath = getConfigPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'command-code',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Command Code settings.json'
|
||||
}
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
|
||||
config.hooks = nextHooks
|
||||
stripManagedHooks(config)
|
||||
writeHooksJson(configPath, config)
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async removeAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
const config = await readHooksJsonAsync(configPath)
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
stripManagedHooks(config)
|
||||
await writeHooksJsonAsync(configPath, config)
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
}
|
||||
|
||||
export const commandCodeHookService = new CommandCodeHookService()
|
||||
|
||||
@@ -10,12 +10,16 @@ import {
|
||||
createManagedCommandMatcher,
|
||||
getSharedManagedScriptPath,
|
||||
readHooksJson,
|
||||
readHooksJsonAsync,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
writeHooksJson,
|
||||
writeHooksJsonAsync,
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
writeManagedScriptAsync,
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
@@ -182,72 +186,84 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function parseErrorStatus(configPath: string): AgentHookInstallStatus {
|
||||
return {
|
||||
agent: 'copilot',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Copilot hooks/orca.json'
|
||||
}
|
||||
}
|
||||
|
||||
function buildStatus(config: HooksConfig | null): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
let staleManagedPresent = false
|
||||
const managedEvents = new Set<string>(COPILOT_EVENTS)
|
||||
for (const eventName of COPILOT_EVENTS) {
|
||||
const command = getManagedCommand(scriptPath, eventName)
|
||||
const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : []
|
||||
const hasCurrentCommand = definitions.some((definition) =>
|
||||
definitionHasCurrentCommand(definition, command)
|
||||
)
|
||||
if (hasCurrentCommand) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(eventName)
|
||||
}
|
||||
}
|
||||
for (const [eventName, definitions] of Object.entries(config.hooks ?? {})) {
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const currentCommand = managedEvents.has(eventName)
|
||||
? getManagedCommand(scriptPath, eventName)
|
||||
: null
|
||||
staleManagedPresent =
|
||||
staleManagedPresent ||
|
||||
definitions.some((definition) =>
|
||||
definitionHasStaleManagedCommand(definition, currentCommand, isManagedCommand)
|
||||
)
|
||||
}
|
||||
|
||||
const managedHooksPresent = presentCount > 0 || staleManagedPresent
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
if (config.disableAllHooks === true && managedHooksPresent) {
|
||||
state = 'partial'
|
||||
detail = 'Managed Copilot hook file is disabled'
|
||||
} else if (staleManagedPresent) {
|
||||
state = 'partial'
|
||||
detail = 'Managed Copilot hook file contains stale entries'
|
||||
} else if (missing.length === 0) {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
} else if (presentCount === 0 && !staleManagedPresent) {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail = `Managed hook missing for events: ${missing.join(', ')}`
|
||||
}
|
||||
return { agent: 'copilot', state, configPath, managedHooksPresent, detail }
|
||||
}
|
||||
|
||||
export class CopilotHookService {
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'copilot',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Copilot hooks/orca.json'
|
||||
}
|
||||
}
|
||||
return buildStatus(readHooksJson(getConfigPath()))
|
||||
}
|
||||
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
let staleManagedPresent = false
|
||||
const managedEvents = new Set<string>(COPILOT_EVENTS)
|
||||
for (const eventName of COPILOT_EVENTS) {
|
||||
const command = getManagedCommand(scriptPath, eventName)
|
||||
const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : []
|
||||
const hasCurrentCommand = definitions.some((definition) =>
|
||||
definitionHasCurrentCommand(definition, command)
|
||||
)
|
||||
if (hasCurrentCommand) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(eventName)
|
||||
}
|
||||
}
|
||||
for (const [eventName, definitions] of Object.entries(config.hooks ?? {})) {
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const currentCommand = managedEvents.has(eventName)
|
||||
? getManagedCommand(scriptPath, eventName)
|
||||
: null
|
||||
staleManagedPresent =
|
||||
staleManagedPresent ||
|
||||
definitions.some((definition) =>
|
||||
definitionHasStaleManagedCommand(definition, currentCommand, isManagedCommand)
|
||||
)
|
||||
}
|
||||
|
||||
const managedHooksPresent = presentCount > 0 || staleManagedPresent
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
if (config.disableAllHooks === true && managedHooksPresent) {
|
||||
state = 'partial'
|
||||
detail = 'Managed Copilot hook file is disabled'
|
||||
} else if (staleManagedPresent) {
|
||||
state = 'partial'
|
||||
detail = 'Managed Copilot hook file contains stale entries'
|
||||
} else if (missing.length === 0) {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
} else if (presentCount === 0 && !staleManagedPresent) {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail = `Managed hook missing for events: ${missing.join(', ')}`
|
||||
}
|
||||
return { agent: 'copilot', state, configPath, managedHooksPresent, detail }
|
||||
// Why: main-thread twin — the sync one stays for the CLI process.
|
||||
async getStatusAsync(): Promise<AgentHookInstallStatus> {
|
||||
return buildStatus(await readHooksJsonAsync(getConfigPath()))
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
@@ -255,13 +271,7 @@ export class CopilotHookService {
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'copilot',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Copilot hooks/orca.json'
|
||||
}
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
@@ -297,6 +307,47 @@ export class CopilotHookService {
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async installAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = await readHooksJsonAsync(configPath)
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
const managedEvents = new Set<string>(COPILOT_EVENTS)
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
if (managedEvents.has(eventName) || !Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
|
||||
for (const eventName of COPILOT_EVENTS) {
|
||||
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand)
|
||||
nextHooks[eventName] = [
|
||||
...cleaned,
|
||||
getManagedHookDefinition(getManagedCommand(scriptPath, eventName))
|
||||
]
|
||||
}
|
||||
|
||||
config.version = 1
|
||||
delete config.disableAllHooks
|
||||
config.hooks = nextHooks
|
||||
await writeManagedScriptAsync(scriptPath, getManagedScript())
|
||||
await writeHooksJsonAsync(configPath, config)
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
const home = remoteHome.replace(/\/$/, '')
|
||||
const remoteConfigPath = `${home}/.copilot/hooks/orca.json`
|
||||
@@ -375,13 +426,7 @@ export class CopilotHookService {
|
||||
}
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'copilot',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Copilot hooks/orca.json'
|
||||
}
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
@@ -406,6 +451,38 @@ export class CopilotHookService {
|
||||
writeHooksJson(configPath, config)
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async removeAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
// Why: no existence probe — a missing file reads as an empty config, so the
|
||||
// loop below finds nothing to change and short-circuits identically.
|
||||
const config = await readHooksJsonAsync(configPath)
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
let changed = false
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
changed = changed || definitionsChanged(definitions, cleaned)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
config.hooks = nextHooks
|
||||
await writeHooksJsonAsync(configPath, config)
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
}
|
||||
|
||||
export const copilotHookService = new CopilotHookService()
|
||||
|
||||
+143
-103
@@ -5,26 +5,25 @@ import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared
|
||||
import {
|
||||
buildManagedCommandDefinition,
|
||||
createManagedCommandMatcher,
|
||||
buildWindowsAgentHookPostCommand,
|
||||
getSharedManagedScriptPath,
|
||||
readHooksJson,
|
||||
readHooksJsonAsync,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
writeHooksJson,
|
||||
writeHooksJsonAsync,
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
writeManagedScriptAsync,
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
import { getManagedScript } from './managed-hook-script'
|
||||
|
||||
// cursor-agent's declarative hooks surface (https://cursor.com/docs/hooks); subscribe to the minimum set for spinner + turn detection.
|
||||
// sessionStart/sessionEnd are NOT subscribed: they fire at process (not turn) boundaries and can race/reset the just-submitted turn's prompt cache.
|
||||
@@ -57,95 +56,64 @@ function getManagedCommand(scriptPath: string): string {
|
||||
: wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
// Why: source the endpoint file so a surviving PTY reaches the current server, not the prior Orca's coordinates (see claude/hook-service.ts).
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
buildWindowsAgentHookPostCommand('cursor'),
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
function parseErrorStatus(configPath: string): AgentHookInstallStatus {
|
||||
return {
|
||||
agent: 'cursor',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Cursor hooks.json'
|
||||
}
|
||||
}
|
||||
|
||||
function buildStatus(config: HooksConfig | null): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
// Why: sourcing refreshes PORT/TOKEN/ENV from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts).
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
'fi',
|
||||
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
// Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe (quotes/newlines); post raw payload as form fields instead.
|
||||
// Why: pipe payload via curl stdin (`payload@-`), not an inline arg, so large tool output stays off the command line (EDR false positives).
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
const command = getManagedCommand(scriptPath)
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
for (const eventName of CURSOR_EVENTS) {
|
||||
const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : []
|
||||
// Why: Cursor puts command directly on the definition (Claude nests under `hooks`); match both shapes.
|
||||
const hasCommand = definitions.some(
|
||||
(definition) =>
|
||||
definition.command === command ||
|
||||
(definition.hooks ?? []).some((hook) => hook.command === command)
|
||||
)
|
||||
if (hasCommand) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(eventName)
|
||||
}
|
||||
}
|
||||
const managedHooksPresent = presentCount > 0
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
if (missing.length === 0) {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
} else if (presentCount === 0) {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail = `Managed hook missing for events: ${missing.join(', ')}`
|
||||
}
|
||||
return { agent: 'cursor', state, configPath, managedHooksPresent, detail }
|
||||
}
|
||||
|
||||
export class CursorHookService {
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'cursor',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Cursor hooks.json'
|
||||
}
|
||||
}
|
||||
return buildStatus(readHooksJson(getConfigPath()))
|
||||
}
|
||||
|
||||
const command = getManagedCommand(scriptPath)
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
for (const eventName of CURSOR_EVENTS) {
|
||||
const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : []
|
||||
// Why: Cursor puts command directly on the definition (Claude nests under `hooks`); match both shapes.
|
||||
const hasCommand = definitions.some(
|
||||
(definition) =>
|
||||
definition.command === command ||
|
||||
(definition.hooks ?? []).some((hook) => hook.command === command)
|
||||
)
|
||||
if (hasCommand) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(eventName)
|
||||
}
|
||||
}
|
||||
const managedHooksPresent = presentCount > 0
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
if (missing.length === 0) {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
} else if (presentCount === 0) {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail = `Managed hook missing for events: ${missing.join(', ')}`
|
||||
}
|
||||
return { agent: 'cursor', state, configPath, managedHooksPresent, detail }
|
||||
// Why: main-thread twin — the sync one stays for the CLI process.
|
||||
async getStatusAsync(): Promise<AgentHookInstallStatus> {
|
||||
return buildStatus(await readHooksJsonAsync(getConfigPath()))
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
@@ -153,13 +121,7 @@ export class CursorHookService {
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'cursor',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Cursor hooks.json'
|
||||
}
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const command = getManagedCommand(scriptPath)
|
||||
@@ -211,6 +173,63 @@ export class CursorHookService {
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async installAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = await readHooksJsonAsync(configPath)
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const command = getManagedCommand(scriptPath)
|
||||
// Why: config.hooks is undefined on a fresh file with no prior hook install.
|
||||
const nextHooks = { ...config.hooks }
|
||||
const managedEvents = new Set<string>(CURSOR_EVENTS)
|
||||
|
||||
// Why: match by script filename (not exact command) so installs sweep stale entries from older builds or a different userData path.
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
|
||||
// Why: sweep managed entries from events we no longer subscribe to, else upgraded users keep firing stale hooks.
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
if (managedEvents.has(eventName)) {
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
// Also strip entries with the command at the top level (Cursor schema).
|
||||
const strippedCursorShape = cleaned.filter(
|
||||
(definition) => !isManagedCommand(definition.command as string | undefined)
|
||||
)
|
||||
if (strippedCursorShape.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = strippedCursorShape
|
||||
}
|
||||
}
|
||||
|
||||
for (const eventName of CURSOR_EVENTS) {
|
||||
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
|
||||
// Sweep both Claude-shaped (hooks[].command) and Cursor-shaped (definition.command) variants so installs converge on one entry.
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand).filter(
|
||||
(definition) => !isManagedCommand(definition.command as string | undefined)
|
||||
)
|
||||
// Why: Cursor's schema puts `command` directly on the definition (not under `hooks`); emit that shape.
|
||||
const definition: HookDefinition = buildManagedCommandDefinition(command)
|
||||
nextHooks[eventName] = [...cleaned, definition]
|
||||
}
|
||||
|
||||
// Why: cursor-agent's schema requires top-level `version: 1` (https://cursor.com/docs/hooks); keep any user-pinned value.
|
||||
const nextConfig: Record<string, unknown> = { ...config, hooks: nextHooks }
|
||||
if (nextConfig.version === undefined) {
|
||||
nextConfig.version = 1
|
||||
}
|
||||
await writeManagedScriptAsync(scriptPath, getManagedScript())
|
||||
await writeHooksJsonAsync(configPath, nextConfig)
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
|
||||
// Installs managed Cursor hooks on an SSH remote (POSIX-only). See docs/design/agent-status-over-ssh.md §8.
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.cursor/hooks.json`
|
||||
@@ -273,13 +292,7 @@ export class CursorHookService {
|
||||
const configPath = getConfigPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'cursor',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Cursor hooks.json'
|
||||
}
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
@@ -301,6 +314,33 @@ export class CursorHookService {
|
||||
writeHooksJson(configPath, nextConfig)
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async removeAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
const config = await readHooksJsonAsync(configPath)
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand).filter(
|
||||
(definition) => !isManagedCommand(definition.command as string | undefined)
|
||||
)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
const nextConfig = { ...config, hooks: nextHooks }
|
||||
await writeHooksJsonAsync(configPath, nextConfig)
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
}
|
||||
|
||||
export const cursorHookService = new CursorHookService()
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
// Why: the managed hook body is shared by the local install and the SSH remote
|
||||
// install, so it lives apart from the service that decides where to write it.
|
||||
export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
// Why: source the endpoint file so a surviving PTY reaches the current server, not the prior Orca's coordinates (see claude/hook-service.ts).
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
buildWindowsAgentHookPostCommand('cursor'),
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
// Why: sourcing refreshes PORT/TOKEN/ENV from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts).
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
'fi',
|
||||
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
// Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe (quotes/newlines); post raw payload as form fields instead.
|
||||
// Why: pipe payload via curl stdin (`payload@-`), not an inline arg, so large tool output stays off the command line (EDR false positives).
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Why: freeze #28. Devin's JSONC reader ran existsSync + readFileSync on the
|
||||
// Electron main thread; a stalled ~/.config/devin mount froze the whole app.
|
||||
const {
|
||||
syncFsCalls,
|
||||
hangReads,
|
||||
homedirMock,
|
||||
stalledRoot,
|
||||
STALLED_MOUNT_BLOCK_MS,
|
||||
TIMER_BUDGET_MS
|
||||
} = vi.hoisted(() => ({
|
||||
syncFsCalls: [] as string[],
|
||||
hangReads: { value: false },
|
||||
homedirMock: vi.fn<() => string>(),
|
||||
stalledRoot: { value: '' },
|
||||
STALLED_MOUNT_BLOCK_MS: 1_000,
|
||||
TIMER_BUDGET_MS: 500
|
||||
}))
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
const actual = (await vi.importActual('node:os')) as Record<string, unknown>
|
||||
return { ...actual, default: { ...actual, homedir: homedirMock }, homedir: homedirMock }
|
||||
})
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = (await vi.importActual('node:fs')) as Record<string, unknown>
|
||||
const wrapped: Record<string, unknown> = { ...actual }
|
||||
for (const [name, value] of Object.entries(actual)) {
|
||||
if (name.endsWith('Sync') && typeof value === 'function') {
|
||||
wrapped[name] = (...args: unknown[]) => {
|
||||
const target = String(args[0])
|
||||
syncFsCalls.push(`${name}:${target}`)
|
||||
if (hangReads.value && stalledRoot.value && target.startsWith(stalledRoot.value)) {
|
||||
// Why: a stalled SMB/NFS mount parks a *Sync call in the kernel with
|
||||
// the event loop still on the stack; Atomics.wait is the only faithful
|
||||
// way to reproduce that from JS.
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, STALLED_MOUNT_BLOCK_MS)
|
||||
}
|
||||
return (value as (...a: unknown[]) => unknown)(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...wrapped, default: wrapped }
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = (await vi.importActual('node:fs/promises')) as typeof FsPromises
|
||||
const readFile = (...args: Parameters<typeof actual.readFile>): Promise<unknown> =>
|
||||
hangReads.value ? new Promise<never>(() => {}) : actual.readFile(...args)
|
||||
return { ...actual, default: { ...actual, readFile }, readFile }
|
||||
})
|
||||
|
||||
import { readDevinHooksConfig } from './hook-config-json'
|
||||
import { DevinHookService } from './hook-service'
|
||||
import { getDevinConfigPath } from './hook-settings'
|
||||
|
||||
describe('Devin config read main-thread fs', () => {
|
||||
let homeDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
homeDir = mkdtempSync(join(tmpdir(), 'orca-devin-sync-fs-'))
|
||||
homedirMock.mockReturnValue(homeDir)
|
||||
vi.stubEnv('APPDATA', join(homeDir, 'AppData', 'Roaming'))
|
||||
hangReads.value = false
|
||||
stalledRoot.value = homeDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
hangReads.value = false
|
||||
stalledRoot.value = ''
|
||||
vi.unstubAllEnvs()
|
||||
vi.clearAllMocks()
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reads a present config without a synchronous fs syscall', async () => {
|
||||
const configPath = getDevinConfigPath()
|
||||
mkdirSync(join(configPath, '..'), { recursive: true })
|
||||
writeFileSync(configPath, '{ /* devin */ "hooks": {} }\n', 'utf-8')
|
||||
|
||||
syncFsCalls.length = 0
|
||||
const config = await readDevinHooksConfig(configPath)
|
||||
|
||||
expect(config).toEqual({ hooks: {} })
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('treats a missing config as empty without a synchronous fs syscall', async () => {
|
||||
syncFsCalls.length = 0
|
||||
const config = await readDevinHooksConfig(getDevinConfigPath())
|
||||
|
||||
expect(config).toEqual({})
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('reads status without a synchronous fs syscall', async () => {
|
||||
syncFsCalls.length = 0
|
||||
await new DevinHookService().getStatus()
|
||||
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('installs and removes without a single synchronous fs syscall', async () => {
|
||||
const service = new DevinHookService()
|
||||
|
||||
syncFsCalls.length = 0
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
expect(syncFsCalls).toEqual([])
|
||||
|
||||
syncFsCalls.length = 0
|
||||
expect((await service.remove()).state).toBe('not_installed')
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
// Why: the point of the async conversion is that a stalled Devin config dir
|
||||
// still lets timers/repaints/menus run. Arm the timer *before* the call — a
|
||||
// sync reader blocks inside getStatus(), so later lateness misses it.
|
||||
it('keeps the event loop alive while config.json reads hang', async () => {
|
||||
hangReads.value = true
|
||||
syncFsCalls.length = 0
|
||||
|
||||
const armedAt = Date.now()
|
||||
let observedDelayMs = -1
|
||||
const timer = new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
observedDelayMs = Date.now() - armedAt
|
||||
resolve()
|
||||
}, 5)
|
||||
})
|
||||
|
||||
const pending = Promise.resolve(new DevinHookService().getStatus())
|
||||
void pending.catch(() => {})
|
||||
await timer
|
||||
|
||||
expect(observedDelayMs).toBeGreaterThanOrEqual(0)
|
||||
expect(observedDelayMs).toBeLessThan(TIMER_BUDGET_MS)
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('serializes overlapping installs so config.json never writes out of order', async () => {
|
||||
const service = new DevinHookService()
|
||||
|
||||
const results = await Promise.all([service.install(), service.install()])
|
||||
|
||||
expect(results.map((status) => status.state)).toEqual(['installed', 'installed'])
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,7 @@ describe('readDevinHooksConfig', () => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('parses JSONC comments in Devin config', () => {
|
||||
it('parses JSONC comments in Devin config', async () => {
|
||||
const path = join(dir, 'config.json')
|
||||
writeFileSync(
|
||||
path,
|
||||
@@ -31,7 +31,7 @@ describe('readDevinHooksConfig', () => {
|
||||
`
|
||||
)
|
||||
|
||||
const config = readDevinHooksConfig(path)
|
||||
const config = await readDevinHooksConfig(path)
|
||||
|
||||
expect(config).toEqual({
|
||||
hooks: {},
|
||||
@@ -39,7 +39,7 @@ describe('readDevinHooksConfig', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects recovered partial parses from malformed JSONC', () => {
|
||||
it('rejects recovered partial parses from malformed JSONC', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
expect(parseDevinHooksConfigText('{"hooks": }', 'Devin config.json')).toBeNull()
|
||||
@@ -53,7 +53,7 @@ describe('readDevinHooksConfig', () => {
|
||||
})
|
||||
|
||||
describe('readConfigFromOrcaOverlapDetail', () => {
|
||||
it('warns when legacy read_config_from imports Claude', () => {
|
||||
it('warns when legacy read_config_from imports Claude', async () => {
|
||||
const detail = readConfigFromOrcaOverlapDetail({
|
||||
hooks: {},
|
||||
read_config_from: ['claude', 'custom']
|
||||
@@ -63,7 +63,7 @@ describe('readConfigFromOrcaOverlapDetail', () => {
|
||||
expect(detail).toContain('claude')
|
||||
})
|
||||
|
||||
it('warns when object-shaped read_config_from leaves Claude enabled', () => {
|
||||
it('warns when object-shaped read_config_from leaves Claude enabled', async () => {
|
||||
const detail = readConfigFromOrcaOverlapDetail({
|
||||
hooks: {},
|
||||
read_config_from: { claude: true }
|
||||
@@ -72,7 +72,7 @@ describe('readConfigFromOrcaOverlapDetail', () => {
|
||||
expect(detail).toContain('read_config_from.claude')
|
||||
})
|
||||
|
||||
it('warns when read_config_from is omitted because imports default to enabled', () => {
|
||||
it('warns when read_config_from is omitted because imports default to enabled', async () => {
|
||||
const detail = readConfigFromOrcaOverlapDetail({
|
||||
hooks: {}
|
||||
})
|
||||
@@ -80,7 +80,7 @@ describe('readConfigFromOrcaOverlapDetail', () => {
|
||||
expect(detail).toContain('read_config_from.claude')
|
||||
})
|
||||
|
||||
it('does not warn when read_config_from disables Claude', () => {
|
||||
it('does not warn when read_config_from disables Claude', async () => {
|
||||
const detail = readConfigFromOrcaOverlapDetail({
|
||||
hooks: {},
|
||||
read_config_from: { claude: false }
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { parse as parseJsonc, type ParseError } from 'jsonc-parser'
|
||||
import { isPlainObject, type HooksConfig } from '../agent-hooks/installer-utils'
|
||||
|
||||
/** Devin documents config.json as JSONC; stock JSON.parse rejects comments. */
|
||||
export function readDevinHooksConfig(configPath: string): HooksConfig | null {
|
||||
if (!existsSync(configPath)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Devin documents config.json as JSONC; stock JSON.parse rejects comments.
|
||||
* Async so a stalled ~/.config/devin mount cannot block the Electron main thread.
|
||||
*/
|
||||
export async function readDevinHooksConfig(configPath: string): Promise<HooksConfig | null> {
|
||||
let text: string
|
||||
try {
|
||||
const text = readFileSync(configPath, 'utf-8')
|
||||
return parseDevinHooksConfigText(text, 'Devin config.json')
|
||||
} catch {
|
||||
return null
|
||||
text = await readFile(configPath, 'utf-8')
|
||||
} catch (error) {
|
||||
// Absent config is an empty config; anything else is a real read failure.
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
return code === 'ENOENT' || code === 'ENOTDIR' ? {} : null
|
||||
}
|
||||
return parseDevinHooksConfigText(text, 'Devin config.json')
|
||||
}
|
||||
|
||||
export function parseDevinHooksConfigText(
|
||||
|
||||
@@ -38,8 +38,8 @@ describe('DevinHookService', () => {
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('installs managed hooks into user Devin config and posts to /hook/devin', () => {
|
||||
const status = new DevinHookService().install()
|
||||
it('installs managed hooks into user Devin config and posts to /hook/devin', async () => {
|
||||
const status = await new DevinHookService().install()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(status.agent).toBe('devin')
|
||||
@@ -71,7 +71,7 @@ describe('DevinHookService', () => {
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
})
|
||||
|
||||
it('preserves unrelated keys in Devin config when installing hooks', () => {
|
||||
it('preserves unrelated keys in Devin config when installing hooks', async () => {
|
||||
const configPath = getDevinConfigPath()
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
writeFileSync(
|
||||
@@ -79,7 +79,7 @@ describe('DevinHookService', () => {
|
||||
`${JSON.stringify({ permissions: { mode: 'normal' }, hooks: {} }, null, 2)}\n`
|
||||
)
|
||||
|
||||
new DevinHookService().install()
|
||||
await new DevinHookService().install()
|
||||
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf8')) as {
|
||||
permissions: { mode: string }
|
||||
@@ -89,7 +89,7 @@ describe('DevinHookService', () => {
|
||||
expect(config.hooks.UserPromptSubmit).toBeDefined()
|
||||
})
|
||||
|
||||
it('installs when Devin config uses JSONC comments', () => {
|
||||
it('installs when Devin config uses JSONC comments', async () => {
|
||||
const configPath = getDevinConfigPath()
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
writeFileSync(
|
||||
@@ -101,13 +101,13 @@ describe('DevinHookService', () => {
|
||||
`
|
||||
)
|
||||
|
||||
const status = new DevinHookService().install()
|
||||
const status = await new DevinHookService().install()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(JSON.parse(readFileSync(configPath, 'utf8')).hooks.UserPromptSubmit).toBeDefined()
|
||||
})
|
||||
|
||||
it('surfaces read_config_from overlap in status detail', () => {
|
||||
it('surfaces read_config_from overlap in status detail', async () => {
|
||||
const configPath = getDevinConfigPath()
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
writeFileSync(
|
||||
@@ -115,13 +115,13 @@ describe('DevinHookService', () => {
|
||||
`${JSON.stringify({ hooks: {}, read_config_from: { claude: true } }, null, 2)}\n`
|
||||
)
|
||||
|
||||
const status = new DevinHookService().getStatus()
|
||||
const status = await new DevinHookService().getStatus()
|
||||
|
||||
expect(status.detail).toContain('read_config_from')
|
||||
expect(status.detail).toContain('claude')
|
||||
})
|
||||
|
||||
it('uses a cmd.exe wrapper for managed hook command on Windows', () => {
|
||||
it('uses a cmd.exe wrapper for managed hook command on Windows', async () => {
|
||||
const previous = process.platform
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
try {
|
||||
@@ -137,23 +137,23 @@ describe('DevinHookService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('reports not_installed when Devin config has no managed hooks', () => {
|
||||
it('reports not_installed when Devin config has no managed hooks', async () => {
|
||||
const configPath = getDevinConfigPath()
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
writeFileSync(configPath, `${JSON.stringify({ hooks: {} }, null, 2)}\n`)
|
||||
|
||||
const status = new DevinHookService().getStatus()
|
||||
const status = await new DevinHookService().getStatus()
|
||||
|
||||
expect(status.state).toBe('not_installed')
|
||||
expect(status.managedHooksPresent).toBe(false)
|
||||
})
|
||||
|
||||
it('remove clears managed hook commands from Devin config', () => {
|
||||
it('remove clears managed hook commands from Devin config', async () => {
|
||||
const service = new DevinHookService()
|
||||
const installed = service.install()
|
||||
const installed = await service.install()
|
||||
expect(installed.state).toBe('installed')
|
||||
|
||||
const removed = service.remove()
|
||||
const removed = await service.remove()
|
||||
|
||||
expect(removed.state).toBe('not_installed')
|
||||
const configPath = getDevinConfigPath()
|
||||
@@ -166,7 +166,7 @@ describe('DevinHookService', () => {
|
||||
expect(commands.some((command) => command.includes('devin-hook'))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns partial status when some managed hooks are missing', () => {
|
||||
it('returns partial status when some managed hooks are missing', async () => {
|
||||
const configPath = getDevinConfigPath()
|
||||
const scriptPath = getDevinManagedScriptPath()
|
||||
const command = getDevinManagedCommand(scriptPath)
|
||||
@@ -180,7 +180,7 @@ describe('DevinHookService', () => {
|
||||
`${JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command }] }] } }, null, 2)}\n`
|
||||
)
|
||||
|
||||
const status = new DevinHookService().getStatus()
|
||||
const status = await new DevinHookService().getStatus()
|
||||
|
||||
expect(status.state).toBe('partial')
|
||||
expect(status.managedHooksPresent).toBe(true)
|
||||
@@ -193,7 +193,7 @@ describe('DevinHookService', () => {
|
||||
expect(status.detail).toContain('SessionEnd')
|
||||
})
|
||||
|
||||
it('uses APPDATA on Windows for Devin config path', () => {
|
||||
it('uses APPDATA on Windows for Devin config path', async () => {
|
||||
const previous = process.platform
|
||||
const previousAppData = process.env.APPDATA
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { SFTPWrapper } from 'ssh2'
|
||||
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
buildWindowsAgentHookPostCommand,
|
||||
writeHooksJson,
|
||||
writeManagedScript
|
||||
writeHooksJsonAsync,
|
||||
writeManagedScriptAsync
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
readTextFileRemote,
|
||||
@@ -78,11 +78,21 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// Why: sync fs serialized read-modify-write for free. Chain mutations so two
|
||||
// overlapping install/remove calls can never write config.json out of order.
|
||||
let pendingMutation: Promise<unknown> = Promise.resolve()
|
||||
|
||||
function serializeConfigMutation<T>(run: () => Promise<T>): Promise<T> {
|
||||
const next = pendingMutation.then(run, run)
|
||||
pendingMutation = next.catch(() => {})
|
||||
return next
|
||||
}
|
||||
|
||||
export class DevinHookService {
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
async getStatus(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getDevinConfigPath()
|
||||
const scriptPath = getDevinManagedScriptPath()
|
||||
const config = readDevinHooksConfig(configPath)
|
||||
const config = await readDevinHooksConfig(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'devin',
|
||||
@@ -132,25 +142,27 @@ export class DevinHookService {
|
||||
}
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
const configPath = getDevinConfigPath()
|
||||
const scriptPath = getDevinManagedScriptPath()
|
||||
const config = readDevinHooksConfig(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'devin',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Devin config.json'
|
||||
install(): Promise<AgentHookInstallStatus> {
|
||||
return serializeConfigMutation(async (): Promise<AgentHookInstallStatus> => {
|
||||
const configPath = getDevinConfigPath()
|
||||
const scriptPath = getDevinManagedScriptPath()
|
||||
const config = await readDevinHooksConfig(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'devin',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Devin config.json'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const command = getDevinManagedCommand(scriptPath)
|
||||
const nextConfig = applyDevinManagedHooks(config, command, getDevinManagedScriptFileName())
|
||||
writeManagedScript(scriptPath, getManagedScript())
|
||||
writeHooksJson(configPath, nextConfig)
|
||||
return this.getStatus()
|
||||
const command = getDevinManagedCommand(scriptPath)
|
||||
const nextConfig = applyDevinManagedHooks(config, command, getDevinManagedScriptFileName())
|
||||
await writeManagedScriptAsync(scriptPath, getManagedScript())
|
||||
await writeHooksJsonAsync(configPath, nextConfig)
|
||||
return this.getStatus()
|
||||
})
|
||||
}
|
||||
|
||||
// Why: install the Devin hook on the remote box (SFTP handle + resolved remote $HOME); POSIX-only by design.
|
||||
@@ -202,26 +214,28 @@ export class DevinHookService {
|
||||
}
|
||||
}
|
||||
|
||||
remove(): AgentHookInstallStatus {
|
||||
const configPath = getDevinConfigPath()
|
||||
const config = readDevinHooksConfig(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'devin',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Devin config.json'
|
||||
remove(): Promise<AgentHookInstallStatus> {
|
||||
return serializeConfigMutation(async (): Promise<AgentHookInstallStatus> => {
|
||||
const configPath = getDevinConfigPath()
|
||||
const config = await readDevinHooksConfig(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'devin',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Devin config.json'
|
||||
}
|
||||
}
|
||||
}
|
||||
const { config: nextConfig, changed } = removeDevinManagedHooks(
|
||||
config,
|
||||
getDevinManagedScriptFileName()
|
||||
)
|
||||
if (changed) {
|
||||
writeHooksJson(configPath, nextConfig)
|
||||
}
|
||||
return this.getStatus()
|
||||
const { config: nextConfig, changed } = removeDevinManagedHooks(
|
||||
config,
|
||||
getDevinManagedScriptFileName()
|
||||
)
|
||||
if (changed) {
|
||||
await writeHooksJsonAsync(configPath, nextConfig)
|
||||
}
|
||||
return this.getStatus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+117
-122
@@ -5,14 +5,16 @@ import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared
|
||||
import {
|
||||
buildManagedCommandHook,
|
||||
createManagedCommandMatcher,
|
||||
buildWindowsAgentHookPostCommand,
|
||||
getSharedManagedScriptPath,
|
||||
readHooksJson,
|
||||
readHooksJsonAsync,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
writeHooksJson,
|
||||
writeHooksJsonAsync,
|
||||
writeManagedScript,
|
||||
writeManagedScriptAsync,
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
@@ -21,11 +23,7 @@ import {
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
import { getManagedScript } from './managed-hook-script'
|
||||
|
||||
// Why: SessionStart is installed (not just listened for) so that resuming a
|
||||
// droid session via `droid --resume` resets the per-pane prompt/tool caches
|
||||
@@ -76,49 +74,6 @@ function getManagedCommand(scriptPath: string): string {
|
||||
: wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
buildWindowsAgentHookPostCommand('droid'),
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
'fi',
|
||||
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/droid" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// Why: local install() and installRemote() must produce byte-identical Factory
|
||||
// settings.json hook trees; sharing this mutation is what keeps the SSH install
|
||||
// from drifting off the local one (the exact class of bug that left Droid with
|
||||
@@ -158,68 +113,80 @@ function buildInstalledDroidConfig(
|
||||
config.hooks = nextHooks
|
||||
}
|
||||
|
||||
function parseErrorStatus(configPath: string): AgentHookInstallStatus {
|
||||
return {
|
||||
agent: 'droid',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Factory settings.json'
|
||||
}
|
||||
}
|
||||
|
||||
function buildStatus(config: HooksConfig | null): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const command = getManagedCommand(scriptPath)
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
for (const event of DROID_EVENTS) {
|
||||
const definitions = Array.isArray(config.hooks?.[event.eventName])
|
||||
? config.hooks![event.eventName]!
|
||||
: []
|
||||
const hasCommand = definitions.some((definition) =>
|
||||
(definition.hooks ?? []).some((hook) => hook.command === command)
|
||||
)
|
||||
if (hasCommand) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(event.eventName)
|
||||
}
|
||||
}
|
||||
const managedHooksPresent = presentCount > 0
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
// Why: surface hooksDisabled across every branch — without this, a
|
||||
// disabled-AND-partially-installed (or disabled-AND-not-installed) state
|
||||
// would silently swallow the disabled flag and the user would think a
|
||||
// re-install fixed it.
|
||||
if (missing.length === 0) {
|
||||
if (config.hooksDisabled === true) {
|
||||
state = 'partial'
|
||||
detail = 'Droid hooks are disabled in Factory settings'
|
||||
} else {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
}
|
||||
} else if (presentCount === 0) {
|
||||
if (config.hooksDisabled === true) {
|
||||
state = 'partial'
|
||||
detail = 'Droid hooks are disabled in Factory settings'
|
||||
} else {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
}
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail =
|
||||
config.hooksDisabled === true
|
||||
? `Droid hooks are disabled in Factory settings; managed hook missing for events: ${missing.join(', ')}`
|
||||
: `Managed hook missing for events: ${missing.join(', ')}`
|
||||
}
|
||||
return { agent: 'droid', state, configPath, managedHooksPresent, detail }
|
||||
}
|
||||
|
||||
export class DroidHookService {
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'droid',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Factory settings.json'
|
||||
}
|
||||
}
|
||||
return buildStatus(readHooksJson(getConfigPath()))
|
||||
}
|
||||
|
||||
const command = getManagedCommand(scriptPath)
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
for (const event of DROID_EVENTS) {
|
||||
const definitions = Array.isArray(config.hooks?.[event.eventName])
|
||||
? config.hooks![event.eventName]!
|
||||
: []
|
||||
const hasCommand = definitions.some((definition) =>
|
||||
(definition.hooks ?? []).some((hook) => hook.command === command)
|
||||
)
|
||||
if (hasCommand) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(event.eventName)
|
||||
}
|
||||
}
|
||||
const managedHooksPresent = presentCount > 0
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
// Why: surface hooksDisabled across every branch — without this, a
|
||||
// disabled-AND-partially-installed (or disabled-AND-not-installed) state
|
||||
// would silently swallow the disabled flag and the user would think a
|
||||
// re-install fixed it.
|
||||
if (missing.length === 0) {
|
||||
if (config.hooksDisabled === true) {
|
||||
state = 'partial'
|
||||
detail = 'Droid hooks are disabled in Factory settings'
|
||||
} else {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
}
|
||||
} else if (presentCount === 0) {
|
||||
if (config.hooksDisabled === true) {
|
||||
state = 'partial'
|
||||
detail = 'Droid hooks are disabled in Factory settings'
|
||||
} else {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
}
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail =
|
||||
config.hooksDisabled === true
|
||||
? `Droid hooks are disabled in Factory settings; managed hook missing for events: ${missing.join(', ')}`
|
||||
: `Managed hook missing for events: ${missing.join(', ')}`
|
||||
}
|
||||
return { agent: 'droid', state, configPath, managedHooksPresent, detail }
|
||||
// Why: main-thread twin — the sync one stays for the CLI process.
|
||||
async getStatusAsync(): Promise<AgentHookInstallStatus> {
|
||||
return buildStatus(await readHooksJsonAsync(getConfigPath()))
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
@@ -227,13 +194,7 @@ export class DroidHookService {
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'droid',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Factory settings.json'
|
||||
}
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
buildInstalledDroidConfig(config, getManagedCommand(scriptPath), getManagedScriptFileName())
|
||||
@@ -242,6 +203,20 @@ export class DroidHookService {
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async installAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = await readHooksJsonAsync(configPath)
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
buildInstalledDroidConfig(config, getManagedCommand(scriptPath), getManagedScriptFileName())
|
||||
await writeManagedScriptAsync(scriptPath, getManagedScript())
|
||||
await writeHooksJsonAsync(configPath, config)
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
|
||||
// Why: SSH remotes run the Droid CLI on the remote host, so its hook config
|
||||
// and managed script must be written into the remote ~/.factory + ~/.orca via
|
||||
// SFTP. Without this, Droid never fires the managed hook over SSH and its
|
||||
@@ -291,13 +266,7 @@ export class DroidHookService {
|
||||
const configPath = getConfigPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'droid',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Factory settings.json'
|
||||
}
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
@@ -318,6 +287,32 @@ export class DroidHookService {
|
||||
writeHooksJson(configPath, config)
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async removeAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
const config = await readHooksJsonAsync(configPath)
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
|
||||
config.hooks = nextHooks
|
||||
await writeHooksJsonAsync(configPath, config)
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
}
|
||||
|
||||
export const droidHookService = new DroidHookService()
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
// Why: the managed hook body is shared by the local install and the SSH remote
|
||||
// install, so it lives apart from the service that decides where to write it.
|
||||
export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
buildWindowsAgentHookPostCommand('droid'),
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
'fi',
|
||||
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/droid" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
+141
-107
@@ -9,12 +9,16 @@ import {
|
||||
getSharedManagedScriptPath,
|
||||
MANAGED_HOOK_TIMEOUT_MILLISECONDS,
|
||||
readHooksJson,
|
||||
readHooksJsonAsync,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
writeHooksJson,
|
||||
writeHooksJsonAsync,
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
writeManagedScriptAsync,
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
@@ -96,49 +100,116 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function parseErrorStatus(configPath: string): AgentHookInstallStatus {
|
||||
return {
|
||||
agent: 'gemini',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Gemini settings.json'
|
||||
}
|
||||
}
|
||||
|
||||
function buildStatus(config: HooksConfig | null): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const command = getManagedCommand(getManagedScriptPath())
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
for (const eventName of GEMINI_EVENTS) {
|
||||
const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : []
|
||||
const hasCommand = definitions.some((definition) =>
|
||||
(definition.hooks ?? []).some((hook) => hook.command === command)
|
||||
)
|
||||
if (hasCommand) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(eventName)
|
||||
}
|
||||
}
|
||||
const managedHooksPresent = presentCount > 0
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
if (missing.length === 0) {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
} else if (presentCount === 0) {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail = `Managed hook missing for events: ${missing.join(', ')}`
|
||||
}
|
||||
return { agent: 'gemini', state, configPath, managedHooksPresent, detail }
|
||||
}
|
||||
|
||||
function applyManagedHooks(config: HooksConfig, command: string, scriptFileName: string): void {
|
||||
const nextHooks = { ...config.hooks }
|
||||
|
||||
// Why: match by filename not exact command, so installs sweep stale entries instead of duplicating them.
|
||||
const isManagedCommand = createManagedCommandMatcher(scriptFileName)
|
||||
|
||||
const managedEvents = new Set<string>(GEMINI_EVENTS)
|
||||
|
||||
// Why: sweep managed entries from dropped event buckets so stale hooks (e.g. PreToolUse) don't keep firing.
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
if (managedEvents.has(eventName)) {
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
|
||||
for (const eventName of GEMINI_EVENTS) {
|
||||
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand)
|
||||
const definition: HookDefinition = {
|
||||
// Why: Gemini's hook `timeout` unit is milliseconds, unlike Claude/Codex.
|
||||
hooks: [buildManagedCommandHook(command, MANAGED_HOOK_TIMEOUT_MILLISECONDS)]
|
||||
}
|
||||
nextHooks[eventName] = [...cleaned, definition]
|
||||
}
|
||||
|
||||
config.hooks = nextHooks
|
||||
}
|
||||
|
||||
function stripManagedHooks(config: HooksConfig): void {
|
||||
const nextHooks = { ...config.hooks }
|
||||
// Why: match by filename so remove() sweeps stale entries even after the script path moved.
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
// Why: fail open on malformed (non-array) entries so a broken user config never blocks uninstall.
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
config.hooks = nextHooks
|
||||
}
|
||||
|
||||
export class GeminiHookService {
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'gemini',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Gemini settings.json'
|
||||
}
|
||||
}
|
||||
return buildStatus(readHooksJson(getConfigPath()))
|
||||
}
|
||||
|
||||
const command = getManagedCommand(scriptPath)
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
for (const eventName of GEMINI_EVENTS) {
|
||||
const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : []
|
||||
const hasCommand = definitions.some((definition) =>
|
||||
(definition.hooks ?? []).some((hook) => hook.command === command)
|
||||
)
|
||||
if (hasCommand) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(eventName)
|
||||
}
|
||||
}
|
||||
const managedHooksPresent = presentCount > 0
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
if (missing.length === 0) {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
} else if (presentCount === 0) {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail = `Managed hook missing for events: ${missing.join(', ')}`
|
||||
}
|
||||
return { agent: 'gemini', state, configPath, managedHooksPresent, detail }
|
||||
// Why: main-thread twin — the sync one stays for the CLI process.
|
||||
async getStatusAsync(): Promise<AgentHookInstallStatus> {
|
||||
return buildStatus(await readHooksJsonAsync(getConfigPath()))
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
@@ -146,55 +217,29 @@ export class GeminiHookService {
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'gemini',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Gemini settings.json'
|
||||
}
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const command = getManagedCommand(scriptPath)
|
||||
const nextHooks = { ...config.hooks }
|
||||
|
||||
// Why: match by filename not exact command, so installs sweep stale entries instead of duplicating them.
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
|
||||
const managedEvents = new Set<string>(GEMINI_EVENTS)
|
||||
|
||||
// Why: sweep managed entries from dropped event buckets so stale hooks (e.g. PreToolUse) don't keep firing.
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
if (managedEvents.has(eventName)) {
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
|
||||
for (const eventName of GEMINI_EVENTS) {
|
||||
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand)
|
||||
const definition: HookDefinition = {
|
||||
// Why: Gemini's hook `timeout` unit is milliseconds, unlike Claude/Codex.
|
||||
hooks: [buildManagedCommandHook(command, MANAGED_HOOK_TIMEOUT_MILLISECONDS)]
|
||||
}
|
||||
nextHooks[eventName] = [...cleaned, definition]
|
||||
}
|
||||
|
||||
config.hooks = nextHooks
|
||||
applyManagedHooks(config, getManagedCommand(scriptPath), getManagedScriptFileName())
|
||||
writeManagedScript(scriptPath, getManagedScript())
|
||||
writeHooksJson(configPath, config)
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async installAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = await readHooksJsonAsync(configPath)
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
applyManagedHooks(config, getManagedCommand(scriptPath), getManagedScriptFileName())
|
||||
await writeManagedScriptAsync(scriptPath, getManagedScript())
|
||||
await writeHooksJsonAsync(configPath, config)
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
|
||||
// POSIX-only remote install mirroring ClaudeHookService.installRemote. See docs/design/agent-status-over-ssh.md §8.
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.gemini/settings.json`
|
||||
@@ -270,34 +315,23 @@ export class GeminiHookService {
|
||||
const configPath = getConfigPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'gemini',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Gemini settings.json'
|
||||
}
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
// Why: match by filename so remove() sweeps stale entries even after the script path moved.
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
// Why: fail open on malformed (non-array) entries so a broken user config never blocks uninstall.
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
config.hooks = nextHooks
|
||||
stripManagedHooks(config)
|
||||
writeHooksJson(configPath, config)
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async removeAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
const config = await readHooksJsonAsync(configPath)
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
stripManagedHooks(config)
|
||||
await writeHooksJsonAsync(configPath, config)
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
}
|
||||
|
||||
export const geminiHookService = new GeminiHookService()
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// Why: `git:status` is polled on a timer, so the shared-directory lookup it performs was the
|
||||
// highest-frequency `orca.yaml` read in the app. On a stalled repo mount the sync read froze
|
||||
// the Electron main thread on a timer, with no user action (freeze #9). These tests pin that
|
||||
// the async path never touches sync fs, single-flights concurrent polls, and serves the last
|
||||
// known value instead of queueing behind a read that may never return.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { syncCalls, loadHooksAsyncMock, loadHooksMock } = vi.hoisted(() => ({
|
||||
syncCalls: [] as string[],
|
||||
loadHooksAsyncMock: vi.fn(),
|
||||
loadHooksMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', () => {
|
||||
const record =
|
||||
(name: string) =>
|
||||
(...args: unknown[]) => {
|
||||
syncCalls.push(`${name}:${String(args[0])}`)
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
return {
|
||||
existsSync: vi.fn(record('existsSync')),
|
||||
readFileSync: vi.fn(record('readFileSync')),
|
||||
statSync: vi.fn(record('statSync')),
|
||||
lstatSync: vi.fn(record('lstatSync'))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
loadHooks: loadHooksMock,
|
||||
loadHooksAsync: loadHooksAsyncMock
|
||||
}))
|
||||
|
||||
const REPO = '/mnt/stalled-nas/repo'
|
||||
|
||||
const hooksWith = (...directories: string[]): { worktree: { sharedDirectories: string[] } } => ({
|
||||
worktree: { sharedDirectories: directories }
|
||||
})
|
||||
|
||||
let clearCache: () => void
|
||||
|
||||
beforeEach(async () => {
|
||||
syncCalls.length = 0
|
||||
loadHooksAsyncMock.mockReset()
|
||||
loadHooksMock.mockReset()
|
||||
;({ clearConfiguredWorktreeSharedDirectoriesCacheForTests: clearCache } =
|
||||
await import('./worktree-shared-directories'))
|
||||
clearCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearCache()
|
||||
})
|
||||
|
||||
describe('polled shared-directory lookup never blocks the main thread', () => {
|
||||
it('resolves the status-poll link paths with no sync fs call', async () => {
|
||||
loadHooksAsyncMock.mockResolvedValue(hooksWith('node_modules'))
|
||||
|
||||
const { getWorktreeSharedLinkPathsAsync } = await import('./worktree-shared-directories')
|
||||
await expect(
|
||||
getWorktreeSharedLinkPathsAsync({ path: REPO, symlinkPaths: ['.cache'] })
|
||||
).resolves.toEqual(['.cache', 'node_modules'])
|
||||
|
||||
expect(syncCalls).toEqual([])
|
||||
expect(loadHooksMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('single-flights concurrent pollers into one orca.yaml read', async () => {
|
||||
let resolveRead: (hooks: unknown) => void = () => {}
|
||||
loadHooksAsyncMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const { getConfiguredWorktreeSharedDirectoriesAsync } =
|
||||
await import('./worktree-shared-directories')
|
||||
const polls = [
|
||||
getConfiguredWorktreeSharedDirectoriesAsync(REPO),
|
||||
getConfiguredWorktreeSharedDirectoriesAsync(REPO),
|
||||
getConfiguredWorktreeSharedDirectoriesAsync(REPO)
|
||||
]
|
||||
resolveRead(hooksWith('node_modules'))
|
||||
|
||||
await expect(Promise.all(polls)).resolves.toEqual([
|
||||
['node_modules'],
|
||||
['node_modules'],
|
||||
['node_modules']
|
||||
])
|
||||
expect(loadHooksAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(syncCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('serves the last known value while a stalled refresh is in flight', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
loadHooksAsyncMock.mockResolvedValueOnce(hooksWith('node_modules'))
|
||||
const { getConfiguredWorktreeSharedDirectoriesAsync } =
|
||||
await import('./worktree-shared-directories')
|
||||
await expect(getConfiguredWorktreeSharedDirectoriesAsync(REPO)).resolves.toEqual([
|
||||
'node_modules'
|
||||
])
|
||||
|
||||
// The mount goes away: every read after the TTL expires hangs forever.
|
||||
loadHooksAsyncMock.mockReturnValue(new Promise(() => {}))
|
||||
vi.advanceTimersByTime(30_001)
|
||||
|
||||
for (let poll = 0; poll < 3; poll++) {
|
||||
await expect(getConfiguredWorktreeSharedDirectoriesAsync(REPO)).resolves.toEqual([
|
||||
'node_modules'
|
||||
])
|
||||
}
|
||||
// One stuck read, not one per poll.
|
||||
expect(loadHooksAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(syncCalls).toEqual([])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps timers firing while the first-ever read is stuck forever', async () => {
|
||||
loadHooksAsyncMock.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const { getWorktreeSharedLinkPathsAsync } = await import('./worktree-shared-directories')
|
||||
let settled = false
|
||||
void getWorktreeSharedLinkPathsAsync({ path: REPO }).then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
const timerFired = await new Promise<boolean>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(true)
|
||||
}, 1)
|
||||
})
|
||||
|
||||
expect(timerFired).toBe(true)
|
||||
expect(settled).toBe(false)
|
||||
expect(syncCalls).toEqual([])
|
||||
})
|
||||
|
||||
// Why: the async twin cannot help the sync callers that remain (worktree removal, runtime,
|
||||
// cleanup evidence). When the mount dies the refresh never settles, so the entry stays
|
||||
// expired forever — and an expired entry used to send those callers straight into the
|
||||
// synchronous read on the same dead mount, i.e. the freeze the async twin was added to stop.
|
||||
it('serves sync callers the last known value while a refresh is stuck on the mount', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
loadHooksAsyncMock.mockResolvedValueOnce(hooksWith('node_modules'))
|
||||
const {
|
||||
getConfiguredWorktreeSharedDirectories,
|
||||
getConfiguredWorktreeSharedDirectoriesAsync,
|
||||
getWorktreeSharedLinkPaths
|
||||
} = await import('./worktree-shared-directories')
|
||||
await getConfiguredWorktreeSharedDirectoriesAsync(REPO)
|
||||
|
||||
loadHooksAsyncMock.mockReturnValue(new Promise(() => {}))
|
||||
vi.advanceTimersByTime(30_001)
|
||||
void getConfiguredWorktreeSharedDirectoriesAsync(REPO)
|
||||
|
||||
expect(getConfiguredWorktreeSharedDirectories(REPO)).toEqual(['node_modules'])
|
||||
expect(getWorktreeSharedLinkPaths({ path: REPO, symlinkPaths: ['.cache'] })).toEqual([
|
||||
'.cache',
|
||||
'node_modules'
|
||||
])
|
||||
// The sync read is the block: it must never be reached while the mount is unanswered.
|
||||
expect(loadHooksMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
// Why: the refresh map only orders async reads against each other. The sync twin writes the
|
||||
// same cache entry without consulting it, so a read that started earlier could land later,
|
||||
// overwrite the newer value, and stamp a fresh 30s TTL on the stale one.
|
||||
it('does not let a slow refresh clobber a value the sync twin published after it started', async () => {
|
||||
let resolveRead: (hooks: unknown) => void = () => {}
|
||||
loadHooksAsyncMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveRead = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const { getConfiguredWorktreeSharedDirectories, getConfiguredWorktreeSharedDirectoriesAsync } =
|
||||
await import('./worktree-shared-directories')
|
||||
const poll = getConfiguredWorktreeSharedDirectoriesAsync(REPO)
|
||||
|
||||
loadHooksMock.mockReturnValue(hooksWith('.venv'))
|
||||
expect(getConfiguredWorktreeSharedDirectories(REPO)).toEqual(['.venv'])
|
||||
|
||||
resolveRead(hooksWith('node_modules'))
|
||||
await expect(poll).resolves.toEqual(['.venv'])
|
||||
expect(getConfiguredWorktreeSharedDirectories(REPO)).toEqual(['.venv'])
|
||||
})
|
||||
|
||||
it('refreshes again after a failed read instead of caching the failure', async () => {
|
||||
loadHooksAsyncMock.mockRejectedValueOnce(new Error('EIO'))
|
||||
const { getConfiguredWorktreeSharedDirectoriesAsync } =
|
||||
await import('./worktree-shared-directories')
|
||||
await expect(getConfiguredWorktreeSharedDirectoriesAsync(REPO)).resolves.toEqual([])
|
||||
|
||||
loadHooksAsyncMock.mockResolvedValueOnce(hooksWith('node_modules'))
|
||||
await expect(getConfiguredWorktreeSharedDirectoriesAsync(REPO)).resolves.toEqual([
|
||||
'node_modules'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -2,17 +2,46 @@ import { stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { checkIgnoredPaths } from './check-ignored-paths'
|
||||
import type { GitRuntimeOptions } from './git-runtime-options'
|
||||
import { loadHooks } from '../hooks'
|
||||
import { loadHooks, loadHooksAsync } from '../hooks'
|
||||
import type { Repo } from '../../shared/types'
|
||||
|
||||
// Why: a fresh worktree has no node_modules/.cache, and copying them is slow and
|
||||
// duplicates disk; `orca.yaml` names the ones every worktree should share instead.
|
||||
|
||||
const CONFIGURED_SHARED_DIRECTORIES_CACHE_TTL_MS = 30_000
|
||||
const configuredSharedDirectoriesByRepoPath = new Map<
|
||||
string,
|
||||
{ directories: string[]; expiresAt: number }
|
||||
>()
|
||||
|
||||
type SharedDirectoriesCacheEntry = {
|
||||
value: { directories: readonly string[]; expiresAt: number } | null
|
||||
/** Bumped on every publish, so a read that started earlier can tell a newer value landed
|
||||
* while it was in flight and must not be clobbered. */
|
||||
revision: number
|
||||
/** Non-null while an async read is in flight. A stalled mount can leave it set forever —
|
||||
* which is exactly when sync callers must be served the stale value instead of blocking. */
|
||||
refresh: Promise<readonly string[]> | null
|
||||
}
|
||||
|
||||
const sharedDirectoriesByRepoPath = new Map<string, SharedDirectoriesCacheEntry>()
|
||||
|
||||
function getSharedDirectoriesCacheEntry(repoPath: string): SharedDirectoriesCacheEntry {
|
||||
const existing = sharedDirectoriesByRepoPath.get(repoPath)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const created: SharedDirectoriesCacheEntry = { value: null, revision: 0, refresh: null }
|
||||
sharedDirectoriesByRepoPath.set(repoPath, created)
|
||||
return created
|
||||
}
|
||||
|
||||
/** Publishes into the entry object, not the map, so a refresh still in flight when the cache is
|
||||
* cleared writes to its own detached entry instead of resurrecting a stale one. */
|
||||
function publishSharedDirectories(
|
||||
entry: SharedDirectoriesCacheEntry,
|
||||
directories: readonly string[]
|
||||
): readonly string[] {
|
||||
entry.value = { directories, expiresAt: Date.now() + CONFIGURED_SHARED_DIRECTORIES_CACHE_TTL_MS }
|
||||
entry.revision++
|
||||
return directories
|
||||
}
|
||||
|
||||
/** The configured `worktree.sharedDirectories` names, before any existence or
|
||||
* gitignore filtering.
|
||||
@@ -24,24 +53,71 @@ const configuredSharedDirectoriesByRepoPath = new Map<
|
||||
*
|
||||
* `readonly` because this is the cached array itself: a mutating caller would
|
||||
* corrupt every later read for the rest of the TTL. Copying on return would fix
|
||||
* that too, but this runs on the status-polling path — the type costs nothing. */
|
||||
* that too, but this runs on the status-polling path — the type costs nothing.
|
||||
*
|
||||
* An expired value with a read still in flight is served anyway: a read stalled on a dead
|
||||
* mount never settles, so the entry would stay expired forever and push every sync caller
|
||||
* onto exactly the blocking read the async twin exists to avoid. */
|
||||
export function getConfiguredWorktreeSharedDirectories(repoPath: string): readonly string[] {
|
||||
const cached = configuredSharedDirectoriesByRepoPath.get(repoPath)
|
||||
const now = Date.now()
|
||||
if (cached && cached.expiresAt > now) {
|
||||
const entry = sharedDirectoriesByRepoPath.get(repoPath)
|
||||
if (entry?.value && (entry.value.expiresAt > Date.now() || entry.refresh !== null)) {
|
||||
return entry.value.directories
|
||||
}
|
||||
return publishSharedDirectories(
|
||||
getSharedDirectoriesCacheEntry(repoPath),
|
||||
loadHooks(repoPath)?.worktree?.sharedDirectories ?? []
|
||||
)
|
||||
}
|
||||
|
||||
/** Single-flight: concurrent status polls share one `orca.yaml` read per repo. */
|
||||
function refreshConfiguredWorktreeSharedDirectories(repoPath: string): Promise<readonly string[]> {
|
||||
const entry = getSharedDirectoriesCacheEntry(repoPath)
|
||||
if (entry.refresh) {
|
||||
return entry.refresh
|
||||
}
|
||||
|
||||
const startedAtRevision = entry.revision
|
||||
const refresh: Promise<readonly string[]> = loadHooksAsync(repoPath)
|
||||
.then((hooks) => {
|
||||
const directories = hooks?.worktree?.sharedDirectories ?? []
|
||||
// Why: the sync twin publishes into the same entry. A read that started before it must
|
||||
// not overwrite the newer value, nor stamp a fresh TTL onto its own stale one.
|
||||
if (entry.revision !== startedAtRevision) {
|
||||
return entry.value?.directories ?? directories
|
||||
}
|
||||
return publishSharedDirectories(entry, directories)
|
||||
})
|
||||
.catch(() => entry.value?.directories ?? [])
|
||||
.finally(() => {
|
||||
if (entry.refresh === refresh) {
|
||||
entry.refresh = null
|
||||
}
|
||||
})
|
||||
|
||||
entry.refresh = refresh
|
||||
return refresh
|
||||
}
|
||||
|
||||
/** Async twin of `getConfiguredWorktreeSharedDirectories`, for the polled status path.
|
||||
*
|
||||
* A stalled repo mount can hold the `orca.yaml` read for a minute; while one is in
|
||||
* flight every later poll is served the last known value instead of queueing behind
|
||||
* it, so status keeps flowing and only the first-ever read can wait. */
|
||||
export async function getConfiguredWorktreeSharedDirectoriesAsync(
|
||||
repoPath: string
|
||||
): Promise<readonly string[]> {
|
||||
const cached = sharedDirectoriesByRepoPath.get(repoPath)?.value
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.directories
|
||||
}
|
||||
const configured = loadHooks(repoPath)?.worktree?.sharedDirectories ?? []
|
||||
configuredSharedDirectoriesByRepoPath.set(repoPath, {
|
||||
directories: configured,
|
||||
expiresAt: now + CONFIGURED_SHARED_DIRECTORIES_CACHE_TTL_MS
|
||||
})
|
||||
return configured
|
||||
|
||||
const refresh = refreshConfiguredWorktreeSharedDirectories(repoPath)
|
||||
return cached ? cached.directories : refresh
|
||||
}
|
||||
|
||||
/** Reset the process cache between tests. */
|
||||
export function clearConfiguredWorktreeSharedDirectoriesCacheForTests(): void {
|
||||
configuredSharedDirectoriesByRepoPath.clear()
|
||||
sharedDirectoriesByRepoPath.clear()
|
||||
}
|
||||
|
||||
/** Every path Orca may have symlinked into a worktree: the per-user Worktree
|
||||
@@ -56,6 +132,14 @@ export function getWorktreeSharedLinkPaths(repo: Pick<Repo, 'path' | 'symlinkPat
|
||||
)
|
||||
}
|
||||
|
||||
/** Async twin of `getWorktreeSharedLinkPaths` for callers that can await. */
|
||||
export async function getWorktreeSharedLinkPathsAsync(
|
||||
repo: Pick<Repo, 'path' | 'symlinkPaths'>
|
||||
): Promise<string[]> {
|
||||
const configured = await getConfiguredWorktreeSharedDirectoriesAsync(repo.path)
|
||||
return Array.from(new Set([...(repo.symlinkPaths ?? []), ...configured]))
|
||||
}
|
||||
|
||||
/** Resolve `worktree.sharedDirectories` from the repo-root `orca.yaml` to
|
||||
* concrete repo-relative directories to symlink into a new worktree.
|
||||
*
|
||||
@@ -70,7 +154,7 @@ export async function resolveWorktreeSharedDirectories(
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const configured = loadHooks(repoPath)?.worktree?.sharedDirectories ?? []
|
||||
const configured = (await loadHooksAsync(repoPath))?.worktree?.sharedDirectories ?? []
|
||||
if (configured.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getGrokAccountStatus } from './status'
|
||||
import { isGrokAccessTokenFresh, readGrokAuthSession } from '../rate-limits/grok-auth'
|
||||
|
||||
vi.mock('../rate-limits/grok-auth', () => ({
|
||||
isGrokAccessTokenFresh: vi.fn(),
|
||||
readGrokAuthSession: vi.fn()
|
||||
}))
|
||||
|
||||
describe('getGrokAccountStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(isGrokAccessTokenFresh).mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('reports unsigned status when the Grok auth file is missing', () => {
|
||||
vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' })
|
||||
|
||||
expect(getGrokAccountStatus()).toEqual({
|
||||
signedIn: false,
|
||||
email: null,
|
||||
teamId: null,
|
||||
tokenFresh: false,
|
||||
error: null
|
||||
})
|
||||
})
|
||||
|
||||
it('reports auth read errors without exposing token fields', () => {
|
||||
vi.mocked(readGrokAuthSession).mockReturnValue({
|
||||
status: 'error',
|
||||
error: 'Grok auth file is invalid'
|
||||
})
|
||||
|
||||
expect(getGrokAccountStatus()).toEqual({
|
||||
signedIn: false,
|
||||
email: null,
|
||||
teamId: null,
|
||||
tokenFresh: false,
|
||||
error: 'Grok auth file is invalid'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns non-secret signed-in metadata and freshness', () => {
|
||||
vi.mocked(readGrokAuthSession).mockReturnValue({
|
||||
status: 'ok',
|
||||
session: {
|
||||
accessToken: 'secret-token',
|
||||
email: 'dev@example.com',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
expiresAtMs: null,
|
||||
oidcClientId: 'client-1'
|
||||
}
|
||||
})
|
||||
vi.mocked(isGrokAccessTokenFresh).mockReturnValue(false)
|
||||
|
||||
const status = getGrokAccountStatus()
|
||||
|
||||
expect(status).toEqual({
|
||||
signedIn: true,
|
||||
email: 'dev@example.com',
|
||||
teamId: 'team-1',
|
||||
tokenFresh: false,
|
||||
error: null
|
||||
})
|
||||
expect(JSON.stringify(status)).not.toContain('secret-token')
|
||||
expect(JSON.stringify(status)).not.toContain('client-1')
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { GrokAccountStatus } from '../../shared/rate-limit-types'
|
||||
import { isGrokAccessTokenFresh, readGrokAuthSession } from '../rate-limits/grok-auth'
|
||||
|
||||
export function getGrokAccountStatus(): GrokAccountStatus {
|
||||
const readResult = readGrokAuthSession()
|
||||
if (readResult.status === 'missing') {
|
||||
return {
|
||||
signedIn: false,
|
||||
email: null,
|
||||
teamId: null,
|
||||
tokenFresh: false,
|
||||
error: null
|
||||
}
|
||||
}
|
||||
if (readResult.status === 'error') {
|
||||
return {
|
||||
signedIn: false,
|
||||
email: null,
|
||||
teamId: null,
|
||||
tokenFresh: false,
|
||||
error: readResult.error
|
||||
}
|
||||
}
|
||||
const session = readResult.session
|
||||
return {
|
||||
signedIn: true,
|
||||
email: session.email,
|
||||
teamId: session.teamId,
|
||||
tokenFresh: isGrokAccessTokenFresh(session),
|
||||
error: null
|
||||
}
|
||||
}
|
||||
+103
-124
@@ -5,34 +5,31 @@ import { resolveGrokHomeDir } from '../../shared/grok-session-paths'
|
||||
import {
|
||||
buildManagedCommandHook,
|
||||
createManagedCommandMatcher,
|
||||
buildWindowsAgentHookPostCommand,
|
||||
getSharedManagedScriptPath,
|
||||
readHooksJson,
|
||||
readHooksJsonAsync,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsHookCommand,
|
||||
writeHooksJson,
|
||||
writeHooksJsonAsync,
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
writeManagedScriptAsync,
|
||||
type HookDefinition,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
import { getManagedScript, GROK_HOME_ENVELOPE_MAX_LENGTH } from './managed-hook-script'
|
||||
|
||||
// Why: Grok's tool-event matcher is a real regex (see Grok hooks docs). Bare
|
||||
// `*` is not a valid "match all" pattern and can fail to load/match, so tool
|
||||
// lifecycle hooks never fire. `.*` matches every tool name (same as Command
|
||||
// Code's managed hooks).
|
||||
const GROK_TOOL_EVENT_MATCHER = '.*'
|
||||
const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096
|
||||
const WINDOWS_HOOK_PAYLOAD_FORM_LINE = ' --data-urlencode "payload@-" >nul 2>nul'
|
||||
|
||||
const GROK_EVENTS = [
|
||||
{ eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
@@ -95,11 +92,6 @@ function hasControlCharacter(value: string): boolean {
|
||||
})
|
||||
}
|
||||
|
||||
const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok').replace(
|
||||
WINDOWS_HOOK_PAYLOAD_FORM_LINE,
|
||||
` --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^\r\n${WINDOWS_HOOK_PAYLOAD_FORM_LINE}`
|
||||
)
|
||||
|
||||
function getManagedScriptFileName(): string {
|
||||
return process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh'
|
||||
}
|
||||
@@ -114,59 +106,6 @@ function getManagedCommand(scriptPath: string): string {
|
||||
: wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
'set "ORCA_GROK_HOME=%GROK_HOME%"',
|
||||
`if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`,
|
||||
// Why: a trailing backslash escapes curl's closing argv quote on Windows,
|
||||
// merging the payload option into grokHome and dropping the hook body.
|
||||
'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."',
|
||||
WINDOWS_GROK_HOOK_POST_COMMAND,
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
'fi',
|
||||
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'grok_home=',
|
||||
`if [ -n "\${GROK_HOME:-}" ] && [ "\${#GROK_HOME}" -le ${GROK_HOME_ENVELOPE_MAX_LENGTH} ]; then`,
|
||||
' grok_home=$GROK_HOME',
|
||||
'fi',
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "grokHome=${grok_home}" \\',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildInstalledConfig(
|
||||
config: NonNullable<ReturnType<typeof readHooksJson>>,
|
||||
command: string,
|
||||
@@ -203,52 +142,64 @@ function buildInstalledConfig(
|
||||
config.hooks = nextHooks
|
||||
}
|
||||
|
||||
function parseErrorStatus(configPath: string): AgentHookInstallStatus {
|
||||
return {
|
||||
agent: 'grok',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Grok hook config'
|
||||
}
|
||||
}
|
||||
|
||||
function buildStatus(config: HooksConfig | null): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const command = getManagedCommand(scriptPath)
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
for (const event of GROK_EVENTS) {
|
||||
const definitions = Array.isArray(config.hooks?.[event.eventName])
|
||||
? config.hooks![event.eventName]!
|
||||
: []
|
||||
const hasCommand = definitions.some((definition) =>
|
||||
(definition.hooks ?? []).some((hook) => hook.command === command)
|
||||
)
|
||||
if (hasCommand) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(event.eventName)
|
||||
}
|
||||
}
|
||||
|
||||
const managedHooksPresent = presentCount > 0
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
if (missing.length === 0) {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
} else if (presentCount === 0) {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail = `Managed hook missing for events: ${missing.join(', ')}`
|
||||
}
|
||||
return { agent: 'grok', state, configPath, managedHooksPresent, detail }
|
||||
}
|
||||
|
||||
export class GrokHookService {
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'grok',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Grok hook config'
|
||||
}
|
||||
}
|
||||
return buildStatus(readHooksJson(getConfigPath()))
|
||||
}
|
||||
|
||||
const command = getManagedCommand(scriptPath)
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
for (const event of GROK_EVENTS) {
|
||||
const definitions = Array.isArray(config.hooks?.[event.eventName])
|
||||
? config.hooks![event.eventName]!
|
||||
: []
|
||||
const hasCommand = definitions.some((definition) =>
|
||||
(definition.hooks ?? []).some((hook) => hook.command === command)
|
||||
)
|
||||
if (hasCommand) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(event.eventName)
|
||||
}
|
||||
}
|
||||
|
||||
const managedHooksPresent = presentCount > 0
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
if (missing.length === 0) {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
} else if (presentCount === 0) {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail = `Managed hook missing for events: ${missing.join(', ')}`
|
||||
}
|
||||
return { agent: 'grok', state, configPath, managedHooksPresent, detail }
|
||||
// Why: main-thread twin — the sync one stays for the CLI process.
|
||||
async getStatusAsync(): Promise<AgentHookInstallStatus> {
|
||||
return buildStatus(await readHooksJsonAsync(getConfigPath()))
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
@@ -256,13 +207,7 @@ export class GrokHookService {
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'grok',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Grok hook config'
|
||||
}
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
buildInstalledConfig(config, getManagedCommand(scriptPath), getManagedScriptFileName())
|
||||
@@ -271,6 +216,20 @@ export class GrokHookService {
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async installAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = await readHooksJsonAsync(configPath)
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
buildInstalledConfig(config, getManagedCommand(scriptPath), getManagedScriptFileName())
|
||||
await writeManagedScriptAsync(scriptPath, getManagedScript())
|
||||
await writeHooksJsonAsync(configPath, config)
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
|
||||
async installRemote(
|
||||
sftp: SFTPWrapper,
|
||||
remoteHome: string,
|
||||
@@ -319,13 +278,7 @@ export class GrokHookService {
|
||||
const configPath = getConfigPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'grok',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Grok hook config'
|
||||
}
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
@@ -346,6 +299,32 @@ export class GrokHookService {
|
||||
writeHooksJson(configPath, config)
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async removeAsync(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
const config = await readHooksJsonAsync(configPath)
|
||||
if (!config) {
|
||||
return parseErrorStatus(configPath)
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
|
||||
config.hooks = nextHooks
|
||||
await writeHooksJsonAsync(configPath, config)
|
||||
return this.getStatusAsync()
|
||||
}
|
||||
}
|
||||
|
||||
export const grokHookService = new GrokHookService()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
// Why: bound the GROK_HOME value the hook echoes back so a hostile env var cannot blow up the request.
|
||||
export const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096
|
||||
|
||||
const WINDOWS_HOOK_PAYLOAD_FORM_LINE = ' --data-urlencode "payload@-" >nul 2>nul'
|
||||
|
||||
const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok').replace(
|
||||
WINDOWS_HOOK_PAYLOAD_FORM_LINE,
|
||||
` --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^\r\n${WINDOWS_HOOK_PAYLOAD_FORM_LINE}`
|
||||
)
|
||||
|
||||
// Why: the managed hook body is shared by the local install and the SSH remote
|
||||
// install, so it lives apart from the service that decides where to write it.
|
||||
export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
'set "ORCA_GROK_HOME=%GROK_HOME%"',
|
||||
`if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`,
|
||||
// Why: a trailing backslash escapes curl's closing argv quote on Windows,
|
||||
// merging the payload option into grokHome and dropping the hook body.
|
||||
'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."',
|
||||
WINDOWS_GROK_HOOK_POST_COMMAND,
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
...buildPosixHookPayloadCapture(),
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
'fi',
|
||||
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'grok_home=',
|
||||
`if [ -n "\${GROK_HOME:-}" ] && [ "\${#GROK_HOME}" -le ${GROK_HOME_ENVELOPE_MAX_LENGTH} ]; then`,
|
||||
' grok_home=$GROK_HOME',
|
||||
'fi',
|
||||
// Timeout caps best-effort hook posts if the local listener stalls.
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
|
||||
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
|
||||
// command line (EDR command-line false positives). Wire body is identical.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "grokHome=${grok_home}" \\',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Why: freeze #30. Hermes ran up to five sync fs probes per status call against
|
||||
// ~/.hermes. On a stalled SMB/NFS mount those block the Electron main thread for
|
||||
// tens of seconds: no repaint, no menus, no Force Quit.
|
||||
const { syncFsCalls, hangReads, stalledRoot, STALLED_MOUNT_BLOCK_MS, TIMER_BUDGET_MS } = vi.hoisted(
|
||||
() => ({
|
||||
syncFsCalls: [] as string[],
|
||||
hangReads: { value: false },
|
||||
stalledRoot: { value: '' },
|
||||
STALLED_MOUNT_BLOCK_MS: 1_000,
|
||||
TIMER_BUDGET_MS: 500
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = (await vi.importActual('node:fs')) as Record<string, unknown>
|
||||
const wrapped: Record<string, unknown> = { ...actual }
|
||||
for (const [name, value] of Object.entries(actual)) {
|
||||
if (name.endsWith('Sync') && typeof value === 'function') {
|
||||
wrapped[name] = (...args: unknown[]) => {
|
||||
const target = String(args[0])
|
||||
syncFsCalls.push(`${name}:${target}`)
|
||||
if (hangReads.value && stalledRoot.value && target.startsWith(stalledRoot.value)) {
|
||||
// Why: a stalled SMB/NFS mount parks a *Sync call in the kernel with
|
||||
// the event loop still on the stack; Atomics.wait is the only faithful
|
||||
// way to reproduce that from JS.
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, STALLED_MOUNT_BLOCK_MS)
|
||||
}
|
||||
return (value as (...a: unknown[]) => unknown)(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...wrapped, default: wrapped }
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = (await vi.importActual('node:fs/promises')) as typeof FsPromises
|
||||
const readFile = (...args: Parameters<typeof actual.readFile>): Promise<unknown> =>
|
||||
hangReads.value ? new Promise<never>(() => {}) : actual.readFile(...args)
|
||||
return { ...actual, default: { ...actual, readFile }, readFile }
|
||||
})
|
||||
|
||||
import { HermesHookService } from './hook-service'
|
||||
|
||||
describe('HermesHookService main-thread fs', () => {
|
||||
let homeDir: string
|
||||
let previousHermesHome: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
previousHermesHome = process.env.HERMES_HOME
|
||||
homeDir = mkdtempSync(join(tmpdir(), 'orca-hermes-sync-fs-'))
|
||||
process.env.HERMES_HOME = homeDir
|
||||
hangReads.value = false
|
||||
stalledRoot.value = homeDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
hangReads.value = false
|
||||
stalledRoot.value = ''
|
||||
if (previousHermesHome === undefined) {
|
||||
delete process.env.HERMES_HOME
|
||||
} else {
|
||||
process.env.HERMES_HOME = previousHermesHome
|
||||
}
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reads status without a single synchronous fs syscall', async () => {
|
||||
writeFileSync(join(homeDir, 'config.yaml'), 'plugins:\n enabled: []\n', 'utf-8')
|
||||
|
||||
syncFsCalls.length = 0
|
||||
await new HermesHookService().getStatus()
|
||||
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('installs and removes without a single synchronous fs syscall', async () => {
|
||||
const service = new HermesHookService()
|
||||
|
||||
syncFsCalls.length = 0
|
||||
const installed = await service.install()
|
||||
expect(installed.state).toBe('installed')
|
||||
expect(syncFsCalls).toEqual([])
|
||||
|
||||
syncFsCalls.length = 0
|
||||
const removed = await service.remove()
|
||||
expect(removed.state).toBe('not_installed')
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
// Why: the point of the async conversion is that a stalled ~/.hermes still
|
||||
// lets timers/repaints/menus run. Arm the timer *before* the call — a sync
|
||||
// reader blocks inside getStatus(), so lateness measured afterwards misses it.
|
||||
it('keeps the event loop alive while ~/.hermes reads hang', async () => {
|
||||
hangReads.value = true
|
||||
syncFsCalls.length = 0
|
||||
|
||||
const armedAt = Date.now()
|
||||
let observedDelayMs = -1
|
||||
const timer = new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
observedDelayMs = Date.now() - armedAt
|
||||
resolve()
|
||||
}, 5)
|
||||
})
|
||||
|
||||
const pending = Promise.resolve(new HermesHookService().getStatus())
|
||||
void pending.catch(() => {})
|
||||
await timer
|
||||
|
||||
expect(observedDelayMs).toBeGreaterThanOrEqual(0)
|
||||
expect(observedDelayMs).toBeLessThan(TIMER_BUDGET_MS)
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('serializes overlapping installs so config.yaml never renames out of order', async () => {
|
||||
const service = new HermesHookService()
|
||||
|
||||
const [first, second] = await Promise.all([service.install(), service.install()])
|
||||
|
||||
expect(first.state).toBe('installed')
|
||||
expect(second.state).toBe('installed')
|
||||
expect((await service.getStatus()).state).toBe('installed')
|
||||
})
|
||||
})
|
||||
@@ -30,8 +30,8 @@ describe('HermesHookService', () => {
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('installs the managed Hermes plugin and enables it in config.yaml', () => {
|
||||
const status = new HermesHookService().install()
|
||||
it('installs the managed Hermes plugin and enables it in config.yaml', async () => {
|
||||
const status = await new HermesHookService().install()
|
||||
|
||||
expect(status).toMatchObject({
|
||||
agent: 'hermes',
|
||||
@@ -48,7 +48,7 @@ describe('HermesHookService', () => {
|
||||
expect(config.plugins.enabled).toContain(_internals.HERMES_PLUGIN_NAME)
|
||||
})
|
||||
|
||||
it('preserves other enabled plugins and removes Orca from disabled list', () => {
|
||||
it('preserves other enabled plugins and removes Orca from disabled list', async () => {
|
||||
writeFileSync(
|
||||
join(homeDir, 'config.yaml'),
|
||||
[
|
||||
@@ -63,7 +63,7 @@ describe('HermesHookService', () => {
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
new HermesHookService().install()
|
||||
await new HermesHookService().install()
|
||||
|
||||
const config = parse(readFileSync(join(homeDir, 'config.yaml'), 'utf-8')) as {
|
||||
model: string
|
||||
@@ -74,14 +74,14 @@ describe('HermesHookService', () => {
|
||||
expect(config.plugins.disabled).toEqual([])
|
||||
})
|
||||
|
||||
it('normalizes malformed plugin lists during install', () => {
|
||||
it('normalizes malformed plugin lists during install', async () => {
|
||||
writeFileSync(
|
||||
join(homeDir, 'config.yaml'),
|
||||
['plugins:', ' enabled: "not-a-list"', ' disabled: "not-a-list"', ''].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const status = new HermesHookService().install()
|
||||
const status = await new HermesHookService().install()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
const config = parse(readFileSync(join(homeDir, 'config.yaml'), 'utf-8')) as {
|
||||
@@ -91,8 +91,8 @@ describe('HermesHookService', () => {
|
||||
expect(config.plugins.disabled).toEqual([])
|
||||
})
|
||||
|
||||
it('reports partial when the plugin exists but is not enabled', () => {
|
||||
new HermesHookService().install()
|
||||
it('reports partial when the plugin exists but is not enabled', async () => {
|
||||
await new HermesHookService().install()
|
||||
const update = _internals.updateConfigContent(
|
||||
readFileSync(join(homeDir, 'config.yaml'), 'utf-8'),
|
||||
_internals.disablePlugin
|
||||
@@ -100,18 +100,18 @@ describe('HermesHookService', () => {
|
||||
expect(update.content).toBeTruthy()
|
||||
writeFileSync(join(homeDir, 'config.yaml'), update.content!, 'utf-8')
|
||||
|
||||
const status = new HermesHookService().getStatus()
|
||||
const status = await new HermesHookService().getStatus()
|
||||
|
||||
expect(status.state).toBe('partial')
|
||||
expect(status.detail).toContain('not enabled')
|
||||
})
|
||||
|
||||
it('is visible to the real hermes CLI when hermes is installed', () => {
|
||||
it('is visible to the real hermes CLI when hermes is installed', async () => {
|
||||
const hermesAvailable = spawnSync('hermes', ['--version'], { encoding: 'utf-8' }).status === 0
|
||||
if (!hermesAvailable) {
|
||||
return
|
||||
}
|
||||
new HermesHookService().install()
|
||||
await new HermesHookService().install()
|
||||
|
||||
const output = execFileSync('hermes', ['plugins', 'list'], {
|
||||
env: { ...process.env, HERMES_HOME: homeDir },
|
||||
@@ -128,7 +128,7 @@ describe('HermesHookService', () => {
|
||||
if (!pythonAvailable) {
|
||||
return
|
||||
}
|
||||
new HermesHookService().install()
|
||||
await new HermesHookService().install()
|
||||
|
||||
const received = new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
@@ -225,12 +225,12 @@ describe('HermesHookService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds generated plugin payload normalization before JSON encoding', () => {
|
||||
it('bounds generated plugin payload normalization before JSON encoding', async () => {
|
||||
const pythonAvailable = spawnSync('python3', ['--version'], { encoding: 'utf-8' }).status === 0
|
||||
if (!pythonAvailable) {
|
||||
return
|
||||
}
|
||||
new HermesHookService().install()
|
||||
await new HermesHookService().install()
|
||||
|
||||
const initPath = join(homeDir, 'plugins', _internals.HERMES_PLUGIN_NAME, '__init__.py')
|
||||
const script = [
|
||||
|
||||
+117
-98
@@ -1,15 +1,6 @@
|
||||
/* eslint-disable max-lines -- Why: install/status/remove must share the exact Hermes plugin source, YAML enablement logic, and status classification. Splitting would make the managed plugin bytes drift from the installer tests that verify them against the real Hermes CLI. */
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { copyFile, mkdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
@@ -133,45 +124,65 @@ function disablePlugin(config: HermesConfig): HermesConfig {
|
||||
return next
|
||||
}
|
||||
|
||||
function readConfigFile(configPath: string): ConfigParseResult {
|
||||
if (!existsSync(configPath)) {
|
||||
return { ok: true, config: {} }
|
||||
}
|
||||
return parseHermesConfig(readFileSync(configPath, 'utf-8'))
|
||||
// Why: async fs keeps a stalled ~/.hermes mount off the Electron main thread; a
|
||||
// hung read now parks a threadpool slot instead of freezing the whole app.
|
||||
function isMissingPath(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
return code === 'ENOENT' || code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
function writeConfigFile(configPath: string, config: HermesConfig): void {
|
||||
const dir = dirname(configPath)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const serialized = serializeHermesConfig(config)
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
if (readFileSync(configPath, 'utf-8') === serialized) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the atomic write path.
|
||||
// Why: one read replaces the existsSync probe + read; fewer syscalls on a stalled mount.
|
||||
async function readConfigFile(configPath: string): Promise<ConfigParseResult> {
|
||||
try {
|
||||
return parseHermesConfig(await readFile(configPath, 'utf-8'))
|
||||
} catch (error) {
|
||||
if (isMissingPath(error)) {
|
||||
return { ok: true, config: {} }
|
||||
}
|
||||
return { ok: false, detail: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async function writeConfigFile(configPath: string, config: HermesConfig): Promise<void> {
|
||||
const dir = dirname(configPath)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const serialized = serializeHermesConfig(config)
|
||||
try {
|
||||
if ((await readFile(configPath, 'utf-8')) === serialized) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Absent or unreadable: fall through to the atomic write path.
|
||||
}
|
||||
|
||||
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
|
||||
try {
|
||||
writeFileSync(tmpPath, serialized, 'utf-8')
|
||||
if (existsSync(configPath)) {
|
||||
copyFileSync(configPath, `${configPath}.bak`)
|
||||
}
|
||||
renameSync(tmpPath, configPath)
|
||||
} finally {
|
||||
if (existsSync(tmpPath)) {
|
||||
try {
|
||||
unlinkSync(tmpPath)
|
||||
} catch {
|
||||
// best effort
|
||||
await writeFile(tmpPath, serialized, 'utf-8')
|
||||
try {
|
||||
await copyFile(configPath, `${configPath}.bak`)
|
||||
} catch (error) {
|
||||
// No prior config to back up; any other failure still aborts the write.
|
||||
if (!isMissingPath(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
await rename(tmpPath, configPath)
|
||||
} finally {
|
||||
// ENOENT after a successful rename is the normal case.
|
||||
await unlink(tmpPath).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// Why: sync fs serialized read-modify-write for free. Chain mutations so two
|
||||
// overlapping install/remove calls can never rename config.yaml out of order.
|
||||
let pendingMutation: Promise<unknown> = Promise.resolve()
|
||||
|
||||
function serializeConfigMutation<T>(run: () => Promise<T>): Promise<T> {
|
||||
const next = pendingMutation.then(run, run)
|
||||
pendingMutation = next.catch(() => {})
|
||||
return next
|
||||
}
|
||||
|
||||
function updateConfigContent(
|
||||
content: string | null,
|
||||
updater: (config: HermesConfig) => HermesConfig
|
||||
@@ -183,32 +194,36 @@ function updateConfigContent(
|
||||
return { content: serializeHermesConfig(updater(parsed.config)) }
|
||||
}
|
||||
|
||||
function getPluginFilesState(pluginDir = getPluginDir()): {
|
||||
// Why: two parallel reads replace four serial sync probes (2x exists + 2x read),
|
||||
// which is the bulk of the per-status syscall cost on a stalled ~/.hermes mount.
|
||||
async function getPluginFilesState(pluginDir = getPluginDir()): Promise<{
|
||||
present: boolean
|
||||
managed: boolean
|
||||
detail: string | null
|
||||
} {
|
||||
const manifestPath = getManifestPath(pluginDir)
|
||||
const initPath = getInitPath(pluginDir)
|
||||
if (!existsSync(manifestPath) || !existsSync(initPath)) {
|
||||
return { present: false, managed: false, detail: 'Managed Hermes plugin files are missing' }
|
||||
}
|
||||
}> {
|
||||
let manifest: string
|
||||
let init: string
|
||||
try {
|
||||
const manifest = readFileSync(manifestPath, 'utf-8')
|
||||
const init = readFileSync(initPath, 'utf-8')
|
||||
const managed = manifest.includes(HERMES_PLUGIN_MARKER) && init.includes(HERMES_PLUGIN_MARKER)
|
||||
return {
|
||||
present: true,
|
||||
managed,
|
||||
detail: managed ? null : 'Hermes orca-status plugin exists but is not Orca-managed'
|
||||
}
|
||||
;[manifest, init] = await Promise.all([
|
||||
readFile(getManifestPath(pluginDir), 'utf-8'),
|
||||
readFile(getInitPath(pluginDir), 'utf-8')
|
||||
])
|
||||
} catch (error) {
|
||||
if (isMissingPath(error)) {
|
||||
return { present: false, managed: false, detail: 'Managed Hermes plugin files are missing' }
|
||||
}
|
||||
return {
|
||||
present: true,
|
||||
managed: false,
|
||||
detail: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
const managed = manifest.includes(HERMES_PLUGIN_MARKER) && init.includes(HERMES_PLUGIN_MARKER)
|
||||
return {
|
||||
present: true,
|
||||
managed,
|
||||
detail: managed ? null : 'Hermes orca-status plugin exists but is not Orca-managed'
|
||||
}
|
||||
}
|
||||
|
||||
function getConfigEnablement(config: HermesConfig): {
|
||||
@@ -234,8 +249,11 @@ function getConfigEnablement(config: HermesConfig): {
|
||||
}
|
||||
}
|
||||
|
||||
function buildStatus(configPath: string, config: HermesConfig): AgentHookInstallStatus {
|
||||
const pluginFiles = getPluginFilesState()
|
||||
async function buildStatus(
|
||||
configPath: string,
|
||||
config: HermesConfig
|
||||
): Promise<AgentHookInstallStatus> {
|
||||
const pluginFiles = await getPluginFilesState()
|
||||
const enablement = getConfigEnablement(config)
|
||||
const details = [
|
||||
pluginFiles.detail,
|
||||
@@ -267,6 +285,19 @@ function buildStatus(configPath: string, config: HermesConfig): AgentHookInstall
|
||||
}
|
||||
}
|
||||
|
||||
async function parseErrorStatus(
|
||||
configPath: string,
|
||||
detail: string
|
||||
): Promise<AgentHookInstallStatus> {
|
||||
return {
|
||||
agent: 'hermes',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: (await getPluginFilesState()).managed,
|
||||
detail: `Could not parse Hermes config.yaml: ${detail}`
|
||||
}
|
||||
}
|
||||
|
||||
function getPluginManifest(): string {
|
||||
return [
|
||||
`# ${HERMES_PLUGIN_MARKER}`,
|
||||
@@ -436,10 +467,12 @@ def register(ctx: Any) -> None:
|
||||
`
|
||||
}
|
||||
|
||||
function writePluginFiles(pluginDir = getPluginDir()): void {
|
||||
mkdirSync(pluginDir, { recursive: true })
|
||||
writeFileSync(getManifestPath(pluginDir), getPluginManifest(), 'utf-8')
|
||||
writeFileSync(getInitPath(pluginDir), getPluginInitSource(), 'utf-8')
|
||||
async function writePluginFiles(pluginDir = getPluginDir()): Promise<void> {
|
||||
await mkdir(pluginDir, { recursive: true })
|
||||
await Promise.all([
|
||||
writeFile(getManifestPath(pluginDir), getPluginManifest(), 'utf-8'),
|
||||
writeFile(getInitPath(pluginDir), getPluginInitSource(), 'utf-8')
|
||||
])
|
||||
}
|
||||
|
||||
function stripTrailingSlash(path: string): string {
|
||||
@@ -447,37 +480,27 @@ function stripTrailingSlash(path: string): string {
|
||||
}
|
||||
|
||||
export class HermesHookService {
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
async getStatus(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
const parsed = readConfigFile(configPath)
|
||||
const parsed = await readConfigFile(configPath)
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
agent: 'hermes',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: getPluginFilesState().managed,
|
||||
detail: `Could not parse Hermes config.yaml: ${parsed.detail}`
|
||||
}
|
||||
return parseErrorStatus(configPath, parsed.detail)
|
||||
}
|
||||
return buildStatus(configPath, parsed.config)
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const parsed = readConfigFile(configPath)
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
agent: 'hermes',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: getPluginFilesState().managed,
|
||||
detail: `Could not parse Hermes config.yaml: ${parsed.detail}`
|
||||
install(): Promise<AgentHookInstallStatus> {
|
||||
return serializeConfigMutation(async () => {
|
||||
const configPath = getConfigPath()
|
||||
const parsed = await readConfigFile(configPath)
|
||||
if (!parsed.ok) {
|
||||
return parseErrorStatus(configPath, parsed.detail)
|
||||
}
|
||||
}
|
||||
|
||||
writePluginFiles()
|
||||
writeConfigFile(configPath, enablePlugin(parsed.config))
|
||||
return this.getStatus()
|
||||
await writePluginFiles()
|
||||
await writeConfigFile(configPath, enablePlugin(parsed.config))
|
||||
return this.getStatus()
|
||||
})
|
||||
}
|
||||
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
@@ -517,24 +540,20 @@ export class HermesHookService {
|
||||
}
|
||||
}
|
||||
|
||||
remove(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const parsed = readConfigFile(configPath)
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
agent: 'hermes',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: getPluginFilesState().managed,
|
||||
detail: `Could not parse Hermes config.yaml: ${parsed.detail}`
|
||||
remove(): Promise<AgentHookInstallStatus> {
|
||||
return serializeConfigMutation(async () => {
|
||||
const configPath = getConfigPath()
|
||||
const parsed = await readConfigFile(configPath)
|
||||
if (!parsed.ok) {
|
||||
return parseErrorStatus(configPath, parsed.detail)
|
||||
}
|
||||
}
|
||||
const pluginDir = getPluginDir()
|
||||
if (getPluginFilesState(pluginDir).managed) {
|
||||
rmSync(pluginDir, { recursive: true, force: true })
|
||||
}
|
||||
writeConfigFile(configPath, disablePlugin(parsed.config))
|
||||
return this.getStatus()
|
||||
const pluginDir = getPluginDir()
|
||||
if ((await getPluginFilesState(pluginDir)).managed) {
|
||||
await rm(pluginDir, { recursive: true, force: true })
|
||||
}
|
||||
await writeConfigFile(configPath, disablePlugin(parsed.config))
|
||||
return this.getStatus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+114
-11
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable max-lines -- Why: hook parsing, layered issue-command resolution, and cross-platform runner setup share one execution surface, so keeping them together avoids subtle drift across create/read/write paths. */
|
||||
import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs'
|
||||
import { access, readFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { exec, execFile } from 'node:child_process'
|
||||
import { getDefaultRepoHookSettings } from '../shared/constants'
|
||||
@@ -38,11 +39,16 @@ function getHookShell(): string | undefined {
|
||||
|
||||
export { parseOrcaYaml }
|
||||
|
||||
const ORCA_YAML_FILENAME = 'orca.yaml'
|
||||
|
||||
/**
|
||||
* Load hooks from orca.yaml in the given repo root.
|
||||
*
|
||||
* Sync twin of `loadHooksAsync`, kept for callers that cannot await (worktree
|
||||
* create/archive flows and runtime paths that build launch descriptors inline).
|
||||
*/
|
||||
export function loadHooks(repoPath: string): OrcaHooks | null {
|
||||
const yamlPath = join(repoPath, 'orca.yaml')
|
||||
const yamlPath = join(repoPath, ORCA_YAML_FILENAME)
|
||||
if (!existsSync(yamlPath)) {
|
||||
return null
|
||||
}
|
||||
@@ -59,7 +65,78 @@ export function loadHooks(repoPath: string): OrcaHooks | null {
|
||||
* Check whether an orca.yaml exists for a repo.
|
||||
*/
|
||||
export function hasHooksFile(repoPath: string): boolean {
|
||||
return existsSync(join(repoPath, 'orca.yaml'))
|
||||
return existsSync(join(repoPath, ORCA_YAML_FILENAME))
|
||||
}
|
||||
|
||||
function isFileAbsentError(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
return code === 'ENOENT' || code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
/** Why: `existsSync` is `access(F_OK)`, so it answers false for EACCES/ELOOP/EIO as well as
|
||||
* ENOENT, and a readFile failure cannot tell an unreadable file from an unreachable path
|
||||
* (a repo directory at mode 000 fails both with EACCES). Re-probe the way the sync twin did
|
||||
* so `hasHooksFile` keeps its old answer. */
|
||||
async function orcaYamlPathExists(yamlPath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(yamlPath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Read `orca.yaml` in one syscall — an existsSync probe plus a read is two round trips on a
|
||||
* path that may be a stalled mount or a FIFO, and ENOENT already answers "is it there?".
|
||||
* `content` is null when the file is absent *or* unreadable; `exists` separates those. */
|
||||
async function readOrcaYaml(
|
||||
repoPath: string
|
||||
): Promise<{ exists: boolean; content: string | null }> {
|
||||
const yamlPath = join(repoPath, ORCA_YAML_FILENAME)
|
||||
try {
|
||||
return { exists: true, content: await readFile(yamlPath, 'utf-8') }
|
||||
} catch (error) {
|
||||
// Only the absence codes are unambiguous; anything else costs a second syscall to classify.
|
||||
return {
|
||||
exists: isFileAbsentError(error) ? false : await orcaYamlPathExists(yamlPath),
|
||||
content: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseOrcaYamlOrNull(content: string): OrcaHooks | null {
|
||||
try {
|
||||
return parseOrcaYaml(content)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Async twin of `loadHooks`. */
|
||||
export async function loadHooksAsync(repoPath: string): Promise<OrcaHooks | null> {
|
||||
const { content } = await readOrcaYaml(repoPath)
|
||||
return content == null ? null : parseOrcaYamlOrNull(content)
|
||||
}
|
||||
|
||||
export type OrcaYamlHooksCheck = {
|
||||
hasHooksFile: boolean
|
||||
hooks: OrcaHooks | null
|
||||
mayNeedUpdate: boolean
|
||||
}
|
||||
|
||||
/** One read answers what `hasHooksFile` + `loadHooks` + `hasUnrecognizedOrcaYamlKeys` asked in three. */
|
||||
export async function checkOrcaYamlHooks(repoPath: string): Promise<OrcaYamlHooksCheck> {
|
||||
const { exists, content } = await readOrcaYaml(repoPath)
|
||||
if (content == null) {
|
||||
return { hasHooksFile: exists, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
|
||||
const hooks = parseOrcaYamlOrNull(content)
|
||||
return {
|
||||
hasHooksFile: true,
|
||||
hooks,
|
||||
mayNeedUpdate: hooks == null && hasUnrecognizedTopLevelKeys(content)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: detect unrecognised keys so the UI can suggest an update instead of showing a "could not be parsed" error.
|
||||
@@ -71,18 +148,21 @@ const RECOGNIZED_ORCA_YAML_KEYS = new Set([
|
||||
'worktree'
|
||||
])
|
||||
|
||||
function hasUnrecognizedTopLevelKeys(content: string): boolean {
|
||||
for (const line of iterateLfScriptLines(content)) {
|
||||
// Why: match bare `key:` at end-of-line too, since a mapping with a block value on the next line is valid YAML.
|
||||
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(\s|$)/)
|
||||
if (m != null && !RECOGNIZED_ORCA_YAML_KEYS.has(m[1])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** True when `orca.yaml` has a top-level key this version of Orca does not handle. */
|
||||
export function hasUnrecognizedOrcaYamlKeys(repoPath: string): boolean {
|
||||
try {
|
||||
const content = readFileSync(join(repoPath, 'orca.yaml'), 'utf-8')
|
||||
for (const line of iterateLfScriptLines(content)) {
|
||||
// Why: match bare `key:` at end-of-line too, since a mapping with a block value on the next line is valid YAML.
|
||||
const m = line.match(/^([A-Za-z][A-Za-z0-9_-]*):(\s|$)/)
|
||||
if (m != null && !RECOGNIZED_ORCA_YAML_KEYS.has(m[1])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return hasUnrecognizedTopLevelKeys(readFileSync(join(repoPath, ORCA_YAML_FILENAME), 'utf-8'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -138,6 +218,29 @@ export function readIssueCommand(repoPath: string): ResolvedIssueCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/** Async twin of `readIssueCommand`; reads sequentially so a stalled repo holds one threadpool slot, not two. */
|
||||
export async function readIssueCommandAsync(repoPath: string): Promise<ResolvedIssueCommand> {
|
||||
const filePath = getIssueCommandFilePath(repoPath)
|
||||
let localContent: string | null = null
|
||||
|
||||
try {
|
||||
localContent = (await readFile(filePath, 'utf-8')).trim() || null
|
||||
} catch {
|
||||
localContent = null
|
||||
}
|
||||
|
||||
const sharedContent = (await loadHooksAsync(repoPath))?.issueCommand?.trim() || null
|
||||
const effectiveContent = localContent ?? sharedContent
|
||||
|
||||
return {
|
||||
localContent,
|
||||
sharedContent,
|
||||
effectiveContent,
|
||||
localFilePath: filePath,
|
||||
source: localContent ? 'local' : sharedContent ? 'shared' : 'none'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the per-user issue command override to `{repoRoot}/.orca/issue-command`.
|
||||
* Empty content deletes the override so the shared `orca.yaml` command applies again.
|
||||
|
||||
+4
-1
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable max-lines -- main-process entry point; owns app lifecycle, service wiring, window creation, and hook/daemon startup with no cleaner split seam. */
|
||||
import './libuv-threadpool-size'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { isAbsolute, join } from 'node:path'
|
||||
import os from 'node:os'
|
||||
@@ -2614,7 +2615,9 @@ void app.whenReady().then(async () => {
|
||||
console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error)
|
||||
})
|
||||
} else {
|
||||
removeManagedAgentHooks()
|
||||
void removeManagedAgentHooks().catch((error) => {
|
||||
console.warn('[agent-hooks] failed to remove managed hooks on startup:', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
app.on('child-process-gone', (_event, details) => {
|
||||
|
||||
@@ -61,37 +61,43 @@ vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../claude/hook-service', () => ({
|
||||
claudeHookService: { getStatus: vi.fn(() => ({ agent: 'claude', state: 'absent' })) }
|
||||
claudeHookService: { getStatusAsync: vi.fn(async () => ({ agent: 'claude', state: 'absent' })) }
|
||||
}))
|
||||
vi.mock('../openclaude/hook-service', () => ({
|
||||
openClaudeHookService: { getStatus: vi.fn(() => ({ agent: 'openclaude', state: 'absent' })) }
|
||||
openClaudeHookService: {
|
||||
getStatusAsync: vi.fn(async () => ({ agent: 'openclaude', state: 'absent' }))
|
||||
}
|
||||
}))
|
||||
vi.mock('../codex/hook-service', () => ({
|
||||
codexHookService: { getStatus: vi.fn(() => ({ agent: 'codex', state: 'absent' })) }
|
||||
}))
|
||||
vi.mock('../gemini/hook-service', () => ({
|
||||
geminiHookService: { getStatus: vi.fn(() => ({ agent: 'gemini', state: 'absent' })) }
|
||||
geminiHookService: { getStatusAsync: vi.fn(async () => ({ agent: 'gemini', state: 'absent' })) }
|
||||
}))
|
||||
vi.mock('../antigravity/hook-service', () => ({
|
||||
antigravityHookService: { getStatus: vi.fn(() => ({ agent: 'antigravity', state: 'absent' })) }
|
||||
antigravityHookService: {
|
||||
getStatusAsync: vi.fn(async () => ({ agent: 'antigravity', state: 'absent' }))
|
||||
}
|
||||
}))
|
||||
vi.mock('../amp/hook-service', () => ({
|
||||
ampHookService: { getStatus: vi.fn(() => ({ agent: 'amp', state: 'absent' })) }
|
||||
}))
|
||||
vi.mock('../cursor/hook-service', () => ({
|
||||
cursorHookService: { getStatus: vi.fn(() => ({ agent: 'cursor', state: 'absent' })) }
|
||||
cursorHookService: { getStatusAsync: vi.fn(async () => ({ agent: 'cursor', state: 'absent' })) }
|
||||
}))
|
||||
vi.mock('../droid/hook-service', () => ({
|
||||
droidHookService: { getStatus: vi.fn(() => ({ agent: 'droid', state: 'absent' })) }
|
||||
droidHookService: { getStatusAsync: vi.fn(async () => ({ agent: 'droid', state: 'absent' })) }
|
||||
}))
|
||||
vi.mock('../command-code/hook-service', () => ({
|
||||
commandCodeHookService: { getStatus: vi.fn(() => ({ agent: 'command-code', state: 'absent' })) }
|
||||
commandCodeHookService: {
|
||||
getStatusAsync: vi.fn(async () => ({ agent: 'command-code', state: 'absent' }))
|
||||
}
|
||||
}))
|
||||
vi.mock('../grok/hook-service', () => ({
|
||||
grokHookService: { getStatus: vi.fn(() => ({ agent: 'grok', state: 'absent' })) }
|
||||
grokHookService: { getStatusAsync: vi.fn(async () => ({ agent: 'grok', state: 'absent' })) }
|
||||
}))
|
||||
vi.mock('../copilot/hook-service', () => ({
|
||||
copilotHookService: { getStatus: vi.fn(() => ({ agent: 'copilot', state: 'absent' })) }
|
||||
copilotHookService: { getStatusAsync: vi.fn(async () => ({ agent: 'copilot', state: 'absent' })) }
|
||||
}))
|
||||
vi.mock('../hermes/hook-service', () => ({
|
||||
hermesHookService: { getStatus: vi.fn(() => ({ agent: 'hermes', state: 'absent' })) }
|
||||
@@ -215,7 +221,7 @@ describe('agentHooks:antigravityStatus IPC', () => {
|
||||
|
||||
const handler = handleHandlers.get('agentHooks:antigravityStatus')
|
||||
expect(handler).toBeDefined()
|
||||
expect(handler!({})).toEqual({ agent: 'antigravity', state: 'absent' })
|
||||
await expect(handler!({})).resolves.toEqual({ agent: 'antigravity', state: 'absent' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -226,7 +232,7 @@ describe('agentHooks:ampStatus IPC', () => {
|
||||
|
||||
const handler = handleHandlers.get('agentHooks:ampStatus')
|
||||
expect(handler).toBeDefined()
|
||||
expect(handler!({})).toEqual({ agent: 'amp', state: 'absent' })
|
||||
await expect(handler!({})).resolves.toEqual({ agent: 'amp', state: 'absent' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -237,7 +243,7 @@ describe('agentHooks:openClaudeStatus IPC', () => {
|
||||
|
||||
const handler = handleHandlers.get('agentHooks:openClaudeStatus')
|
||||
expect(handler).toBeDefined()
|
||||
expect(handler!({})).toEqual({ agent: 'openclaude', state: 'absent' })
|
||||
await expect(handler!({})).resolves.toEqual({ agent: 'openclaude', state: 'absent' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -248,7 +254,7 @@ describe('agentHooks:commandCodeStatus IPC', () => {
|
||||
|
||||
const handler = handleHandlers.get('agentHooks:commandCodeStatus')
|
||||
expect(handler).toBeDefined()
|
||||
expect(handler!({})).toEqual({ agent: 'command-code', state: 'absent' })
|
||||
await expect(handler!({})).resolves.toEqual({ agent: 'command-code', state: 'absent' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -259,7 +265,7 @@ describe('agentHooks:devinStatus IPC', () => {
|
||||
|
||||
const handler = handleHandlers.get('agentHooks:devinStatus')
|
||||
expect(handler).toBeDefined()
|
||||
expect(handler!({})).toEqual({ agent: 'devin', state: 'absent' })
|
||||
await expect(handler!({})).resolves.toEqual({ agent: 'devin', state: 'absent' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -270,7 +276,7 @@ describe('agentHooks:kimiStatus IPC', () => {
|
||||
|
||||
const handler = handleHandlers.get('agentHooks:kimiStatus')
|
||||
expect(handler).toBeDefined()
|
||||
expect(handler!({})).toEqual({ agent: 'kimi', state: 'absent' })
|
||||
await expect(handler!({})).resolves.toEqual({ agent: 'kimi', state: 'absent' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+42
-195
@@ -38,6 +38,31 @@ type AgentHookHandlerDependencies = {
|
||||
getPtyIdForPaneKey?: (paneKey: string) => string | undefined
|
||||
}
|
||||
|
||||
type AgentHookStatusReader = readonly [
|
||||
channel: string,
|
||||
agent: AgentHookInstallStatus['agent'],
|
||||
read: () => AgentHookInstallStatus | Promise<AgentHookInstallStatus>
|
||||
]
|
||||
|
||||
// Why: read off the main thread — every one of these resolves a path under the
|
||||
// user's HOME, which can be a stalled network mount.
|
||||
const AGENT_HOOK_STATUS_READERS: readonly AgentHookStatusReader[] = [
|
||||
['agentHooks:claudeStatus', 'claude', () => claudeHookService.getStatusAsync()],
|
||||
['agentHooks:openClaudeStatus', 'openclaude', () => openClaudeHookService.getStatusAsync()],
|
||||
['agentHooks:codexStatus', 'codex', () => codexHookService.getStatus()],
|
||||
['agentHooks:geminiStatus', 'gemini', () => geminiHookService.getStatusAsync()],
|
||||
['agentHooks:antigravityStatus', 'antigravity', () => antigravityHookService.getStatusAsync()],
|
||||
['agentHooks:ampStatus', 'amp', () => ampHookService.getStatus()],
|
||||
['agentHooks:cursorStatus', 'cursor', () => cursorHookService.getStatusAsync()],
|
||||
['agentHooks:droidStatus', 'droid', () => droidHookService.getStatusAsync()],
|
||||
['agentHooks:commandCodeStatus', 'command-code', () => commandCodeHookService.getStatusAsync()],
|
||||
['agentHooks:grokStatus', 'grok', () => grokHookService.getStatusAsync()],
|
||||
['agentHooks:copilotStatus', 'copilot', () => copilotHookService.getStatusAsync()],
|
||||
['agentHooks:hermesStatus', 'hermes', () => hermesHookService.getStatus()],
|
||||
['agentHooks:devinStatus', 'devin', () => devinHookService.getStatus()],
|
||||
['agentHooks:kimiStatus', 'kimi', () => kimiHookService.getStatus()]
|
||||
]
|
||||
|
||||
// Why: install/remove are intentionally not exposed to the renderer. Orca
|
||||
// auto-installs managed hooks at app startup (see src/main/index.ts), so a
|
||||
// renderer-triggered remove would be silently reverted on the next launch
|
||||
@@ -52,20 +77,9 @@ export function registerAgentHookHandlers(
|
||||
// recreates the main window). Today the module-level `registered` guard in
|
||||
// register-core-handlers.ts prevents re-entry, but decoupling from that guard
|
||||
// future-proofs this file.
|
||||
ipcMain.removeHandler('agentHooks:claudeStatus')
|
||||
ipcMain.removeHandler('agentHooks:openClaudeStatus')
|
||||
ipcMain.removeHandler('agentHooks:codexStatus')
|
||||
ipcMain.removeHandler('agentHooks:geminiStatus')
|
||||
ipcMain.removeHandler('agentHooks:antigravityStatus')
|
||||
ipcMain.removeHandler('agentHooks:ampStatus')
|
||||
ipcMain.removeHandler('agentHooks:cursorStatus')
|
||||
ipcMain.removeHandler('agentHooks:droidStatus')
|
||||
ipcMain.removeHandler('agentHooks:commandCodeStatus')
|
||||
ipcMain.removeHandler('agentHooks:grokStatus')
|
||||
ipcMain.removeHandler('agentHooks:copilotStatus')
|
||||
ipcMain.removeHandler('agentHooks:hermesStatus')
|
||||
ipcMain.removeHandler('agentHooks:devinStatus')
|
||||
ipcMain.removeHandler('agentHooks:kimiStatus')
|
||||
for (const [channel] of AGENT_HOOK_STATUS_READERS) {
|
||||
ipcMain.removeHandler(channel)
|
||||
}
|
||||
ipcMain.removeHandler('agentStatus:getSnapshot')
|
||||
ipcMain.removeHandler('agentStatus:inferInterrupt')
|
||||
ipcMain.removeHandler('agentStatus:inferQuestionAnswered')
|
||||
@@ -139,186 +153,19 @@ export function registerAgentHookHandlers(
|
||||
// render a coherent per-agent error row. Letting the exception propagate out
|
||||
// of the IPC handler surfaces as an unhandled renderer-side rejection, which
|
||||
// defeats the AgentHookInstallStatus contract the UI relies on.
|
||||
ipcMain.handle('agentHooks:claudeStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return claudeHookService.getStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'claude',
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
for (const [channel, agent, readStatus] of AGENT_HOOK_STATUS_READERS) {
|
||||
ipcMain.handle(channel, async (): Promise<AgentHookInstallStatus> => {
|
||||
try {
|
||||
return await readStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent,
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:openClaudeStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return openClaudeHookService.getStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'openclaude',
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:codexStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return codexHookService.getStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'codex',
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:geminiStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return geminiHookService.getStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'gemini',
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:antigravityStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return antigravityHookService.getStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'antigravity',
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:ampStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return ampHookService.getStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'amp',
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:cursorStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return cursorHookService.getStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'cursor',
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:droidStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return droidHookService.getStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'droid',
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:commandCodeStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return commandCodeHookService.getStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'command-code',
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:grokStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return grokHookService.getStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'grok',
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:copilotStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return copilotHookService.getStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'copilot',
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:hermesStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return hermesHookService.getStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'hermes',
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:devinStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return devinHookService.getStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'devin',
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle('agentHooks:kimiStatus', (): AgentHookInstallStatus => {
|
||||
try {
|
||||
return kimiHookService.getStatus()
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'kimi',
|
||||
state: 'error',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import {
|
||||
type AgentTrustPreset,
|
||||
markCodexProjectTrusted,
|
||||
markCopilotFolderTrusted,
|
||||
markCursorWorkspaceTrusted
|
||||
markCodexProjectTrustedAsync,
|
||||
markCopilotFolderTrustedAsync,
|
||||
markCursorWorkspaceTrustedAsync
|
||||
} from '../agent-trust-presets'
|
||||
import { markRemoteAgentWorkspaceTrusted } from '../remote-agent-trust-presets'
|
||||
|
||||
@@ -38,11 +38,12 @@ export function registerAgentTrustHandlers(): void {
|
||||
workspacePath: args.workspacePath
|
||||
})
|
||||
} else if (args.preset === 'cursor') {
|
||||
markCursorWorkspaceTrusted(args.workspacePath)
|
||||
// Why: async twins keep a stalled workspace mount off the main thread (freeze #14).
|
||||
await markCursorWorkspaceTrustedAsync(args.workspacePath)
|
||||
} else if (args.preset === 'copilot') {
|
||||
markCopilotFolderTrusted(args.workspacePath)
|
||||
await markCopilotFolderTrustedAsync(args.workspacePath)
|
||||
} else if (args.preset === 'codex') {
|
||||
markCodexProjectTrusted(args.workspacePath)
|
||||
await markCodexProjectTrustedAsync(args.workspacePath)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: see Why above. The user can still accept the trust
|
||||
|
||||
@@ -34,7 +34,7 @@ export function registerCodexAccountHandlers(
|
||||
}
|
||||
forgetStaleCodexPanes(args.ptyIds.filter((ptyId): ptyId is string => typeof ptyId === 'string'))
|
||||
})
|
||||
ipcMain.handle('codexAccounts:list', () => codexAccounts.listAccounts())
|
||||
ipcMain.handle('codexAccounts:list', () => codexAccounts.listAccountsAsync())
|
||||
ipcMain.handle('codexAccounts:add', (_event, args?: CodexAccountAddTarget) =>
|
||||
codexAccounts.addAccount(args)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Regression guard for freeze #15: the diagnostics bundle preview is written
|
||||
// into app.getPath('temp'), which on some setups is redirected onto a network
|
||||
// share. A sync mkdir/write there blocks the Electron main thread.
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
import type { CollectedBundle } from '../observability/bundle'
|
||||
|
||||
const {
|
||||
syncFsCalls,
|
||||
gate,
|
||||
previewRoot,
|
||||
collectDiagnosticBundleMock,
|
||||
RECORDED_SYNC,
|
||||
RECORDED_ASYNC
|
||||
} = vi.hoisted(() => ({
|
||||
syncFsCalls: [] as string[],
|
||||
gate: { block: null as Promise<void> | null },
|
||||
previewRoot: { dir: '' },
|
||||
collectDiagnosticBundleMock: vi.fn(),
|
||||
RECORDED_SYNC: [
|
||||
'accessSync',
|
||||
'existsSync',
|
||||
'mkdirSync',
|
||||
'readFileSync',
|
||||
'renameSync',
|
||||
'statSync',
|
||||
'unlinkSync',
|
||||
'writeFileSync'
|
||||
] as const,
|
||||
RECORDED_ASYNC: ['mkdir', 'unlink', 'writeFile'] as const
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof NodeFs>('node:fs')
|
||||
const patched: Record<string, unknown> = { ...actual }
|
||||
for (const name of RECORDED_SYNC) {
|
||||
const real = actual[name] as (...args: unknown[]) => unknown
|
||||
patched[name] = (...args: unknown[]) => {
|
||||
syncFsCalls.push(`${name} ${String(args[0])}`)
|
||||
return real(...args)
|
||||
}
|
||||
}
|
||||
return patched
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof NodeFsPromises>('node:fs/promises')
|
||||
const patched: Record<string, unknown> = { ...actual }
|
||||
for (const name of RECORDED_ASYNC) {
|
||||
const real = actual[name] as (...args: unknown[]) => Promise<unknown>
|
||||
patched[name] = async (...args: unknown[]) => {
|
||||
if (gate.block) {
|
||||
await gate.block
|
||||
}
|
||||
return real(...args)
|
||||
}
|
||||
}
|
||||
return patched
|
||||
})
|
||||
|
||||
const handlers = new Map<string, (event: unknown, args?: unknown) => unknown>()
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => previewRoot.dir, getVersion: () => '1.2.3-test' },
|
||||
dialog: { showMessageBox: vi.fn(async () => ({ response: 1 })) },
|
||||
ipcMain: {
|
||||
handle: vi.fn((channel: string, handler: (event: unknown, args?: unknown) => unknown) => {
|
||||
handlers.set(channel, handler)
|
||||
})
|
||||
},
|
||||
shell: { openPath: vi.fn(async () => '') }
|
||||
}))
|
||||
|
||||
vi.mock('../observability', () => ({
|
||||
collectDiagnosticBundle: collectDiagnosticBundleMock,
|
||||
deleteDiagnosticBundle: vi.fn(),
|
||||
getDiagnosticsStatus: () => ({
|
||||
localFileEnabled: true,
|
||||
bundleEnabled: true,
|
||||
traceFilePath: '/tmp/main.trace.ndjson',
|
||||
traceFamilySize: 0
|
||||
}),
|
||||
uploadDiagnosticBundle: vi.fn()
|
||||
}))
|
||||
|
||||
function makeBundle(bundleSubmissionId: string): CollectedBundle {
|
||||
return {
|
||||
bundleSubmissionId,
|
||||
payload: '{"type":"bundle-header"}\n',
|
||||
bytes: 25,
|
||||
spanCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
function invoke(channel: string, args?: unknown): Promise<unknown> {
|
||||
const handler = handlers.get(channel)
|
||||
if (!handler) {
|
||||
throw new Error(`no handler for ${channel}`)
|
||||
}
|
||||
return Promise.resolve(handler(null, args))
|
||||
}
|
||||
|
||||
describe('diagnostics preview writes stay off main-thread sync fs', () => {
|
||||
beforeEach(async () => {
|
||||
previewRoot.dir = mkdtempSync(join(tmpdir(), 'orca-diagnostics-freeze-'))
|
||||
gate.block = null
|
||||
collectDiagnosticBundleMock.mockReset()
|
||||
vi.resetModules()
|
||||
handlers.clear()
|
||||
const { registerDiagnosticsHandlers } = await import('./diagnostics')
|
||||
registerDiagnosticsHandlers()
|
||||
syncFsCalls.length = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
gate.block = null
|
||||
rmSync(previewRoot.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('collects and discards a bundle preview without a synchronous fs call', async () => {
|
||||
collectDiagnosticBundleMock.mockReturnValue(makeBundle('bundleabcdefghijklmnop'))
|
||||
|
||||
await invoke('diagnostics:collectBundle', 30)
|
||||
await invoke('diagnostics:discardBundlePreview', 'bundleabcdefghijklmnop')
|
||||
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the event loop running while the preview write is parked', async () => {
|
||||
collectDiagnosticBundleMock.mockReturnValue(makeBundle('bundleabcdefghijklmnop'))
|
||||
gate.block = new Promise<void>(() => {})
|
||||
|
||||
let settled = false
|
||||
void invoke('diagnostics:collectBundle', 30).then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
let timerFired = false
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
timerFired = true
|
||||
resolve()
|
||||
}, 5)
|
||||
})
|
||||
|
||||
expect(timerFired).toBe(true)
|
||||
expect(settled).toBe(false)
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { CollectedBundle } from '../observability/bundle'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
|
||||
const handlers = new Map<string, (_event: unknown, ...args: unknown[]) => unknown>()
|
||||
|
||||
@@ -10,6 +11,9 @@ const {
|
||||
mkdirSyncMock,
|
||||
readFileSyncMock,
|
||||
writeFileSyncMock,
|
||||
mkdirMock,
|
||||
writeFileMock,
|
||||
unlinkMock,
|
||||
showMessageBoxMock,
|
||||
openPathMock,
|
||||
collectDiagnosticBundleMock,
|
||||
@@ -21,6 +25,9 @@ const {
|
||||
mkdirSyncMock: vi.fn(),
|
||||
readFileSyncMock: vi.fn(),
|
||||
writeFileSyncMock: vi.fn(),
|
||||
mkdirMock: vi.fn(async () => undefined),
|
||||
writeFileMock: vi.fn(async () => undefined),
|
||||
unlinkMock: vi.fn(async () => undefined),
|
||||
showMessageBoxMock: vi.fn(),
|
||||
openPathMock: vi.fn(),
|
||||
collectDiagnosticBundleMock: vi.fn(),
|
||||
@@ -39,6 +46,16 @@ vi.mock('node:fs', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof NodeFsPromises>('node:fs/promises')
|
||||
return {
|
||||
...actual,
|
||||
mkdir: mkdirMock,
|
||||
writeFile: writeFileMock,
|
||||
unlink: unlinkMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => '/tmp', getVersion: () => '1.2.3-test' },
|
||||
dialog: { showMessageBox: showMessageBoxMock },
|
||||
@@ -82,6 +99,12 @@ describe('diagnostics IPC handlers', () => {
|
||||
mkdirSyncMock.mockReset()
|
||||
readFileSyncMock.mockReset()
|
||||
writeFileSyncMock.mockReset()
|
||||
mkdirMock.mockReset()
|
||||
mkdirMock.mockResolvedValue(undefined)
|
||||
writeFileMock.mockReset()
|
||||
writeFileMock.mockResolvedValue(undefined)
|
||||
unlinkMock.mockReset()
|
||||
unlinkMock.mockResolvedValue(undefined)
|
||||
showMessageBoxMock.mockReset()
|
||||
showMessageBoxMock.mockResolvedValue({ response: 0 })
|
||||
openPathMock.mockReset()
|
||||
@@ -281,15 +304,18 @@ describe('diagnostics IPC handlers', () => {
|
||||
|
||||
await collect({}, 30)
|
||||
|
||||
expect(mkdirSyncMock).toHaveBeenCalledWith(expect.any(String), {
|
||||
expect(mkdirMock).toHaveBeenCalledWith(expect.any(String), {
|
||||
mode: 0o700,
|
||||
recursive: true
|
||||
})
|
||||
expect(writeFileSync).toHaveBeenCalledWith(
|
||||
expect(writeFileMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`${bundle.bundleSubmissionId}.ndjson`),
|
||||
bundle.payload,
|
||||
{ encoding: 'utf8', mode: 0o600 }
|
||||
)
|
||||
// The main thread must stay off the preview write entirely.
|
||||
expect(mkdirSyncMock).not.toHaveBeenCalled()
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns only bundle metadata from collection', async () => {
|
||||
@@ -302,7 +328,7 @@ describe('diagnostics IPC handlers', () => {
|
||||
collectDiagnosticBundleMock.mockReturnValue(bundle)
|
||||
const collect = handlers.get('diagnostics:collectBundle')!
|
||||
|
||||
expect(collect({}, 30)).toEqual({
|
||||
await expect(collect({}, 30)).resolves.toEqual({
|
||||
bundleSubmissionId: bundle.bundleSubmissionId,
|
||||
bytes: bundle.bytes,
|
||||
spanCount: bundle.spanCount
|
||||
|
||||
+31
-31
@@ -19,7 +19,7 @@
|
||||
// constant or env var and does the POST itself.
|
||||
|
||||
import { app, dialog, ipcMain, shell } from 'electron'
|
||||
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { mkdir, unlink, writeFile } from 'node:fs/promises'
|
||||
import { arch as osArch, platform as osPlatform, release as osRelease, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
@@ -55,7 +55,7 @@ const pendingBundles = new Map<string, PendingBundle>()
|
||||
function prunePendingBundles(now = Date.now()): void {
|
||||
for (const [id, pending] of pendingBundles) {
|
||||
if (now - pending.createdAtMs > PENDING_BUNDLE_TTL_MS) {
|
||||
deletePendingBundle(id)
|
||||
void deletePendingBundle(id)
|
||||
}
|
||||
}
|
||||
while (pendingBundles.size > MAX_PENDING_BUNDLES) {
|
||||
@@ -63,13 +63,15 @@ function prunePendingBundles(now = Date.now()): void {
|
||||
if (!oldest) {
|
||||
break
|
||||
}
|
||||
deletePendingBundle(oldest)
|
||||
void deletePendingBundle(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
function rememberBundle(bundle: CollectedBundle): void {
|
||||
deletePendingBundle(bundle.bundleSubmissionId)
|
||||
const previewFilePath = writeBundlePreviewFile(bundle)
|
||||
async function rememberBundle(bundle: CollectedBundle): Promise<void> {
|
||||
// Why: awaited so a re-collect under the same id cannot unlink the preview
|
||||
// file the write below is about to create.
|
||||
await deletePendingBundle(bundle.bundleSubmissionId)
|
||||
const previewFilePath = await writeBundlePreviewFile(bundle)
|
||||
pendingBundles.set(bundle.bundleSubmissionId, {
|
||||
bundle,
|
||||
createdAtMs: Date.now(),
|
||||
@@ -84,7 +86,7 @@ function rememberBundle(bundle: CollectedBundle): void {
|
||||
|
||||
function schedulePendingBundleExpiry(bundleSubmissionId: string): ReturnType<typeof setTimeout> {
|
||||
const timer = setTimeout(() => {
|
||||
deletePendingBundle(bundleSubmissionId)
|
||||
void deletePendingBundle(bundleSubmissionId)
|
||||
}, PENDING_BUNDLE_TTL_MS)
|
||||
if (typeof timer === 'object' && 'unref' in timer) {
|
||||
timer.unref()
|
||||
@@ -138,22 +140,24 @@ function getPendingPreviewFilePath(bundleSubmissionId: unknown): string {
|
||||
return pending.previewFilePath
|
||||
}
|
||||
|
||||
function discardPendingBundle(bundleSubmissionId: unknown): void {
|
||||
function discardPendingBundle(bundleSubmissionId: unknown): Promise<void> {
|
||||
if (
|
||||
typeof bundleSubmissionId !== 'string' ||
|
||||
!/^[A-Za-z0-9_-]{16,64}$/.test(bundleSubmissionId)
|
||||
) {
|
||||
throw new Error('bundleSubmissionId has invalid format')
|
||||
}
|
||||
deletePendingBundle(bundleSubmissionId)
|
||||
return deletePendingBundle(bundleSubmissionId)
|
||||
}
|
||||
|
||||
function deletePendingBundle(bundleSubmissionId: string): void {
|
||||
async function deletePendingBundle(bundleSubmissionId: string): Promise<void> {
|
||||
const pending = pendingBundles.get(bundleSubmissionId)
|
||||
if (pending) {
|
||||
clearTimeout(pending.ttlTimer)
|
||||
deletePreviewFile(pending.previewFilePath)
|
||||
// Why: drop the map entry before awaiting so a caller that re-checks after
|
||||
// the await never sees an entry whose preview file is already gone.
|
||||
pendingBundles.delete(bundleSubmissionId)
|
||||
await deletePreviewFile(pending.previewFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,22 +171,18 @@ function getPreviewDirectory(): string {
|
||||
return join(base, 'orca-diagnostic-bundle-previews')
|
||||
}
|
||||
|
||||
function writeBundlePreviewFile(bundle: CollectedBundle): string {
|
||||
async function writeBundlePreviewFile(bundle: CollectedBundle): Promise<string> {
|
||||
const previewDirectory = getPreviewDirectory()
|
||||
mkdirSync(previewDirectory, { mode: 0o700, recursive: true })
|
||||
await mkdir(previewDirectory, { mode: 0o700, recursive: true })
|
||||
const previewFilePath = join(previewDirectory, `${bundle.bundleSubmissionId}.ndjson`)
|
||||
writeFileSync(previewFilePath, bundle.payload, { encoding: 'utf8', mode: 0o600 })
|
||||
await writeFile(previewFilePath, bundle.payload, { encoding: 'utf8', mode: 0o600 })
|
||||
return previewFilePath
|
||||
}
|
||||
|
||||
function deletePreviewFile(filePath: string): void {
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath)
|
||||
}
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
async function deletePreviewFile(filePath: string): Promise<void> {
|
||||
// Unconditional unlink; ENOENT here is the same "already gone" outcome the
|
||||
// exists() probe used to buy, in one syscall instead of two.
|
||||
await unlink(filePath).catch(() => {})
|
||||
}
|
||||
|
||||
function isTicketId(value: unknown): value is string {
|
||||
@@ -211,7 +211,7 @@ export function registerDiagnosticsHandlers(): void {
|
||||
|
||||
ipcMain.handle(
|
||||
'diagnostics:collectBundle',
|
||||
(_event, lookbackMinutesIn: unknown): DiagnosticsBundlePreview => {
|
||||
async (_event, lookbackMinutesIn: unknown): Promise<DiagnosticsBundlePreview> => {
|
||||
// Consent gate: main is the consent enforcement boundary; the
|
||||
// renderer-side button-hide is UX, not security. A compromised or
|
||||
// malicious renderer must not be able to assemble a bundle when the
|
||||
@@ -235,7 +235,7 @@ export function registerDiagnosticsHandlers(): void {
|
||||
orcaChannel: resolveDiagnosticOrcaChannel(),
|
||||
...(lookbackMinutes !== undefined ? { lookbackMinutes } : {})
|
||||
})
|
||||
rememberBundle(bundle)
|
||||
await rememberBundle(bundle)
|
||||
return toBundlePreview(bundle)
|
||||
}
|
||||
)
|
||||
@@ -271,10 +271,7 @@ export function registerDiagnosticsHandlers(): void {
|
||||
payload,
|
||||
bundleSubmissionId: bundle.bundleSubmissionId
|
||||
})
|
||||
const uploadedPending = pendingBundles.get(bundle.bundleSubmissionId)
|
||||
if (uploadedPending) {
|
||||
deletePendingBundle(bundle.bundleSubmissionId)
|
||||
}
|
||||
await deletePendingBundle(bundle.bundleSubmissionId)
|
||||
return result
|
||||
}
|
||||
)
|
||||
@@ -291,9 +288,12 @@ export function registerDiagnosticsHandlers(): void {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('diagnostics:discardBundlePreview', (_event, bundleSubmissionId: unknown) => {
|
||||
discardPendingBundle(bundleSubmissionId)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'diagnostics:discardBundlePreview',
|
||||
async (_event, bundleSubmissionId: unknown) => {
|
||||
await discardPendingBundle(bundleSubmissionId)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('diagnostics:deleteBundle', async (_event, ticketId: unknown): Promise<void> => {
|
||||
if (!isTicketId(ticketId)) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
const syncFsCalls: string[] = []
|
||||
const realpathMock = vi.fn<(target: string) => Promise<string>>()
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- importOriginal requires inline import()
|
||||
const actual = await importOriginal<typeof import('node:fs')>()
|
||||
const recorded: Record<string, unknown> = { ...actual }
|
||||
for (const name of Object.keys(actual)) {
|
||||
if (!name.endsWith('Sync')) {
|
||||
continue
|
||||
}
|
||||
recorded[name] = (...args: unknown[]) => {
|
||||
syncFsCalls.push(`${name}(${String(args[0])})`)
|
||||
return (actual as unknown as Record<string, (...a: unknown[]) => unknown>)[name](...args)
|
||||
}
|
||||
}
|
||||
return recorded
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- importOriginal requires inline import()
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return { ...actual, realpath: (target: string) => realpathMock(target) }
|
||||
})
|
||||
|
||||
const { authorizeExternalPath, isPathAllowed, resolveAuthorizedPath } =
|
||||
await import('./filesystem-auth')
|
||||
|
||||
const emptyStore = { getRepos: () => [], getSettings: () => ({}) } as unknown as Store
|
||||
|
||||
function deferred(): { promise: Promise<string>; settle: (value: string) => void } {
|
||||
let settle: (value: string) => void = () => {}
|
||||
const promise = new Promise<string>((fulfill) => {
|
||||
settle = fulfill
|
||||
})
|
||||
return { promise, settle }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
syncFsCalls.length = 0
|
||||
realpathMock.mockReset()
|
||||
})
|
||||
|
||||
describe('authorizeExternalPath off the main thread', () => {
|
||||
it('canonicalizes without any synchronous fs syscall', async () => {
|
||||
const target = resolve('/mnt/stalled-share/notes.md')
|
||||
realpathMock.mockResolvedValue(target)
|
||||
|
||||
await authorizeExternalPath(target)
|
||||
|
||||
expect(syncFsCalls).toEqual([])
|
||||
expect(realpathMock).toHaveBeenCalledWith(target)
|
||||
})
|
||||
|
||||
it('keeps the event loop alive while canonicalization hangs on a stalled mount', async () => {
|
||||
const target = resolve('/mnt/stalled-share/hang.md')
|
||||
// Never resolves: models an uninterruptible realpath on a dead SMB mount.
|
||||
realpathMock.mockReturnValue(new Promise<string>(() => {}))
|
||||
|
||||
const authorization = authorizeExternalPath(target)
|
||||
const timerFired = await new Promise<boolean>((fulfill) => {
|
||||
setTimeout(() => fulfill(true), 5)
|
||||
})
|
||||
|
||||
expect(timerFired).toBe(true)
|
||||
expect(syncFsCalls).toEqual([])
|
||||
void authorization
|
||||
})
|
||||
|
||||
it('authorizes the resolved path synchronously, before canonicalization lands', () => {
|
||||
const target = resolve('/mnt/stalled-share/drag-drop.md')
|
||||
realpathMock.mockReturnValue(new Promise<string>(() => {}))
|
||||
|
||||
// No await: this is the ordering the next ipc read depends on.
|
||||
void authorizeExternalPath(target)
|
||||
|
||||
expect(isPathAllowed(target, emptyStore)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not deny a follow-up read that races the pending canonicalization', async () => {
|
||||
const target = resolve('/tmp/orca-race/report.md')
|
||||
const canonical = resolve('/private/tmp/orca-race/report.md')
|
||||
const pending = deferred()
|
||||
let authorizationRealpathPending = true
|
||||
realpathMock.mockImplementation((path) => {
|
||||
if (path === target && authorizationRealpathPending) {
|
||||
authorizationRealpathPending = false
|
||||
return pending.promise
|
||||
}
|
||||
return Promise.resolve(canonical)
|
||||
})
|
||||
|
||||
// ipcMain.handle cannot await this, so the canonical form lands late.
|
||||
void authorizeExternalPath(target)
|
||||
const read = resolveAuthorizedPath(target, emptyStore)
|
||||
setTimeout(() => pending.settle(canonical), 0)
|
||||
|
||||
await expect(read).resolves.toBe(canonical)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: keeps the filesystem-auth security boundary auditable end to end. */
|
||||
import { resolve, relative, dirname, basename, isAbsolute, sep } from 'node:path'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import type { Store } from '../persistence'
|
||||
import { isRepoRoot, listRepoWorktrees } from '../repo-worktrees'
|
||||
@@ -36,13 +35,44 @@ function rememberAuthorizedExternalPath(path: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function authorizeExternalPath(targetPath: string): void {
|
||||
// Why: realpath on a stalled mount used to block the main thread for tens of
|
||||
// seconds (freeze #10). Keyed by authorized path so a read only ever waits on
|
||||
// the canonicalization that gates it, not on some unrelated hung mount.
|
||||
const pendingExternalPathCanonicalizations = new Map<string, Promise<void>>()
|
||||
|
||||
export function authorizeExternalPath(targetPath: string): Promise<void> {
|
||||
const resolvedTarget = resolve(targetPath)
|
||||
// Ordering: authorize the textual form synchronously so the follow-up read this
|
||||
// call gates on is never denied while canonicalization is still in flight.
|
||||
rememberAuthorizedExternalPath(resolvedTarget)
|
||||
try {
|
||||
// Why: macOS canonicalizes /tmp to /private/tmp during read authorization.
|
||||
rememberAuthorizedExternalPath(realpathSync(resolvedTarget))
|
||||
} catch {}
|
||||
// Why: macOS canonicalizes /tmp to /private/tmp during read authorization.
|
||||
const canonicalization: Promise<void> = realpath(resolvedTarget)
|
||||
.then(rememberAuthorizedExternalPath, () => {})
|
||||
.finally(() => {
|
||||
if (pendingExternalPathCanonicalizations.get(resolvedTarget) === canonicalization) {
|
||||
pendingExternalPathCanonicalizations.delete(resolvedTarget)
|
||||
}
|
||||
})
|
||||
pendingExternalPathCanonicalizations.set(resolvedTarget, canonicalization)
|
||||
return canonicalization
|
||||
}
|
||||
|
||||
/** Returns true once every in-flight authorization covering sourcePath has landed. */
|
||||
async function awaitPendingCanonicalizationsFor(sourcePath: string): Promise<boolean> {
|
||||
if (pendingExternalPathCanonicalizations.size === 0) {
|
||||
return false
|
||||
}
|
||||
const gating: Promise<void>[] = []
|
||||
for (const [authorizedPath, canonicalization] of pendingExternalPathCanonicalizations) {
|
||||
if (isDescendantOrEqual(sourcePath, authorizedPath)) {
|
||||
gating.push(canonicalization)
|
||||
}
|
||||
}
|
||||
if (gating.length === 0) {
|
||||
return false
|
||||
}
|
||||
await Promise.all(gating)
|
||||
return true
|
||||
}
|
||||
|
||||
export function invalidateAuthorizedRootsCache(): void {
|
||||
@@ -389,6 +419,15 @@ async function isPathAllowedIncludingRegisteredWorktrees(
|
||||
return true
|
||||
}
|
||||
|
||||
// Why: authorizeExternalPath returns before its realpath lands, and callers that
|
||||
// can't await it (ipcMain.handle) would otherwise deny the read they just granted.
|
||||
if (
|
||||
(await awaitPendingCanonicalizationsFor(options.canonicalSourcePath ?? targetPath)) &&
|
||||
isPathAllowed(targetPath, store)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
await ensureAuthorizedRootsCache(store)
|
||||
|
||||
// Why: linked worktrees are already git-trusted; reuse the cached root index so reads don't spawn `git worktree list` each time.
|
||||
|
||||
@@ -132,7 +132,7 @@ import { registerLocalLogTailHandlers } from './local-log-tail'
|
||||
import { localLogFileIdentity } from '../ai-vault/local-log-tail-reader'
|
||||
import { sanitizeLocalDownloadFilename } from '../local-download-filename'
|
||||
import { registerFilesystemDownloadFolderHandlers } from './filesystem-download-folder'
|
||||
import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories'
|
||||
import { getWorktreeSharedLinkPathsAsync } from '../git/worktree-shared-directories'
|
||||
import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation'
|
||||
|
||||
// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files.
|
||||
@@ -1125,7 +1125,7 @@ export function registerFilesystemHandlers(
|
||||
// handler, and the scan walks every repo's worktree meta.
|
||||
const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath)
|
||||
const gitOptions = getLocalGitOptionsForRepo(store, repo)
|
||||
const sharedLinkPaths = repo ? getWorktreeSharedLinkPaths(repo) : []
|
||||
const sharedLinkPaths = repo ? await getWorktreeSharedLinkPathsAsync(repo) : []
|
||||
return await getStatus(worktreePath, {
|
||||
...options,
|
||||
...gitOptions,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { getGrokAccountStatus } from '../grok-accounts/status'
|
||||
import { readGrokAuthSessionAsync, toGrokAccountStatus } from '../rate-limits/grok-auth'
|
||||
|
||||
export function registerGrokAccountHandlers(): void {
|
||||
ipcMain.handle('grokAccounts:getStatus', () => getGrokAccountStatus())
|
||||
ipcMain.handle('grokAccounts:getStatus', async () =>
|
||||
toGrokAccountStatus(await readGrokAuthSessionAsync())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { getHostedReviewForBranch } from '../source-control/hosted-review'
|
||||
import { resolveRegisteredWorktreePath } from './filesystem-auth'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
||||
import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories'
|
||||
import { getWorktreeSharedLinkPathsAsync } from '../git/worktree-shared-directories'
|
||||
|
||||
function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string): Repo {
|
||||
if (repoId) {
|
||||
@@ -127,7 +127,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector
|
||||
// Remote creation never materializes them, and `repo.path` is a path on the
|
||||
// remote host — reading it locally would resolve an unrelated `orca.yaml`.
|
||||
// Not dead code: SSH ignores these, so this only prevents that read and a poisoned cache entry.
|
||||
const sharedLinkPaths = repo.connectionId ? [] : getWorktreeSharedLinkPaths(repo)
|
||||
const sharedLinkPaths = repo.connectionId ? [] : await getWorktreeSharedLinkPathsAsync(repo)
|
||||
const executionOptions =
|
||||
Object.keys(localGitOptions).length > 0 || sharedLinkPaths.length > 0
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// Regression guard for freeze #16: ~/.orca can live on a network home
|
||||
// directory, where a sync read/write from a keybindings IPC handler blocks the
|
||||
// Electron main thread and the whole app stops repainting.
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
import type { KeybindingService } from '../keybindings/keybinding-service'
|
||||
|
||||
/** One-shot park on a named fs op, so a test can interleave an overlapping call. */
|
||||
type FsHold = { op: string; release: Promise<void> }
|
||||
|
||||
const { syncFsCalls, asyncFsCalls, gate, RECORDED_SYNC, RECORDED_ASYNC } = vi.hoisted(() => ({
|
||||
syncFsCalls: [] as string[],
|
||||
asyncFsCalls: [] as string[],
|
||||
gate: { block: null as Promise<void> | null, hold: null as FsHold | null },
|
||||
RECORDED_SYNC: [
|
||||
'accessSync',
|
||||
'existsSync',
|
||||
'mkdirSync',
|
||||
'readFileSync',
|
||||
'renameSync',
|
||||
'statSync',
|
||||
'unlinkSync',
|
||||
'writeFileSync'
|
||||
] as const,
|
||||
RECORDED_ASYNC: ['mkdir', 'readFile', 'rename', 'unlink', 'writeFile'] as const
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof NodeFs>('node:fs')
|
||||
const patched: Record<string, unknown> = { ...actual }
|
||||
for (const name of RECORDED_SYNC) {
|
||||
const real = actual[name] as (...args: unknown[]) => unknown
|
||||
patched[name] = (...args: unknown[]) => {
|
||||
syncFsCalls.push(`${name} ${String(args[0])}`)
|
||||
return real(...args)
|
||||
}
|
||||
}
|
||||
return patched
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof NodeFsPromises>('node:fs/promises')
|
||||
const patched: Record<string, unknown> = { ...actual }
|
||||
for (const name of RECORDED_ASYNC) {
|
||||
const real = actual[name] as (...args: unknown[]) => Promise<unknown>
|
||||
patched[name] = async (...args: unknown[]) => {
|
||||
asyncFsCalls.push(`${name} ${String(args[0])}`)
|
||||
const hold = gate.hold
|
||||
if (hold?.op === name) {
|
||||
// The syscall sees the pre-write state; only its resolution is delayed.
|
||||
gate.hold = null
|
||||
try {
|
||||
return await real(...args)
|
||||
} finally {
|
||||
await hold.release
|
||||
}
|
||||
}
|
||||
if (gate.block) {
|
||||
await gate.block
|
||||
}
|
||||
return real(...args)
|
||||
}
|
||||
}
|
||||
return patched
|
||||
})
|
||||
|
||||
const handlers = new Map<string, (event: unknown, args?: unknown) => unknown>()
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { getAllWindows: () => [] },
|
||||
ipcMain: {
|
||||
handle: vi.fn((channel: string, handler: (event: unknown, args?: unknown) => unknown) => {
|
||||
handlers.set(channel, handler)
|
||||
})
|
||||
},
|
||||
shell: { openPath: vi.fn(async () => ''), showItemInFolder: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: vi.fn() }))
|
||||
vi.mock('../menu/register-app-menu', () => ({ rebuildAppMenu: vi.fn() }))
|
||||
|
||||
async function setup(home: string): Promise<KeybindingService> {
|
||||
vi.resetModules()
|
||||
handlers.clear()
|
||||
const { KeybindingService } = await import('../keybindings/keybinding-service')
|
||||
const { registerKeybindingHandlers } = await import('./keybindings')
|
||||
// The constructor is the sanctioned sync bootstrap: it runs pre-window.
|
||||
const service = new KeybindingService({ homePath: home, platform: 'linux' })
|
||||
registerKeybindingHandlers(service)
|
||||
return service
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function invoke(channel: string, args?: unknown): Promise<unknown> {
|
||||
const handler = handlers.get(channel)
|
||||
if (!handler) {
|
||||
throw new Error(`no handler for ${channel}`)
|
||||
}
|
||||
return Promise.resolve(handler(null, args))
|
||||
}
|
||||
|
||||
describe('keybinding IPC handlers stay off main-thread sync fs', () => {
|
||||
let home: string
|
||||
let service: KeybindingService
|
||||
|
||||
beforeEach(async () => {
|
||||
home = mkdtempSync(join(tmpdir(), 'orca-keybindings-freeze-'))
|
||||
gate.block = null
|
||||
gate.hold = null
|
||||
service = await setup(home)
|
||||
syncFsCalls.length = 0
|
||||
asyncFsCalls.length = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
gate.block = null
|
||||
gate.hold = null
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('serves keybindings:get from the warm snapshot with no fs call', async () => {
|
||||
await expect(invoke('keybindings:get')).resolves.toMatchObject({ platform: 'linux' })
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('reloads without a synchronous fs call', async () => {
|
||||
await expect(invoke('keybindings:reload')).resolves.toMatchObject({ platform: 'linux' })
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('creates the keybindings file without a synchronous fs call', async () => {
|
||||
const snapshot = (await invoke('keybindings:ensureFile')) as { exists: boolean }
|
||||
expect(snapshot.exists).toBe(true)
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('creates the keybindings file through a temp file, never in place', async () => {
|
||||
const path = join(home, '.orca', 'keybindings.json')
|
||||
|
||||
await invoke('keybindings:ensureFile')
|
||||
|
||||
// A torn in-place write leaves unparseable JSON at the real path, and every
|
||||
// later write then refuses to replace a file it could not parse.
|
||||
expect(asyncFsCalls).not.toContain(`writeFile ${path}`)
|
||||
expect(asyncFsCalls).toContain(`writeFile ${path}.async.tmp`)
|
||||
expect(asyncFsCalls).toContain(`rename ${path}.async.tmp`)
|
||||
})
|
||||
|
||||
it('does not let an overlapping reload install the pre-write snapshot', async () => {
|
||||
await invoke('keybindings:ensureFile')
|
||||
let releaseRead = (): void => {}
|
||||
gate.hold = {
|
||||
op: 'readFile',
|
||||
release: new Promise<void>((resolve) => {
|
||||
releaseRead = resolve
|
||||
})
|
||||
}
|
||||
|
||||
const reloaded = service.reloadAsync()
|
||||
const written = service.setActionBindingsAsync('terminal.search', ['Ctrl+Shift+F'])
|
||||
// Release once the write settles; the timeout keeps the ordered
|
||||
// implementation (where the write queues behind the reload) from deadlocking.
|
||||
void Promise.race([written.catch(() => {}), delay(50)]).then(releaseRead)
|
||||
await Promise.all([reloaded, written])
|
||||
|
||||
expect(service.getSnapshot().overrides['terminal.search']).toEqual(['Ctrl+Shift+F'])
|
||||
})
|
||||
|
||||
it('writes a shortcut override without a synchronous fs call', async () => {
|
||||
const snapshot = (await invoke('keybindings:setAction', {
|
||||
actionId: 'terminal.search',
|
||||
bindings: ['Ctrl+Shift+F']
|
||||
})) as { overrides: Record<string, string[]> }
|
||||
|
||||
expect(snapshot.overrides['terminal.search']).toEqual(['Ctrl+Shift+F'])
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('serializes overlapping shortcut writes so neither override is lost', async () => {
|
||||
await Promise.all([
|
||||
invoke('keybindings:setAction', { actionId: 'terminal.search', bindings: ['Ctrl+Shift+F'] }),
|
||||
invoke('keybindings:setAction', { actionId: 'view.tasks', bindings: ['Ctrl+Alt+T'] })
|
||||
])
|
||||
|
||||
const snapshot = (await invoke('keybindings:reload')) as {
|
||||
overrides: Record<string, string[]>
|
||||
}
|
||||
expect(snapshot.overrides['terminal.search']).toEqual(['Ctrl+Shift+F'])
|
||||
expect(snapshot.overrides['view.tasks']).toEqual(['Ctrl+Alt+T'])
|
||||
})
|
||||
|
||||
it('keeps the event loop running while a keybindings write is parked', async () => {
|
||||
gate.block = new Promise<void>(() => {})
|
||||
|
||||
let settled = false
|
||||
void invoke('keybindings:setAction', {
|
||||
actionId: 'terminal.search',
|
||||
bindings: ['Ctrl+Shift+F']
|
||||
}).then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
let timerFired = false
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
timerFired = true
|
||||
resolve()
|
||||
}, 5)
|
||||
})
|
||||
|
||||
expect(timerFired).toBe(true)
|
||||
expect(settled).toBe(false)
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -68,19 +68,19 @@ describe('registerKeybindingHandlers', () => {
|
||||
showItemInFolderMock.mockReset()
|
||||
})
|
||||
|
||||
it('authorizes the keybindings file for in-app editing when ensuring it exists', () => {
|
||||
registerKeybindingHandlers({ ensureFile: vi.fn(() => snapshot) } as never)
|
||||
it('authorizes the keybindings file for in-app editing when ensuring it exists', async () => {
|
||||
registerKeybindingHandlers({ ensureFileAsync: vi.fn(async () => snapshot) } as never)
|
||||
|
||||
expect(getHandler('keybindings:ensureFile')()).toBe(snapshot)
|
||||
await expect(getHandler('keybindings:ensureFile')()).resolves.toBe(snapshot)
|
||||
expect(authorizeExternalPathMock).toHaveBeenCalledWith(snapshot.path)
|
||||
})
|
||||
|
||||
it('reconciles plugin command conflicts after a shortcut edit', () => {
|
||||
it('reconciles plugin command conflicts after a shortcut edit', async () => {
|
||||
const onChanged = vi.fn()
|
||||
const setActionBindings = vi.fn(() => snapshot)
|
||||
registerKeybindingHandlers({ setActionBindings } as never, onChanged)
|
||||
const setActionBindingsAsync = vi.fn(async () => snapshot)
|
||||
registerKeybindingHandlers({ setActionBindingsAsync } as never, onChanged)
|
||||
|
||||
expect(
|
||||
await expect(
|
||||
getHandler('keybindings:setAction')(
|
||||
{},
|
||||
{
|
||||
@@ -88,13 +88,13 @@ describe('registerKeybindingHandlers', () => {
|
||||
bindings: ['Mod+Shift+T']
|
||||
}
|
||||
)
|
||||
).toBe(snapshot)
|
||||
).resolves.toBe(snapshot)
|
||||
expect(onChanged).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('authorizes the keybindings file before opening it outside Orca', async () => {
|
||||
openPathMock.mockResolvedValue('')
|
||||
registerKeybindingHandlers({ ensureFile: vi.fn(() => snapshot) } as never)
|
||||
registerKeybindingHandlers({ ensureFileAsync: vi.fn(async () => snapshot) } as never)
|
||||
|
||||
await expect(getHandler('keybindings:openFile')()).resolves.toBe(snapshot)
|
||||
expect(authorizeExternalPathMock).toHaveBeenCalledWith(snapshot.path)
|
||||
|
||||
+13
-10
@@ -17,10 +17,13 @@ export function registerKeybindingHandlers(
|
||||
service: KeybindingService,
|
||||
onChanged?: () => void
|
||||
): void {
|
||||
ipcMain.handle('keybindings:get', () => service.getSnapshot())
|
||||
// Why async twins everywhere below: ~/.orca can sit on a network home
|
||||
// directory, where a sync read/write blocks the main thread and freezes the
|
||||
// whole app until the mount answers.
|
||||
ipcMain.handle('keybindings:get', () => service.getSnapshotAsync())
|
||||
|
||||
ipcMain.handle('keybindings:ensureFile', () => {
|
||||
const snapshot = service.ensureFile()
|
||||
ipcMain.handle('keybindings:ensureFile', async () => {
|
||||
const snapshot = await service.ensureFileAsync()
|
||||
// Why: keybindings.json lives in Orca's app config directory, not inside a
|
||||
// workspace. Opening it in the editor still needs normal fs IPC access.
|
||||
authorizeExternalPath(snapshot.path)
|
||||
@@ -31,23 +34,23 @@ export function registerKeybindingHandlers(
|
||||
|
||||
ipcMain.handle(
|
||||
'keybindings:setAction',
|
||||
(_event, args: { actionId: KeybindingActionId; bindings: string[] | null }) => {
|
||||
const snapshot = service.setActionBindings(args.actionId, args.bindings)
|
||||
async (_event, args: { actionId: KeybindingActionId; bindings: string[] | null }) => {
|
||||
const snapshot = await service.setActionBindingsAsync(args.actionId, args.bindings)
|
||||
broadcastKeybindingsChanged(snapshot)
|
||||
onChanged?.()
|
||||
return snapshot
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('keybindings:reload', () => {
|
||||
const snapshot = service.reload()
|
||||
ipcMain.handle('keybindings:reload', async () => {
|
||||
const snapshot = await service.reloadAsync()
|
||||
broadcastKeybindingsChanged(snapshot)
|
||||
onChanged?.()
|
||||
return snapshot
|
||||
})
|
||||
|
||||
ipcMain.handle('keybindings:openFile', async () => {
|
||||
const snapshot = service.ensureFile()
|
||||
const snapshot = await service.ensureFileAsync()
|
||||
authorizeExternalPath(snapshot.path)
|
||||
const error = await shell.openPath(snapshot.path)
|
||||
if (error) {
|
||||
@@ -56,8 +59,8 @@ export function registerKeybindingHandlers(
|
||||
return snapshot
|
||||
})
|
||||
|
||||
ipcMain.handle('keybindings:revealFile', () => {
|
||||
const snapshot = service.ensureFile()
|
||||
ipcMain.handle('keybindings:revealFile', async () => {
|
||||
const snapshot = await service.ensureFileAsync()
|
||||
authorizeExternalPath(snapshot.path)
|
||||
shell.showItemInFolder(snapshot.path)
|
||||
return snapshot
|
||||
|
||||
@@ -12,15 +12,15 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const saveMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn())
|
||||
const clearMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn())
|
||||
const hasMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn(() => false))
|
||||
const saveMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
const clearMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
const hasMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn(() => Promise.resolve(false)))
|
||||
const clearMiniMaxSessionCookieJarMock = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
|
||||
vi.mock('../minimax/minimax-cookie-store', () => ({
|
||||
saveMiniMaxSessionCookie: saveMiniMaxSessionCookieMock,
|
||||
clearMiniMaxSessionCookie: clearMiniMaxSessionCookieMock,
|
||||
hasMiniMaxSessionCookie: hasMiniMaxSessionCookieMock
|
||||
saveMiniMaxSessionCookieAsync: saveMiniMaxSessionCookieMock,
|
||||
clearMiniMaxSessionCookieAsync: clearMiniMaxSessionCookieMock,
|
||||
hasMiniMaxSessionCookieAsync: hasMiniMaxSessionCookieMock
|
||||
}))
|
||||
|
||||
vi.mock('../rate-limits/minimax-request-context', () => ({
|
||||
@@ -57,11 +57,13 @@ describe('registerMiniMaxCredentialsHandlers', () => {
|
||||
beforeEach(() => {
|
||||
ipcState.handleHandlers.clear()
|
||||
saveMiniMaxSessionCookieMock.mockReset()
|
||||
saveMiniMaxSessionCookieMock.mockResolvedValue(undefined)
|
||||
clearMiniMaxSessionCookieMock.mockReset()
|
||||
clearMiniMaxSessionCookieMock.mockResolvedValue(undefined)
|
||||
clearMiniMaxSessionCookieJarMock.mockReset()
|
||||
clearMiniMaxSessionCookieJarMock.mockResolvedValue(undefined)
|
||||
hasMiniMaxSessionCookieMock.mockReset()
|
||||
hasMiniMaxSessionCookieMock.mockReturnValue(false)
|
||||
hasMiniMaxSessionCookieMock.mockResolvedValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -76,14 +78,14 @@ describe('registerMiniMaxCredentialsHandlers', () => {
|
||||
})
|
||||
|
||||
it('returns the configured state on getStatus from the cookie store', async () => {
|
||||
hasMiniMaxSessionCookieMock.mockReturnValue(true)
|
||||
hasMiniMaxSessionCookieMock.mockResolvedValue(true)
|
||||
registerMiniMaxCredentialsHandlers(null)
|
||||
const status = await invoke<{ configured: boolean }>('minimaxCredentials:getStatus')
|
||||
expect(status).toEqual({ configured: true })
|
||||
})
|
||||
|
||||
it('persists the cookie and reports configured after saveCookie', async () => {
|
||||
hasMiniMaxSessionCookieMock.mockReturnValueOnce(true)
|
||||
hasMiniMaxSessionCookieMock.mockResolvedValueOnce(true)
|
||||
registerMiniMaxCredentialsHandlers(null)
|
||||
const status = await invoke<{ configured: boolean }>(
|
||||
'minimaxCredentials:saveCookie',
|
||||
@@ -111,7 +113,7 @@ describe('registerMiniMaxCredentialsHandlers', () => {
|
||||
|
||||
it('clears the cookie and triggers a refresh on clearCookie', async () => {
|
||||
const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
|
||||
hasMiniMaxSessionCookieMock.mockReturnValueOnce(false)
|
||||
hasMiniMaxSessionCookieMock.mockResolvedValueOnce(false)
|
||||
registerMiniMaxCredentialsHandlers(service as RateLimitService)
|
||||
const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie')
|
||||
expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1)
|
||||
@@ -126,7 +128,7 @@ describe('registerMiniMaxCredentialsHandlers', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
|
||||
clearMiniMaxSessionCookieJarMock.mockRejectedValueOnce(new Error('jar boom'))
|
||||
hasMiniMaxSessionCookieMock.mockReturnValueOnce(false)
|
||||
hasMiniMaxSessionCookieMock.mockResolvedValueOnce(false)
|
||||
registerMiniMaxCredentialsHandlers(service as RateLimitService)
|
||||
|
||||
const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie')
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import {
|
||||
clearMiniMaxSessionCookie,
|
||||
hasMiniMaxSessionCookie,
|
||||
saveMiniMaxSessionCookie
|
||||
clearMiniMaxSessionCookieAsync,
|
||||
hasMiniMaxSessionCookieAsync,
|
||||
saveMiniMaxSessionCookieAsync
|
||||
} from '../minimax/minimax-cookie-store'
|
||||
import { clearMiniMaxSessionCookieJar } from '../rate-limits/minimax-request-context'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
@@ -11,8 +11,8 @@ export type MiniMaxCredentialsStatus = {
|
||||
configured: boolean
|
||||
}
|
||||
|
||||
function getMiniMaxCredentialsStatus(): MiniMaxCredentialsStatus {
|
||||
return { configured: hasMiniMaxSessionCookie() }
|
||||
async function getMiniMaxCredentialsStatus(): Promise<MiniMaxCredentialsStatus> {
|
||||
return { configured: await hasMiniMaxSessionCookieAsync() }
|
||||
}
|
||||
|
||||
// Why: fire-and-forget — callers get the persisted cookie status immediately;
|
||||
@@ -29,18 +29,18 @@ function refreshAfterMiniMaxCredentialChange(
|
||||
|
||||
export function registerMiniMaxCredentialsHandlers(rateLimits: RateLimitService | null): void {
|
||||
ipcMain.handle('minimaxCredentials:getStatus', () => getMiniMaxCredentialsStatus())
|
||||
ipcMain.handle('minimaxCredentials:saveCookie', (_event, cookie: string) => {
|
||||
ipcMain.handle('minimaxCredentials:saveCookie', async (_event, cookie: string) => {
|
||||
// Validate the IPC argument in the main process; the renderer-declared type
|
||||
// is compile-time only and the value arrives as unknown over IPC.
|
||||
if (typeof cookie !== 'string') {
|
||||
throw new Error('MiniMax session cookie must be a string')
|
||||
}
|
||||
saveMiniMaxSessionCookie(cookie)
|
||||
await saveMiniMaxSessionCookieAsync(cookie)
|
||||
refreshAfterMiniMaxCredentialChange(rateLimits, 'save')
|
||||
return getMiniMaxCredentialsStatus()
|
||||
})
|
||||
ipcMain.handle('minimaxCredentials:clearCookie', async () => {
|
||||
clearMiniMaxSessionCookie()
|
||||
await clearMiniMaxSessionCookieAsync()
|
||||
try {
|
||||
await clearMiniMaxSessionCookieJar()
|
||||
} catch (error) {
|
||||
|
||||
@@ -36,10 +36,10 @@ vi.mock('../tray/system-tray', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../orca-profiles/profile-index-store', () => ({
|
||||
createLocalOrcaProfile: vi.fn(),
|
||||
getOrcaProfileListState: vi.fn(),
|
||||
seedNewOrcaProfileTelemetryConsent: vi.fn(),
|
||||
setActiveOrcaProfile: vi.fn()
|
||||
createLocalOrcaProfileAsync: vi.fn(),
|
||||
getOrcaProfileListStateAsync: vi.fn(),
|
||||
seedNewOrcaProfileTelemetryConsentAsync: vi.fn(),
|
||||
setActiveOrcaProfileAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../orca-profiles/profile-project-transfer', () => ({
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Renderer-supplied IPC payloads for the orcaProfiles channels are untrusted;
|
||||
// narrow every field at the boundary before it reaches the profile store.
|
||||
import type {
|
||||
CreateCloudLinkedOrcaProfileArgs,
|
||||
FindOrcaProfileProjectsByPathArgs,
|
||||
SelectOrcaProfileOrgArgs,
|
||||
SwitchOrcaProfileArgs,
|
||||
TransferOrcaProfileProjectArgs
|
||||
} from '../../shared/orca-profiles'
|
||||
import { normalizeExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
export function profileIdFromArgs(args: unknown): string {
|
||||
if (
|
||||
!args ||
|
||||
typeof args !== 'object' ||
|
||||
typeof (args as SwitchOrcaProfileArgs).profileId !== 'string'
|
||||
) {
|
||||
throw new Error('invalid_orca_profile_id')
|
||||
}
|
||||
const profileId = (args as SwitchOrcaProfileArgs).profileId.trim()
|
||||
if (!profileId) {
|
||||
throw new Error('invalid_orca_profile_id')
|
||||
}
|
||||
return profileId
|
||||
}
|
||||
|
||||
export function transferProjectArgsFromUnknown(args: unknown): TransferOrcaProfileProjectArgs {
|
||||
if (!args || typeof args !== 'object') {
|
||||
throw new Error('invalid_orca_profile_project_transfer')
|
||||
}
|
||||
const candidate = args as TransferOrcaProfileProjectArgs
|
||||
const sourceProfileId = candidate.sourceProfileId?.trim()
|
||||
const targetProfileId = candidate.targetProfileId?.trim()
|
||||
const repoId = candidate.repoId?.trim()
|
||||
const mode = candidate.mode
|
||||
if (!sourceProfileId || !targetProfileId || !repoId || (mode !== 'move' && mode !== 'copy')) {
|
||||
throw new Error('invalid_orca_profile_project_transfer')
|
||||
}
|
||||
return {
|
||||
sourceProfileId,
|
||||
targetProfileId,
|
||||
repoId,
|
||||
mode
|
||||
}
|
||||
}
|
||||
|
||||
export function findProjectsByPathArgsFromUnknown(
|
||||
args: unknown
|
||||
): FindOrcaProfileProjectsByPathArgs {
|
||||
if (!args || typeof args !== 'object') {
|
||||
throw new Error('invalid_orca_profile_project_path')
|
||||
}
|
||||
const candidate = args as FindOrcaProfileProjectsByPathArgs
|
||||
const path = typeof candidate.path === 'string' ? candidate.path.trim() : ''
|
||||
if (!path) {
|
||||
throw new Error('invalid_orca_profile_project_path')
|
||||
}
|
||||
let executionHostId: FindOrcaProfileProjectsByPathArgs['executionHostId'] = null
|
||||
if (candidate.executionHostId !== null && candidate.executionHostId !== undefined) {
|
||||
if (typeof candidate.executionHostId !== 'string') {
|
||||
throw new Error('invalid_orca_profile_project_path')
|
||||
}
|
||||
executionHostId = normalizeExecutionHostId(candidate.executionHostId)
|
||||
if (!executionHostId) {
|
||||
throw new Error('invalid_orca_profile_project_path')
|
||||
}
|
||||
}
|
||||
return {
|
||||
path,
|
||||
connectionId:
|
||||
typeof candidate.connectionId === 'string' ? candidate.connectionId.trim() || null : null,
|
||||
executionHostId,
|
||||
excludeProfileId:
|
||||
typeof candidate.excludeProfileId === 'string'
|
||||
? candidate.excludeProfileId.trim() || null
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
export function orgIdFromUnknown(args: unknown): string {
|
||||
if (!args || typeof args !== 'object') {
|
||||
throw new Error('invalid_orca_profile_org_selection')
|
||||
}
|
||||
const orgId = (args as SelectOrcaProfileOrgArgs).orgId?.trim()
|
||||
if (!orgId) {
|
||||
throw new Error('invalid_orca_profile_org_selection')
|
||||
}
|
||||
return orgId
|
||||
}
|
||||
|
||||
export function createCloudLinkedProfileArgsFromUnknown(
|
||||
args: unknown
|
||||
): CreateCloudLinkedOrcaProfileArgs {
|
||||
if (!args || typeof args !== 'object') {
|
||||
return {}
|
||||
}
|
||||
const candidate = args as CreateCloudLinkedOrcaProfileArgs
|
||||
const orgId = typeof candidate.orgId === 'string' ? candidate.orgId.trim() : undefined
|
||||
const name = typeof candidate.name === 'string' ? candidate.name.trim() : undefined
|
||||
return {
|
||||
...(orgId ? { orgId } : {}),
|
||||
...(name ? { name } : {})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
// Regression guard for the app-freeze class: a synchronous fs syscall on the
|
||||
// Electron main thread. When userData sits on a stalled SMB/NFS mount, mkdirSync
|
||||
// enters an uninterruptible wait and the app stops repainting (freeze #36), and
|
||||
// orcaProfiles:list sits on the app-startup chain.
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import type {
|
||||
createLocalOrcaProfile,
|
||||
ensureActiveOrcaProfile,
|
||||
seedNewOrcaProfileTelemetryConsentAsync
|
||||
} from '../orca-profiles/profile-index-store'
|
||||
|
||||
type ProfileIndexStoreModule = {
|
||||
createLocalOrcaProfile: typeof createLocalOrcaProfile
|
||||
ensureActiveOrcaProfile: typeof ensureActiveOrcaProfile
|
||||
seedNewOrcaProfileTelemetryConsentAsync: typeof seedNewOrcaProfileTelemetryConsentAsync
|
||||
}
|
||||
|
||||
/** One-shot park on a named fs op, so a test can interleave a sync writer. */
|
||||
type FsHold = { op: string; entered: () => void; release: Promise<void> }
|
||||
|
||||
const {
|
||||
syncFsCalls,
|
||||
asyncFsCalls,
|
||||
gate,
|
||||
userData,
|
||||
relaunchAppMock,
|
||||
RECORDED_SYNC,
|
||||
RECORDED_ASYNC
|
||||
} = vi.hoisted(() => ({
|
||||
syncFsCalls: [] as string[],
|
||||
asyncFsCalls: [] as string[],
|
||||
gate: { block: null as Promise<void> | null, hold: null as FsHold | null },
|
||||
userData: { dir: '' },
|
||||
relaunchAppMock: vi.fn(),
|
||||
RECORDED_SYNC: [
|
||||
'accessSync',
|
||||
'copyFileSync',
|
||||
'existsSync',
|
||||
'mkdirSync',
|
||||
'readFileSync',
|
||||
'renameSync',
|
||||
'statSync',
|
||||
'unlinkSync',
|
||||
'writeFileSync'
|
||||
] as const,
|
||||
RECORDED_ASYNC: [
|
||||
'access',
|
||||
'copyFile',
|
||||
'mkdir',
|
||||
'readFile',
|
||||
'rename',
|
||||
'stat',
|
||||
'unlink',
|
||||
'writeFile'
|
||||
] as const
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof NodeFs>('node:fs')
|
||||
const patched: Record<string, unknown> = { ...actual }
|
||||
for (const name of RECORDED_SYNC) {
|
||||
const real = actual[name] as (...args: unknown[]) => unknown
|
||||
patched[name] = (...args: unknown[]) => {
|
||||
syncFsCalls.push(`${name} ${String(args[0])}`)
|
||||
return real(...args)
|
||||
}
|
||||
}
|
||||
return patched
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof NodeFsPromises>('node:fs/promises')
|
||||
const patched: Record<string, unknown> = { ...actual }
|
||||
for (const name of RECORDED_ASYNC) {
|
||||
const real = actual[name] as (...args: unknown[]) => Promise<unknown>
|
||||
patched[name] = async (...args: unknown[]) => {
|
||||
asyncFsCalls.push(`${name} ${String(args[0])}`)
|
||||
const hold = gate.hold
|
||||
if (hold?.op === name) {
|
||||
gate.hold = null
|
||||
hold.entered()
|
||||
await hold.release
|
||||
}
|
||||
if (gate.block) {
|
||||
await gate.block
|
||||
}
|
||||
return real(...args)
|
||||
}
|
||||
}
|
||||
return patched
|
||||
})
|
||||
|
||||
const handlers = new Map<string, (event: unknown, args?: unknown) => unknown>()
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => userData.dir,
|
||||
quit: vi.fn(),
|
||||
relaunch: vi.fn()
|
||||
},
|
||||
ipcMain: {
|
||||
handle: vi.fn((channel: string, handler: (event: unknown, args?: unknown) => unknown) => {
|
||||
handlers.set(channel, handler)
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../app-relaunch', () => ({ relaunchApp: relaunchAppMock }))
|
||||
vi.mock('./orca-profile-org-members-handlers', () => ({
|
||||
registerOrcaProfileOrgMemberHandlers: vi.fn()
|
||||
}))
|
||||
vi.mock('../orca-profiles/profile-cloud-service', () => ({
|
||||
createCloudLinkedOrcaProfile: vi.fn(),
|
||||
connectCurrentOrcaProfile: vi.fn(),
|
||||
getCurrentOrcaProfileAuthStatus: vi.fn(),
|
||||
refreshCurrentOrcaProfileAuth: vi.fn(),
|
||||
selectCurrentOrcaProfileOrg: vi.fn(),
|
||||
signOutCurrentOrcaProfile: vi.fn()
|
||||
}))
|
||||
vi.mock('../orca-profiles/profile-cloud-session-mutation', () => ({
|
||||
cloudSessionIdentity: vi.fn(),
|
||||
recordCloudSessionIdentityMutation: vi.fn()
|
||||
}))
|
||||
vi.mock('../orca-profiles/profile-project-transfer', () => ({
|
||||
transferOrcaProfileProject: vi.fn()
|
||||
}))
|
||||
vi.mock('../orca-profiles/profile-project-presence', () => ({
|
||||
findOrcaProfileProjectsByPath: vi.fn()
|
||||
}))
|
||||
|
||||
function makeStore(settings: Partial<GlobalSettings>): {
|
||||
flush: () => void
|
||||
freezeWrites: () => void
|
||||
getSettings: () => Partial<GlobalSettings>
|
||||
} {
|
||||
return { flush: vi.fn(), freezeWrites: vi.fn(), getSettings: () => settings }
|
||||
}
|
||||
|
||||
async function loadHandlers(
|
||||
settings: Partial<GlobalSettings> = {}
|
||||
): Promise<ProfileIndexStoreModule> {
|
||||
vi.resetModules()
|
||||
handlers.clear()
|
||||
const store = await import('../orca-profiles/profile-index-store')
|
||||
const { registerOrcaProfileHandlers } = await import('./orca-profiles')
|
||||
registerOrcaProfileHandlers(makeStore(settings) as never)
|
||||
return store
|
||||
}
|
||||
|
||||
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve: () => void = () => {}
|
||||
const promise = new Promise<void>((settle) => {
|
||||
resolve = settle
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function readIndexProfileIds(): string[] {
|
||||
const index = JSON.parse(
|
||||
readFileSync(join(userData.dir, 'orca-profile-index.json'), 'utf-8')
|
||||
) as { profiles: { id: string }[] }
|
||||
return index.profiles.map((profile) => profile.id)
|
||||
}
|
||||
|
||||
function invoke(channel: string, args?: unknown): Promise<unknown> {
|
||||
const handler = handlers.get(channel)
|
||||
if (!handler) {
|
||||
throw new Error(`no handler for ${channel}`)
|
||||
}
|
||||
return Promise.resolve(handler(null, args))
|
||||
}
|
||||
|
||||
function resetRecorders(): void {
|
||||
syncFsCalls.length = 0
|
||||
asyncFsCalls.length = 0
|
||||
}
|
||||
|
||||
describe('orca profile IPC handlers stay off main-thread sync fs', () => {
|
||||
beforeEach(() => {
|
||||
userData.dir = mkdtempSync(join(tmpdir(), 'orca-profile-freeze-'))
|
||||
gate.block = null
|
||||
gate.hold = null
|
||||
resetRecorders()
|
||||
relaunchAppMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
gate.block = null
|
||||
gate.hold = null
|
||||
rmSync(userData.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('answers orcaProfiles:list from the boot-resolved index with zero fs syscalls', async () => {
|
||||
const { ensureActiveOrcaProfile } = await loadHandlers()
|
||||
ensureActiveOrcaProfile()
|
||||
resetRecorders()
|
||||
|
||||
const result = (await invoke('orcaProfiles:list')) as { activeProfileId: string }
|
||||
|
||||
expect(result.activeProfileId).toBeTruthy()
|
||||
expect(syncFsCalls).toEqual([])
|
||||
expect(asyncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a local profile without a synchronous fs call', async () => {
|
||||
const { ensureActiveOrcaProfile } = await loadHandlers()
|
||||
ensureActiveOrcaProfile()
|
||||
resetRecorders()
|
||||
|
||||
const created = (await invoke('orcaProfiles:createLocal', { name: 'Work' })) as {
|
||||
profile: { id: string }
|
||||
profiles: { id: string }[]
|
||||
}
|
||||
|
||||
expect(created.profiles.map((profile) => profile.id)).toContain(created.profile.id)
|
||||
expect(syncFsCalls).toEqual([])
|
||||
expect(asyncFsCalls.some((call) => call.startsWith('mkdir '))).toBe(true)
|
||||
})
|
||||
|
||||
it('switches the active profile without a synchronous fs call', async () => {
|
||||
const { ensureActiveOrcaProfile } = await loadHandlers()
|
||||
ensureActiveOrcaProfile()
|
||||
const created = (await invoke('orcaProfiles:createLocal', { name: 'Work' })) as {
|
||||
profile: { id: string }
|
||||
}
|
||||
resetRecorders()
|
||||
|
||||
await expect(invoke('orcaProfiles:switch', { profileId: created.profile.id })).resolves.toEqual(
|
||||
{ status: 'relaunching' }
|
||||
)
|
||||
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('serializes overlapping profile creates so neither index write is lost', async () => {
|
||||
const { ensureActiveOrcaProfile } = await loadHandlers()
|
||||
ensureActiveOrcaProfile()
|
||||
|
||||
const [first, second] = (await Promise.all([
|
||||
invoke('orcaProfiles:createLocal', { name: 'One' }),
|
||||
invoke('orcaProfiles:createLocal', { name: 'Two' })
|
||||
])) as { profile: { id: string } }[]
|
||||
|
||||
const listed = (await invoke('orcaProfiles:list')) as { profiles: { id: string }[] }
|
||||
const ids = listed.profiles.map((profile) => profile.id)
|
||||
expect(ids).toContain(first.profile.id)
|
||||
expect(ids).toContain(second.profile.id)
|
||||
})
|
||||
|
||||
it('keeps the event loop running while a profile index write is parked', async () => {
|
||||
const { ensureActiveOrcaProfile } = await loadHandlers()
|
||||
ensureActiveOrcaProfile()
|
||||
resetRecorders()
|
||||
gate.block = new Promise<void>(() => {})
|
||||
|
||||
const parked = invoke('orcaProfiles:createLocal', { name: 'Stalled mount' })
|
||||
let settled = false
|
||||
void parked.then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
let timerFired = false
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
timerFired = true
|
||||
resolve()
|
||||
}, 5)
|
||||
})
|
||||
|
||||
expect(timerFired).toBe(true)
|
||||
expect(settled).toBe(false)
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('lets a sync index write veto an async write already parked on rename', async () => {
|
||||
const store = await loadHandlers()
|
||||
store.ensureActiveOrcaProfile()
|
||||
|
||||
const entered = createDeferred()
|
||||
const release = createDeferred()
|
||||
gate.hold = { op: 'rename', entered: entered.resolve, release: release.promise }
|
||||
|
||||
const pending = invoke('orcaProfiles:createLocal', { name: 'Async' }) as Promise<{
|
||||
profile: { id: string }
|
||||
}>
|
||||
await entered.promise
|
||||
|
||||
// The sync twin (startup, cloud index) still writes this file directly.
|
||||
const synced = store.createLocalOrcaProfile({ name: 'Sync' })
|
||||
release.resolve()
|
||||
const created = await pending
|
||||
|
||||
expect(readIndexProfileIds()).toEqual(
|
||||
expect.arrayContaining([synced.profile.id, created.profile.id])
|
||||
)
|
||||
// The zero-fs list cache must not be left holding the pre-sync index.
|
||||
const listed = (await invoke('orcaProfiles:list')) as { profiles: { id: string }[] }
|
||||
expect(listed.profiles.map((profile) => profile.id)).toEqual(
|
||||
expect.arrayContaining([synced.profile.id, created.profile.id])
|
||||
)
|
||||
})
|
||||
|
||||
it('seeds new-profile telemetry consent through a temp file, never in place', async () => {
|
||||
const telemetry = {
|
||||
optedIn: false,
|
||||
installId: 'install-1',
|
||||
existedBeforeTelemetryRelease: true
|
||||
}
|
||||
const { ensureActiveOrcaProfile } = await loadHandlers({ telemetry })
|
||||
ensureActiveOrcaProfile()
|
||||
resetRecorders()
|
||||
|
||||
const created = (await invoke('orcaProfiles:createLocal', { name: 'Work' })) as {
|
||||
profile: { id: string }
|
||||
}
|
||||
|
||||
const dataFile = join(userData.dir, 'profiles', created.profile.id, 'orca-data.json')
|
||||
expect(syncFsCalls).toEqual([])
|
||||
// A torn in-place write would leave partial JSON at the real path.
|
||||
expect(asyncFsCalls).not.toContain(`writeFile ${dataFile}`)
|
||||
expect(asyncFsCalls).toContain(`writeFile ${dataFile}.async.tmp`)
|
||||
expect(asyncFsCalls).toContain(`rename ${dataFile}.async.tmp`)
|
||||
expect(JSON.parse(readFileSync(dataFile, 'utf-8'))).toEqual({ settings: { telemetry } })
|
||||
})
|
||||
|
||||
it('leaves an existing profile data file untouched when seeding telemetry consent', async () => {
|
||||
const telemetry = {
|
||||
optedIn: true,
|
||||
installId: 'install-2',
|
||||
existedBeforeTelemetryRelease: false
|
||||
}
|
||||
const store = await loadHandlers({ telemetry })
|
||||
store.ensureActiveOrcaProfile()
|
||||
const { seedNewOrcaProfileTelemetryConsentAsync } = store
|
||||
const created = (await invoke('orcaProfiles:createLocal', { name: 'Work' })) as {
|
||||
profile: { id: string }
|
||||
}
|
||||
const dataFile = join(userData.dir, 'profiles', created.profile.id, 'orca-data.json')
|
||||
|
||||
await seedNewOrcaProfileTelemetryConsentAsync(created.profile.id, {
|
||||
...telemetry,
|
||||
installId: 'install-overwrite'
|
||||
})
|
||||
|
||||
expect(JSON.parse(readFileSync(dataFile, 'utf-8'))).toEqual({ settings: { telemetry } })
|
||||
})
|
||||
})
|
||||
@@ -49,10 +49,10 @@ vi.mock('../app-relaunch', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../orca-profiles/profile-index-store', () => ({
|
||||
createLocalOrcaProfile: createLocalOrcaProfileMock,
|
||||
getOrcaProfileListState: getOrcaProfileListStateMock,
|
||||
seedNewOrcaProfileTelemetryConsent: seedNewOrcaProfileTelemetryConsentMock,
|
||||
setActiveOrcaProfile: setActiveOrcaProfileMock
|
||||
createLocalOrcaProfileAsync: createLocalOrcaProfileMock,
|
||||
getOrcaProfileListStateAsync: getOrcaProfileListStateMock,
|
||||
seedNewOrcaProfileTelemetryConsentAsync: seedNewOrcaProfileTelemetryConsentMock,
|
||||
setActiveOrcaProfileAsync: setActiveOrcaProfileMock
|
||||
}))
|
||||
|
||||
function makeStoreMock(flush = vi.fn()): {
|
||||
|
||||
+30
-105
@@ -21,10 +21,10 @@ import type {
|
||||
SignOutCurrentOrcaProfileResult
|
||||
} from '../../shared/orca-profiles'
|
||||
import {
|
||||
createLocalOrcaProfile,
|
||||
getOrcaProfileListState,
|
||||
seedNewOrcaProfileTelemetryConsent,
|
||||
setActiveOrcaProfile
|
||||
createLocalOrcaProfileAsync,
|
||||
getOrcaProfileListStateAsync,
|
||||
seedNewOrcaProfileTelemetryConsentAsync,
|
||||
setActiveOrcaProfileAsync
|
||||
} from '../orca-profiles/profile-index-store'
|
||||
import {
|
||||
cloudSessionIdentity,
|
||||
@@ -34,7 +34,6 @@ import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
|
||||
import { isMultiProfileUiEnabled } from '../orca-profiles/profile-ui-scope'
|
||||
import { transferOrcaProfileProject } from '../orca-profiles/profile-project-transfer'
|
||||
import { findOrcaProfileProjectsByPath } from '../orca-profiles/profile-project-presence'
|
||||
import { normalizeExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
createCloudLinkedOrcaProfile,
|
||||
connectCurrentOrcaProfile,
|
||||
@@ -44,6 +43,13 @@ import {
|
||||
signOutCurrentOrcaProfile
|
||||
} from '../orca-profiles/profile-cloud-service'
|
||||
import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers'
|
||||
import {
|
||||
createCloudLinkedProfileArgsFromUnknown,
|
||||
findProjectsByPathArgsFromUnknown,
|
||||
orgIdFromUnknown,
|
||||
profileIdFromArgs,
|
||||
transferProjectArgsFromUnknown
|
||||
} from './orca-profile-request-args'
|
||||
|
||||
type RegisterOrcaProfileHandlersOptions = {
|
||||
onBeforeRelaunch?: () => void | Promise<void>
|
||||
@@ -51,96 +57,6 @@ type RegisterOrcaProfileHandlersOptions = {
|
||||
onBeforeSignOut?: () => void
|
||||
}
|
||||
|
||||
function profileIdFromArgs(args: unknown): string {
|
||||
if (
|
||||
!args ||
|
||||
typeof args !== 'object' ||
|
||||
typeof (args as SwitchOrcaProfileArgs).profileId !== 'string'
|
||||
) {
|
||||
throw new Error('invalid_orca_profile_id')
|
||||
}
|
||||
const profileId = (args as SwitchOrcaProfileArgs).profileId.trim()
|
||||
if (!profileId) {
|
||||
throw new Error('invalid_orca_profile_id')
|
||||
}
|
||||
return profileId
|
||||
}
|
||||
|
||||
function transferProjectArgsFromUnknown(args: unknown): TransferOrcaProfileProjectArgs {
|
||||
if (!args || typeof args !== 'object') {
|
||||
throw new Error('invalid_orca_profile_project_transfer')
|
||||
}
|
||||
const candidate = args as TransferOrcaProfileProjectArgs
|
||||
const sourceProfileId = candidate.sourceProfileId?.trim()
|
||||
const targetProfileId = candidate.targetProfileId?.trim()
|
||||
const repoId = candidate.repoId?.trim()
|
||||
const mode = candidate.mode
|
||||
if (!sourceProfileId || !targetProfileId || !repoId || (mode !== 'move' && mode !== 'copy')) {
|
||||
throw new Error('invalid_orca_profile_project_transfer')
|
||||
}
|
||||
return {
|
||||
sourceProfileId,
|
||||
targetProfileId,
|
||||
repoId,
|
||||
mode
|
||||
}
|
||||
}
|
||||
|
||||
function findProjectsByPathArgsFromUnknown(args: unknown): FindOrcaProfileProjectsByPathArgs {
|
||||
if (!args || typeof args !== 'object') {
|
||||
throw new Error('invalid_orca_profile_project_path')
|
||||
}
|
||||
const candidate = args as FindOrcaProfileProjectsByPathArgs
|
||||
const path = typeof candidate.path === 'string' ? candidate.path.trim() : ''
|
||||
if (!path) {
|
||||
throw new Error('invalid_orca_profile_project_path')
|
||||
}
|
||||
let executionHostId: FindOrcaProfileProjectsByPathArgs['executionHostId'] = null
|
||||
if (candidate.executionHostId !== null && candidate.executionHostId !== undefined) {
|
||||
if (typeof candidate.executionHostId !== 'string') {
|
||||
throw new Error('invalid_orca_profile_project_path')
|
||||
}
|
||||
executionHostId = normalizeExecutionHostId(candidate.executionHostId)
|
||||
if (!executionHostId) {
|
||||
throw new Error('invalid_orca_profile_project_path')
|
||||
}
|
||||
}
|
||||
return {
|
||||
path,
|
||||
connectionId:
|
||||
typeof candidate.connectionId === 'string' ? candidate.connectionId.trim() || null : null,
|
||||
executionHostId,
|
||||
excludeProfileId:
|
||||
typeof candidate.excludeProfileId === 'string'
|
||||
? candidate.excludeProfileId.trim() || null
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
function orgIdFromUnknown(args: unknown): string {
|
||||
if (!args || typeof args !== 'object') {
|
||||
throw new Error('invalid_orca_profile_org_selection')
|
||||
}
|
||||
const orgId = (args as SelectOrcaProfileOrgArgs).orgId?.trim()
|
||||
if (!orgId) {
|
||||
throw new Error('invalid_orca_profile_org_selection')
|
||||
}
|
||||
return orgId
|
||||
}
|
||||
|
||||
function createCloudLinkedProfileArgsFromUnknown(args: unknown): CreateCloudLinkedOrcaProfileArgs {
|
||||
if (!args || typeof args !== 'object') {
|
||||
return {}
|
||||
}
|
||||
const candidate = args as CreateCloudLinkedOrcaProfileArgs
|
||||
const orgId = typeof candidate.orgId === 'string' ? candidate.orgId.trim() : undefined
|
||||
const name = typeof candidate.name === 'string' ? candidate.name.trim() : undefined
|
||||
return {
|
||||
...(orgId ? { orgId } : {}),
|
||||
...(name ? { name } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async function runBeforeProfileRelaunch(
|
||||
onBeforeRelaunch?: () => void | Promise<void>
|
||||
): Promise<void> {
|
||||
@@ -168,10 +84,13 @@ export function registerOrcaProfileHandlers(
|
||||
store: Store,
|
||||
options: RegisterOrcaProfileHandlersOptions = {}
|
||||
): void {
|
||||
// Why: this channel is on the app-startup chain — it must never sit on a
|
||||
// main-thread fs syscall. The async twin answers from the index boot already
|
||||
// resolved, so the common case does no filesystem work at all.
|
||||
ipcMain.handle(
|
||||
'orcaProfiles:list',
|
||||
(): OrcaProfileListResult => ({
|
||||
...getOrcaProfileListState(),
|
||||
async (): Promise<OrcaProfileListResult> => ({
|
||||
...(await getOrcaProfileListStateAsync()),
|
||||
multiProfileUi: isMultiProfileUiEnabled()
|
||||
})
|
||||
)
|
||||
@@ -183,9 +102,12 @@ export function registerOrcaProfileHandlers(
|
||||
|
||||
ipcMain.handle(
|
||||
'orcaProfiles:createLocal',
|
||||
(_event, args?: CreateLocalOrcaProfileArgs): CreateLocalOrcaProfileResult => {
|
||||
const result = createLocalOrcaProfile(args)
|
||||
seedNewOrcaProfileTelemetryConsent(result.profile.id, store.getSettings().telemetry)
|
||||
async (_event, args?: CreateLocalOrcaProfileArgs): Promise<CreateLocalOrcaProfileResult> => {
|
||||
const result = await createLocalOrcaProfileAsync(args)
|
||||
await seedNewOrcaProfileTelemetryConsentAsync(
|
||||
result.profile.id,
|
||||
store.getSettings().telemetry
|
||||
)
|
||||
return result
|
||||
}
|
||||
)
|
||||
@@ -194,7 +116,7 @@ export function registerOrcaProfileHandlers(
|
||||
'orcaProfiles:switch',
|
||||
async (_event, args: SwitchOrcaProfileArgs): Promise<SwitchOrcaProfileResult> => {
|
||||
const profileId = profileIdFromArgs(args)
|
||||
const current = getOrcaProfileListState()
|
||||
const current = await getOrcaProfileListStateAsync()
|
||||
if (profileId === current.activeProfileId) {
|
||||
return { status: 'already-active' }
|
||||
}
|
||||
@@ -214,7 +136,7 @@ export function registerOrcaProfileHandlers(
|
||||
// points startup at the target profile.
|
||||
await runBeforeProfileRelaunch(options.onBeforeRelaunch)
|
||||
store.flush()
|
||||
setActiveOrcaProfile(profileId)
|
||||
await setActiveOrcaProfileAsync(profileId)
|
||||
|
||||
scheduleProfileRelaunch('profile-switch')
|
||||
|
||||
@@ -229,7 +151,7 @@ export function registerOrcaProfileHandlers(
|
||||
rawArgs: TransferOrcaProfileProjectArgs
|
||||
): Promise<TransferOrcaProfileProjectResult> => {
|
||||
const args = transferProjectArgsFromUnknown(rawArgs)
|
||||
const current = getOrcaProfileListState()
|
||||
const current = await getOrcaProfileListStateAsync()
|
||||
if (args.targetProfileId === current.activeProfileId) {
|
||||
throw new Error('active_target_orca_profile_transfer_requires_relaunch')
|
||||
}
|
||||
@@ -244,7 +166,7 @@ export function registerOrcaProfileHandlers(
|
||||
if (result.status === 'transferred') {
|
||||
store.freezeWrites()
|
||||
await runBeforeProfileRelaunch(options.onBeforeRelaunch)
|
||||
setActiveOrcaProfile(args.targetProfileId)
|
||||
await setActiveOrcaProfileAsync(args.targetProfileId)
|
||||
scheduleProfileRelaunch('profile-transfer')
|
||||
return { ...result, willRelaunch: true }
|
||||
}
|
||||
@@ -286,7 +208,10 @@ export function registerOrcaProfileHandlers(
|
||||
createCloudLinkedProfileArgsFromUnknown(rawArgs)
|
||||
)
|
||||
if (result.status === 'created') {
|
||||
seedNewOrcaProfileTelemetryConsent(result.profile.id, store.getSettings().telemetry)
|
||||
await seedNewOrcaProfileTelemetryConsentAsync(
|
||||
result.profile.id,
|
||||
store.getSettings().telemetry
|
||||
)
|
||||
options.onAuthMutation?.()
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -6,9 +6,9 @@ import { SPEECH_MODEL_CATALOG } from '../speech/model-catalog'
|
||||
import { deleteLocalSpeechModel } from '../speech/speech-model-deletion'
|
||||
import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service'
|
||||
import {
|
||||
clearOpenAiSpeechApiKey,
|
||||
hasOpenAiSpeechApiKey,
|
||||
saveOpenAiSpeechApiKey
|
||||
clearOpenAiSpeechApiKeyAsync,
|
||||
hasOpenAiSpeechApiKeyAsync,
|
||||
saveOpenAiSpeechApiKeyAsync
|
||||
} from '../speech/openai-api-key-store'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
@@ -22,16 +22,16 @@ export function registerSpeechHandlers(store: Store): void {
|
||||
})
|
||||
|
||||
ipcMain.handle('speech:getOpenAiApiKeyStatus', async () => {
|
||||
return { configured: hasOpenAiSpeechApiKey() }
|
||||
return { configured: await hasOpenAiSpeechApiKeyAsync() }
|
||||
})
|
||||
|
||||
ipcMain.handle('speech:saveOpenAiApiKey', async (_event, apiKey: string) => {
|
||||
saveOpenAiSpeechApiKey(apiKey)
|
||||
await saveOpenAiSpeechApiKeyAsync(apiKey)
|
||||
return { configured: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('speech:clearOpenAiApiKey', async () => {
|
||||
clearOpenAiSpeechApiKey()
|
||||
await clearOpenAiSpeechApiKeyAsync()
|
||||
return { configured: false }
|
||||
})
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ const {
|
||||
buildWindowsRunnerScriptMock,
|
||||
getSetupRunnerEnvVarsMock,
|
||||
runHookMock,
|
||||
hasHooksFileMock,
|
||||
checkOrcaYamlHooksMock,
|
||||
readIssueCommandAsyncMock,
|
||||
loadHooksMock,
|
||||
computeWorktreePathMock,
|
||||
ensurePathWithinWorkspaceMock,
|
||||
@@ -101,7 +102,8 @@ const {
|
||||
buildWindowsRunnerScriptMock: vi.fn(),
|
||||
getSetupRunnerEnvVarsMock: vi.fn(),
|
||||
runHookMock: vi.fn(),
|
||||
hasHooksFileMock: vi.fn(),
|
||||
checkOrcaYamlHooksMock: vi.fn(),
|
||||
readIssueCommandAsyncMock: vi.fn(),
|
||||
loadHooksMock: vi.fn(),
|
||||
computeWorktreePathMock: vi.fn(),
|
||||
ensurePathWithinWorkspaceMock: vi.fn(),
|
||||
@@ -200,7 +202,8 @@ vi.mock('../hooks', () => ({
|
||||
loadHooks: loadHooksMock,
|
||||
parseOrcaYaml: parseOrcaYamlMock,
|
||||
runHook: runHookMock,
|
||||
hasHooksFile: hasHooksFileMock,
|
||||
checkOrcaYamlHooks: checkOrcaYamlHooksMock,
|
||||
readIssueCommandAsync: readIssueCommandAsyncMock,
|
||||
shouldRunSetupForCreate: shouldRunSetupForCreateMock
|
||||
}))
|
||||
|
||||
@@ -366,7 +369,8 @@ describe('registerWorktreeHandlers', () => {
|
||||
getSetupRunnerEnvVarsMock,
|
||||
shouldRunSetupForCreateMock,
|
||||
runHookMock,
|
||||
hasHooksFileMock,
|
||||
checkOrcaYamlHooksMock,
|
||||
readIssueCommandAsyncMock,
|
||||
loadHooksMock,
|
||||
computeWorktreePathMock,
|
||||
ensurePathWithinWorkspaceMock,
|
||||
@@ -472,6 +476,11 @@ describe('registerWorktreeHandlers', () => {
|
||||
getEffectiveHooksMock.mockReturnValue(null)
|
||||
getEffectiveHooksFromConfigMock.mockImplementation(() => getEffectiveHooksMock())
|
||||
getDefaultTabsLaunchMock.mockReturnValue(undefined)
|
||||
checkOrcaYamlHooksMock.mockResolvedValue({
|
||||
hasHooksFile: false,
|
||||
hooks: null,
|
||||
mayNeedUpdate: false
|
||||
})
|
||||
parseOrcaYamlMock.mockReturnValue(null)
|
||||
shouldRunSetupForCreateMock.mockReturnValue(false)
|
||||
buildPosixRunnerScriptMock.mockImplementation(
|
||||
@@ -8925,7 +8934,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
mayNeedUpdate: false
|
||||
})
|
||||
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml')
|
||||
expect(hasHooksFileMock).not.toHaveBeenCalled()
|
||||
expect(checkOrcaYamlHooksMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails hook inspection closed when duplicate repo ids omit the host', async () => {
|
||||
@@ -8946,7 +8955,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
mayNeedUpdate: false
|
||||
})
|
||||
expect(getSshFilesystemProviderMock).not.toHaveBeenCalled()
|
||||
expect(hasHooksFileMock).not.toHaveBeenCalled()
|
||||
expect(checkOrcaYamlHooksMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('inspects setup-script imports on the requested host when repo ids collide', async () => {
|
||||
|
||||
@@ -84,16 +84,14 @@ import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
checkOrcaYamlHooks,
|
||||
createIssueCommandRunnerScript,
|
||||
getEffectiveHooks,
|
||||
getEffectiveHooksFromConfig,
|
||||
getSetupRunnerEnvVars,
|
||||
loadHooks,
|
||||
parseOrcaYaml,
|
||||
readIssueCommand,
|
||||
readIssueCommandAsync,
|
||||
runHook,
|
||||
hasHooksFile,
|
||||
hasUnrecognizedOrcaYamlKeys,
|
||||
writeIssueCommand
|
||||
} from '../hooks'
|
||||
import {
|
||||
@@ -3046,15 +3044,14 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
const has = hasHooksFile(repo.path)
|
||||
const hooks = has ? loadHooks(repo.path) : null
|
||||
// Why: unrecognised top-level keys mean the file is well-formed but from a newer Orca; suggest updating rather than "could not be parsed".
|
||||
const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path)
|
||||
// Why: `mayNeedUpdate` means the file is well-formed but from a newer Orca; suggest
|
||||
// updating rather than "could not be parsed". One read answers it and the rest.
|
||||
const check = await checkOrcaYamlHooks(repo.path)
|
||||
return {
|
||||
status: 'ok',
|
||||
hasHooks: has,
|
||||
hooks,
|
||||
mayNeedUpdate
|
||||
hasHooks: check.hasHooksFile,
|
||||
hooks: check.hooks,
|
||||
mayNeedUpdate: check.mayNeedUpdate
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -3203,7 +3200,7 @@ export function registerWorktreeHandlers(
|
||||
: ('none' as const)
|
||||
}
|
||||
}
|
||||
return readIssueCommand(repo.path)
|
||||
return readIssueCommandAsync(repo.path)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable max-lines -- Why: parsing, sanitizing, migrating, and writing the keybindings file must stay together so file-format edge cases share one validation path. */
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { access, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
findKeybindingConflicts,
|
||||
@@ -42,16 +43,11 @@ function createEmptyDocument(): JsonObject {
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonDocument(path: string): {
|
||||
exists: boolean
|
||||
document: JsonObject | null
|
||||
error?: string
|
||||
} {
|
||||
if (!existsSync(path)) {
|
||||
return { exists: false, document: createEmptyDocument() }
|
||||
}
|
||||
type ReadDocumentResult = { exists: boolean; document: JsonObject | null; error?: string }
|
||||
|
||||
function parseDocument(contents: string): ReadDocumentResult {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
|
||||
const parsed = JSON.parse(contents) as unknown
|
||||
if (!isJsonObject(parsed)) {
|
||||
return { exists: true, document: null, error: 'Keybindings file must contain a JSON object.' }
|
||||
}
|
||||
@@ -65,6 +61,69 @@ function readJsonDocument(path: string): {
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonDocument(path: string): ReadDocumentResult {
|
||||
if (!existsSync(path)) {
|
||||
return { exists: false, document: createEmptyDocument() }
|
||||
}
|
||||
return parseDocument(readFileSync(path, 'utf8'))
|
||||
}
|
||||
|
||||
async function readJsonDocumentAsync(path: string): Promise<ReadDocumentResult> {
|
||||
let contents: string
|
||||
try {
|
||||
contents = await readFile(path, 'utf8')
|
||||
} catch (error) {
|
||||
// Why: ENOENT from the read replaces a separate exists() probe — one syscall
|
||||
// instead of two, which matters when ~/.orca sits on a stalled mount.
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { exists: false, document: createEmptyDocument() }
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
document: null,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
return parseDocument(contents)
|
||||
}
|
||||
|
||||
// Why: async tmp+rename loses the serialization the single main thread gave for
|
||||
// free. Chain per path so two overlapping writes can never rename out of order,
|
||||
// and so a read-modify-write can hold the lane across its read.
|
||||
const writeChains = new Map<string, Promise<void>>()
|
||||
|
||||
function runExclusive<T>(path: string, task: () => Promise<T>): Promise<T> {
|
||||
const chain = (writeChains.get(path) ?? Promise.resolve()).then(task, task)
|
||||
const settled = chain.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
writeChains.set(path, settled)
|
||||
void settled.then(() => {
|
||||
if (writeChains.get(path) === settled) {
|
||||
writeChains.delete(path)
|
||||
}
|
||||
})
|
||||
return chain
|
||||
}
|
||||
|
||||
// Why: distinct from the sync `.tmp` so a startup-time sync write can never
|
||||
// rename a half-written async temp file over a good keybindings file.
|
||||
const ASYNC_TMP_SUFFIX = '.async.tmp'
|
||||
|
||||
async function writeJsonDocumentAsync(path: string, document: JsonObject): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
const tempPath = `${path}${ASYNC_TMP_SUFFIX}`
|
||||
try {
|
||||
await writeFile(tempPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8')
|
||||
await rename(tempPath, path)
|
||||
} catch (error) {
|
||||
// Unconditional unlink; ENOENT here just means there was nothing to clean up.
|
||||
await unlink(tempPath).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonDocument(path: string, document: JsonObject): void {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
const tempPath = `${path}.tmp`
|
||||
@@ -245,13 +304,13 @@ function removeConflictingOverrides(
|
||||
return next
|
||||
}
|
||||
|
||||
export function readKeybindingFile(
|
||||
function buildSnapshot(
|
||||
path: string,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
platform: NodeJS.Platform,
|
||||
readResult: ReadDocumentResult
|
||||
): KeybindingFileSnapshot {
|
||||
const keybindingPlatform = getKeybindingPlatform(platform)
|
||||
const diagnostics: KeybindingFileDiagnostic[] = []
|
||||
const readResult = readJsonDocument(path)
|
||||
if (!readResult.document) {
|
||||
return {
|
||||
path,
|
||||
@@ -292,6 +351,32 @@ export function readKeybindingFile(
|
||||
}
|
||||
}
|
||||
|
||||
export function readKeybindingFile(
|
||||
path: string,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): KeybindingFileSnapshot {
|
||||
return buildSnapshot(path, platform, readJsonDocument(path))
|
||||
}
|
||||
|
||||
export async function readKeybindingFileAsync(
|
||||
path: string,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<KeybindingFileSnapshot> {
|
||||
return buildSnapshot(path, platform, await readJsonDocumentAsync(path))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read in the write lane. Callers that install the result as cached state need
|
||||
* this: an unordered read can start before an overlapping write and resolve
|
||||
* after it, which would publish the pre-write snapshot as the current one.
|
||||
*/
|
||||
export function readKeybindingFileExclusiveAsync(
|
||||
path: string,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<KeybindingFileSnapshot> {
|
||||
return runExclusive(path, () => readKeybindingFileAsync(path, platform))
|
||||
}
|
||||
|
||||
export function ensureKeybindingFile(path: string): void {
|
||||
if (existsSync(path)) {
|
||||
return
|
||||
@@ -299,6 +384,25 @@ export function ensureKeybindingFile(path: string): void {
|
||||
writeJsonDocument(path, createEmptyDocument())
|
||||
}
|
||||
|
||||
export async function ensureKeybindingFileAsync(path: string): Promise<void> {
|
||||
// In the write lane so the probe and the create cannot straddle an overlapping
|
||||
// override write and drop it by seeding an empty document over it.
|
||||
return runExclusive(path, async () => {
|
||||
try {
|
||||
// Matches the sync twin's existsSync guard, which is also false for EACCES
|
||||
// and friends: only create when there is nothing to read.
|
||||
await access(path)
|
||||
return
|
||||
} catch {
|
||||
// Missing; create it below.
|
||||
}
|
||||
// Why tmp+rename and not an in-place write: a torn write leaves a partial
|
||||
// keybindings.json, which parses to no document, and every later write
|
||||
// refuses to replace a file it could not parse — the user is stuck.
|
||||
await writeJsonDocumentAsync(path, createEmptyDocument())
|
||||
})
|
||||
}
|
||||
|
||||
export function migrateLegacyKeybindings(
|
||||
path: string,
|
||||
platform: NodeJS.Platform,
|
||||
@@ -382,14 +486,13 @@ export function seedLegacyTabSwitchBindings(
|
||||
|
||||
// Why: the one-shot seed migration and Settings writes must produce the same
|
||||
// on-disk document shape; a single assembly path keeps them from drifting.
|
||||
function writeActivePlatformSection(
|
||||
path: string,
|
||||
function assembleActivePlatformDocument(
|
||||
platform: NodeJS.Platform,
|
||||
readResult: ReadDocumentResult,
|
||||
fallbackCommonOverrides: KeybindingOverrides,
|
||||
mutateActivePlatform: (activePlatform: JsonObject) => void
|
||||
): KeybindingFileSnapshot {
|
||||
): JsonObject {
|
||||
const keybindingPlatform = getKeybindingPlatform(platform)
|
||||
const readResult = readJsonDocument(path)
|
||||
if (!readResult.document) {
|
||||
// Why: writes must never replace a user-owned file that could not be
|
||||
// parsed; callers surface the error (or retry the migration) after repair.
|
||||
@@ -419,10 +522,83 @@ function writeActivePlatformSection(
|
||||
win32: isJsonObject(platforms.win32) ? platforms.win32 : {},
|
||||
[keybindingPlatform]: activePlatform
|
||||
}
|
||||
writeJsonDocument(path, document)
|
||||
return document
|
||||
}
|
||||
|
||||
function writeActivePlatformSection(
|
||||
path: string,
|
||||
platform: NodeJS.Platform,
|
||||
fallbackCommonOverrides: KeybindingOverrides,
|
||||
mutateActivePlatform: (activePlatform: JsonObject) => void
|
||||
): KeybindingFileSnapshot {
|
||||
writeJsonDocument(
|
||||
path,
|
||||
assembleActivePlatformDocument(
|
||||
platform,
|
||||
readJsonDocument(path),
|
||||
fallbackCommonOverrides,
|
||||
mutateActivePlatform
|
||||
)
|
||||
)
|
||||
return readKeybindingFile(path, platform)
|
||||
}
|
||||
|
||||
async function writeActivePlatformSectionAsync(
|
||||
path: string,
|
||||
platform: NodeJS.Platform,
|
||||
fallbackCommonOverrides: KeybindingOverrides,
|
||||
mutateActivePlatform: (activePlatform: JsonObject) => void
|
||||
): Promise<KeybindingFileSnapshot> {
|
||||
await writeJsonDocumentAsync(
|
||||
path,
|
||||
assembleActivePlatformDocument(
|
||||
platform,
|
||||
await readJsonDocumentAsync(path),
|
||||
fallbackCommonOverrides,
|
||||
mutateActivePlatform
|
||||
)
|
||||
)
|
||||
return readKeybindingFileAsync(path, platform)
|
||||
}
|
||||
|
||||
function assertNoBlockingConflict(
|
||||
platform: KeybindingPlatform,
|
||||
currentOverrides: KeybindingOverrides,
|
||||
actionId: KeybindingActionId,
|
||||
normalizedBindings: string[] | null
|
||||
): void {
|
||||
const candidateOverrides = { ...currentOverrides }
|
||||
if (normalizedBindings === null) {
|
||||
delete candidateOverrides[actionId]
|
||||
} else {
|
||||
candidateOverrides[actionId] = normalizedBindings
|
||||
}
|
||||
const blockingConflict = findKeybindingConflicts(platform, candidateOverrides).find((conflict) =>
|
||||
conflict.actionIds.includes(actionId)
|
||||
)
|
||||
if (blockingConflict) {
|
||||
throw new Error(
|
||||
`${formatKeybindingList([blockingConflict.binding], platform)} conflicts with another shortcut.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function activePlatformOverrideMutation(
|
||||
actionId: KeybindingActionId,
|
||||
normalizedBindings: string[] | null
|
||||
): (activePlatform: JsonObject) => void {
|
||||
return (activePlatform) => {
|
||||
if (normalizedBindings === null) {
|
||||
// Why: Settings edits are scoped to the current platform. A hand-authored
|
||||
// common binding may be intentional for other OSes, so reset only removes
|
||||
// the platform-specific mask instead of deleting the shared value.
|
||||
delete activePlatform[actionId]
|
||||
} else {
|
||||
activePlatform[actionId] = normalizedBindings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function writeKeybindingOverride(
|
||||
path: string,
|
||||
platform: NodeJS.Platform,
|
||||
@@ -433,37 +609,46 @@ export function writeKeybindingOverride(
|
||||
throw new Error(`Unknown keybinding action "${actionId}".`)
|
||||
}
|
||||
const normalizedBindings = normalizeWriteBindingValue(actionId, bindings)
|
||||
|
||||
const keybindingPlatform = getKeybindingPlatform(platform)
|
||||
const currentSnapshot = readKeybindingFile(path, platform)
|
||||
const candidateOverrides = { ...currentSnapshot.overrides }
|
||||
if (normalizedBindings === null) {
|
||||
delete candidateOverrides[actionId]
|
||||
} else {
|
||||
candidateOverrides[actionId] = normalizedBindings
|
||||
}
|
||||
const blockingConflict = findKeybindingConflicts(keybindingPlatform, candidateOverrides).find(
|
||||
(conflict) => conflict.actionIds.includes(actionId)
|
||||
assertNoBlockingConflict(
|
||||
getKeybindingPlatform(platform),
|
||||
currentSnapshot.overrides,
|
||||
actionId,
|
||||
normalizedBindings
|
||||
)
|
||||
if (blockingConflict) {
|
||||
throw new Error(
|
||||
`${formatKeybindingList([blockingConflict.binding], keybindingPlatform)} conflicts with another shortcut.`
|
||||
)
|
||||
}
|
||||
|
||||
return writeActivePlatformSection(
|
||||
path,
|
||||
platform,
|
||||
currentSnapshot.commonOverrides,
|
||||
(activePlatform) => {
|
||||
if (normalizedBindings === null) {
|
||||
// Why: Settings edits are scoped to the current platform. A hand-authored
|
||||
// common binding may be intentional for other OSes, so reset only removes
|
||||
// the platform-specific mask instead of deleting the shared value.
|
||||
delete activePlatform[actionId]
|
||||
} else {
|
||||
activePlatform[actionId] = normalizedBindings
|
||||
}
|
||||
}
|
||||
activePlatformOverrideMutation(actionId, normalizedBindings)
|
||||
)
|
||||
}
|
||||
|
||||
export async function writeKeybindingOverrideAsync(
|
||||
path: string,
|
||||
platform: NodeJS.Platform,
|
||||
actionId: string,
|
||||
bindings: unknown
|
||||
): Promise<KeybindingFileSnapshot> {
|
||||
if (!isKeybindingActionId(actionId)) {
|
||||
throw new Error(`Unknown keybinding action "${actionId}".`)
|
||||
}
|
||||
const normalizedBindings = normalizeWriteBindingValue(actionId, bindings)
|
||||
// Read-modify-write inside the lane: without this, two overlapping edits could
|
||||
// each read the pre-edit file and the second rename would drop the first.
|
||||
return runExclusive(path, async () => {
|
||||
const currentSnapshot = await readKeybindingFileAsync(path, platform)
|
||||
assertNoBlockingConflict(
|
||||
getKeybindingPlatform(platform),
|
||||
currentSnapshot.overrides,
|
||||
actionId,
|
||||
normalizedBindings
|
||||
)
|
||||
return writeActivePlatformSectionAsync(
|
||||
path,
|
||||
platform,
|
||||
currentSnapshot.commonOverrides,
|
||||
activePlatformOverrideMutation(actionId, normalizedBindings)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,11 +6,15 @@ import {
|
||||
} from '../../shared/keybindings'
|
||||
import {
|
||||
ensureKeybindingFile,
|
||||
ensureKeybindingFileAsync,
|
||||
getUserKeybindingsPath,
|
||||
migrateLegacyKeybindings,
|
||||
readKeybindingFile,
|
||||
readKeybindingFileAsync,
|
||||
readKeybindingFileExclusiveAsync,
|
||||
seedLegacyTabSwitchBindings,
|
||||
writeKeybindingOverride
|
||||
writeKeybindingOverride,
|
||||
writeKeybindingOverrideAsync
|
||||
} from './keybinding-file'
|
||||
|
||||
export type KeybindingServiceOptions = {
|
||||
@@ -54,12 +58,17 @@ export class KeybindingService {
|
||||
console.error('Failed to seed legacy tab-switch keybindings:', error)
|
||||
}
|
||||
}
|
||||
// Why: sync callers (menu build, browser/runtime settings resolvers) cannot
|
||||
// await. Warm the cache here, pre-window, so they never trigger a lazy sync
|
||||
// read later — that read would freeze the UI if ~/.orca is on a stalled mount.
|
||||
this.snapshot ??= readKeybindingFile(this.configPath, this.platform)
|
||||
}
|
||||
|
||||
getPath(): string {
|
||||
return this.configPath
|
||||
}
|
||||
|
||||
/** Sync accessor for callers that cannot await; served from the warm cache. */
|
||||
getSnapshot(): KeybindingFileSnapshot {
|
||||
if (!this.snapshot) {
|
||||
this.snapshot = readKeybindingFile(this.configPath, this.platform)
|
||||
@@ -67,11 +76,23 @@ export class KeybindingService {
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
async getSnapshotAsync(): Promise<KeybindingFileSnapshot> {
|
||||
this.snapshot ??= await readKeybindingFileAsync(this.configPath, this.platform)
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
reload(): KeybindingFileSnapshot {
|
||||
this.snapshot = readKeybindingFile(this.configPath, this.platform)
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
async reloadAsync(): Promise<KeybindingFileSnapshot> {
|
||||
// Ordered against in-flight writes so a reload started before one cannot
|
||||
// install the pre-write snapshot over it.
|
||||
this.snapshot = await readKeybindingFileExclusiveAsync(this.configPath, this.platform)
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
getOverrides(): KeybindingOverrides {
|
||||
return this.getSnapshot().overrides
|
||||
}
|
||||
@@ -81,6 +102,11 @@ export class KeybindingService {
|
||||
return this.reload()
|
||||
}
|
||||
|
||||
async ensureFileAsync(): Promise<KeybindingFileSnapshot> {
|
||||
await ensureKeybindingFileAsync(this.configPath)
|
||||
return this.reloadAsync()
|
||||
}
|
||||
|
||||
setActionBindings(
|
||||
actionId: KeybindingActionId,
|
||||
bindings: string[] | null
|
||||
@@ -88,4 +114,17 @@ export class KeybindingService {
|
||||
this.snapshot = writeKeybindingOverride(this.configPath, this.platform, actionId, bindings)
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
async setActionBindingsAsync(
|
||||
actionId: KeybindingActionId,
|
||||
bindings: string[] | null
|
||||
): Promise<KeybindingFileSnapshot> {
|
||||
this.snapshot = await writeKeybindingOverrideAsync(
|
||||
this.configPath,
|
||||
this.platform,
|
||||
actionId,
|
||||
bindings
|
||||
)
|
||||
return this.snapshot
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Why: freeze #31. Kimi's config.toml reader ran existsSync + readFileSync on the
|
||||
// Electron main thread; a stalled ~/.kimi-code mount froze the whole app.
|
||||
const { syncFsCalls, hangReads, stalledRoot, STALLED_MOUNT_BLOCK_MS, TIMER_BUDGET_MS } = vi.hoisted(
|
||||
() => ({
|
||||
syncFsCalls: [] as string[],
|
||||
hangReads: { value: false },
|
||||
stalledRoot: { value: '' },
|
||||
STALLED_MOUNT_BLOCK_MS: 1_000,
|
||||
TIMER_BUDGET_MS: 500
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = (await vi.importActual('node:fs')) as Record<string, unknown>
|
||||
const wrapped: Record<string, unknown> = { ...actual }
|
||||
for (const [name, value] of Object.entries(actual)) {
|
||||
if (name.endsWith('Sync') && typeof value === 'function') {
|
||||
wrapped[name] = (...args: unknown[]) => {
|
||||
const target = String(args[0])
|
||||
syncFsCalls.push(`${name}:${target}`)
|
||||
if (hangReads.value && stalledRoot.value && target.startsWith(stalledRoot.value)) {
|
||||
// Why: a stalled SMB/NFS mount parks a *Sync call in the kernel with
|
||||
// the event loop still on the stack; Atomics.wait is the only faithful
|
||||
// way to reproduce that from JS.
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, STALLED_MOUNT_BLOCK_MS)
|
||||
}
|
||||
return (value as (...a: unknown[]) => unknown)(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...wrapped, default: wrapped }
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = (await vi.importActual('node:fs/promises')) as typeof FsPromises
|
||||
const readFile = (...args: Parameters<typeof actual.readFile>): Promise<unknown> =>
|
||||
hangReads.value ? new Promise<never>(() => {}) : actual.readFile(...args)
|
||||
return { ...actual, default: { ...actual, readFile }, readFile }
|
||||
})
|
||||
|
||||
import { KimiHookService } from './hook-service'
|
||||
|
||||
describe('KimiHookService main-thread fs', () => {
|
||||
let home: string
|
||||
let kimiHome: string
|
||||
let originalHome: string | undefined
|
||||
let originalKimiHome: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'orca-kimi-sync-fs-'))
|
||||
kimiHome = join(home, '.kimi-code')
|
||||
originalHome = process.env.HOME
|
||||
originalKimiHome = process.env.KIMI_CODE_HOME
|
||||
process.env.HOME = home
|
||||
process.env.KIMI_CODE_HOME = kimiHome
|
||||
hangReads.value = false
|
||||
stalledRoot.value = home
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
hangReads.value = false
|
||||
stalledRoot.value = ''
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = originalHome
|
||||
}
|
||||
if (originalKimiHome === undefined) {
|
||||
delete process.env.KIMI_CODE_HOME
|
||||
} else {
|
||||
process.env.KIMI_CODE_HOME = originalKimiHome
|
||||
}
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('reads status without a single synchronous fs syscall', async () => {
|
||||
mkdirSync(kimiHome, { recursive: true })
|
||||
writeFileSync(join(kimiHome, 'config.toml'), 'model = "k2"\n', 'utf-8')
|
||||
|
||||
syncFsCalls.length = 0
|
||||
await new KimiHookService().getStatus()
|
||||
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('installs and removes without a single synchronous fs syscall', async () => {
|
||||
const service = new KimiHookService()
|
||||
|
||||
syncFsCalls.length = 0
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
expect(syncFsCalls).toEqual([])
|
||||
|
||||
syncFsCalls.length = 0
|
||||
expect((await service.remove()).state).toBe('not_installed')
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
// Why: the point of the async conversion is that a stalled ~/.kimi-code still
|
||||
// lets timers/repaints/menus run. Arm the timer *before* the call — a sync
|
||||
// reader blocks inside getStatus(), so lateness measured afterwards misses it.
|
||||
it('keeps the event loop alive while config.toml reads hang', async () => {
|
||||
hangReads.value = true
|
||||
syncFsCalls.length = 0
|
||||
|
||||
const armedAt = Date.now()
|
||||
let observedDelayMs = -1
|
||||
const timer = new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
observedDelayMs = Date.now() - armedAt
|
||||
resolve()
|
||||
}, 5)
|
||||
})
|
||||
|
||||
const pending = Promise.resolve(new KimiHookService().getStatus())
|
||||
void pending.catch(() => {})
|
||||
await timer
|
||||
|
||||
expect(observedDelayMs).toBeGreaterThanOrEqual(0)
|
||||
expect(observedDelayMs).toBeLessThan(TIMER_BUDGET_MS)
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('serializes overlapping installs so config.toml never renames out of order', async () => {
|
||||
const service = new KimiHookService()
|
||||
|
||||
const results = await Promise.all([service.install(), service.install()])
|
||||
|
||||
expect(results.map((status) => status.state)).toEqual(['installed', 'installed'])
|
||||
expect((await service.getStatus()).state).toBe('installed')
|
||||
})
|
||||
})
|
||||
@@ -39,12 +39,12 @@ const configPath = (): string => join(home, '.kimi-code', 'config.toml')
|
||||
const scriptPath = (): string => join(home, '.orca', 'agent-hooks', 'kimi-hook.sh')
|
||||
|
||||
describe('KimiHookService', () => {
|
||||
it('reports not_installed before install', () => {
|
||||
expect(new KimiHookService().getStatus().state).toBe('not_installed')
|
||||
it('reports not_installed before install', async () => {
|
||||
expect((await new KimiHookService().getStatus()).state).toBe('not_installed')
|
||||
})
|
||||
|
||||
it('installs the managed hooks block and the managed script', () => {
|
||||
const status = new KimiHookService().install()
|
||||
it('installs the managed hooks block and the managed script', async () => {
|
||||
const status = await new KimiHookService().install()
|
||||
expect(status.state).toBe('installed')
|
||||
expect(status.managedHooksPresent).toBe(true)
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('KimiHookService', () => {
|
||||
expect(config).toContain('agent-hooks/kimi-hook.sh')
|
||||
})
|
||||
|
||||
it('keeps user config when installing, then restores it on remove', () => {
|
||||
it('keeps user config when installing, then restores it on remove', async () => {
|
||||
const dir = join(home, '.kimi-code')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
// Pre-existing user config with their own provider.
|
||||
@@ -73,18 +73,18 @@ describe('KimiHookService', () => {
|
||||
writeFileSync(configPath(), userConfig)
|
||||
|
||||
const service = new KimiHookService()
|
||||
expect(service.install().state).toBe('installed')
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
const installed = readFileSync(configPath(), 'utf-8')
|
||||
expect(installed).toContain('api_key = "sk-secret"')
|
||||
expect(installed).toContain('default_model = "kimi-k2.6"')
|
||||
|
||||
// Reinstall must not duplicate the managed block.
|
||||
service.install()
|
||||
await service.install()
|
||||
const reinstalled = readFileSync(configPath(), 'utf-8')
|
||||
expect((reinstalled.match(/orca-managed-kimi-hooks \(/g) ?? []).length).toBe(1)
|
||||
|
||||
const removed = service.remove()
|
||||
const removed = await service.remove()
|
||||
expect(removed.state).toBe('not_installed')
|
||||
const afterRemove = readFileSync(configPath(), 'utf-8')
|
||||
expect(afterRemove).toBe(userConfig)
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join, posix as pathPosix } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
@@ -16,7 +8,7 @@ import {
|
||||
createManagedCommandMatcher,
|
||||
getSharedManagedScriptPath,
|
||||
wrapPosixHookCommand,
|
||||
writeManagedScript
|
||||
writeManagedScriptAsync
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
readTextFileRemote,
|
||||
@@ -92,48 +84,70 @@ function getManagedScript(): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// Why: async fs keeps a stalled ~/.kimi-code mount off the Electron main thread;
|
||||
// a hung read now parks a threadpool slot instead of freezing the whole app.
|
||||
function isMissingPath(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
return code === 'ENOENT' || code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
// Returns the file text, '' when the config does not exist yet (Kimi creates it
|
||||
// lazily), or null on an unreadable file so callers can report a structured error.
|
||||
function readConfigToml(configPath: string): string | null {
|
||||
if (!existsSync(configPath)) {
|
||||
return ''
|
||||
}
|
||||
async function readConfigToml(configPath: string): Promise<string | null> {
|
||||
try {
|
||||
return readFileSync(configPath, 'utf-8')
|
||||
} catch {
|
||||
return null
|
||||
return await readFile(configPath, 'utf-8')
|
||||
} catch (error) {
|
||||
return isMissingPath(error) ? '' : null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: temp+rename keeps a hand-editable config.toml intact if a write is
|
||||
// interrupted, and a single rolling .bak makes a bad write recoverable.
|
||||
function writeConfigToml(configPath: string, text: string): void {
|
||||
async function writeConfigToml(configPath: string, text: string): Promise<void> {
|
||||
const dir = dirname(configPath)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
if (readFileSync(configPath, 'utf-8') === text) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the atomic write path.
|
||||
await mkdir(dir, { recursive: true })
|
||||
try {
|
||||
if ((await readFile(configPath, 'utf-8')) === text) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Absent or unreadable: fall through to the atomic write path.
|
||||
}
|
||||
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
|
||||
try {
|
||||
writeFileSync(tmpPath, text, 'utf-8')
|
||||
if (existsSync(configPath)) {
|
||||
copyFileSync(configPath, `${configPath}.bak`)
|
||||
}
|
||||
renameSync(tmpPath, configPath)
|
||||
} finally {
|
||||
if (existsSync(tmpPath)) {
|
||||
try {
|
||||
unlinkSync(tmpPath)
|
||||
} catch {
|
||||
// best effort
|
||||
await writeFile(tmpPath, text, 'utf-8')
|
||||
try {
|
||||
await copyFile(configPath, `${configPath}.bak`)
|
||||
} catch (error) {
|
||||
// No prior config to back up; any other failure still aborts the write.
|
||||
if (!isMissingPath(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
await rename(tmpPath, configPath)
|
||||
} finally {
|
||||
// ENOENT after a successful rename is the normal case.
|
||||
await unlink(tmpPath).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// Why: sync fs serialized read-modify-write for free. Chain mutations so two
|
||||
// overlapping install/remove calls can never rename config.toml out of order.
|
||||
let pendingMutation: Promise<unknown> = Promise.resolve()
|
||||
|
||||
function serializeConfigMutation<T>(run: () => Promise<T>): Promise<T> {
|
||||
const next = pendingMutation.then(run, run)
|
||||
pendingMutation = next.catch(() => {})
|
||||
return next
|
||||
}
|
||||
|
||||
function unreadableConfigStatus(configPath: string): AgentHookInstallStatus {
|
||||
return {
|
||||
agent: 'kimi',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not read Kimi config.toml'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,40 +169,30 @@ function buildStatus(present: Set<string>, configPath: string): AgentHookInstall
|
||||
}
|
||||
|
||||
export class KimiHookService {
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
async getStatus(): Promise<AgentHookInstallStatus> {
|
||||
const configPath = getConfigPath()
|
||||
const text = readConfigToml(configPath)
|
||||
const text = await readConfigToml(configPath)
|
||||
if (text === null) {
|
||||
return {
|
||||
agent: 'kimi',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not read Kimi config.toml'
|
||||
}
|
||||
return unreadableConfigStatus(configPath)
|
||||
}
|
||||
const isManagedCommand = createManagedCommandMatcher(MANAGED_SCRIPT_FILE_NAME)
|
||||
return buildStatus(readManagedKimiHookEvents(text, isManagedCommand), configPath)
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const text = readConfigToml(configPath)
|
||||
if (text === null) {
|
||||
return {
|
||||
agent: 'kimi',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not read Kimi config.toml'
|
||||
install(): Promise<AgentHookInstallStatus> {
|
||||
return serializeConfigMutation(async () => {
|
||||
const configPath = getConfigPath()
|
||||
const text = await readConfigToml(configPath)
|
||||
if (text === null) {
|
||||
return unreadableConfigStatus(configPath)
|
||||
}
|
||||
}
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const command = getManagedCommand(scriptPath)
|
||||
// Write the script first so config.toml never points at a missing script.
|
||||
writeManagedScript(scriptPath, getManagedScript())
|
||||
writeConfigToml(configPath, applyManagedKimiHooks(text, command))
|
||||
return this.getStatus()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const command = getManagedCommand(scriptPath)
|
||||
// Write the script first so config.toml never points at a missing script.
|
||||
await writeManagedScriptAsync(scriptPath, getManagedScript())
|
||||
await writeConfigToml(configPath, applyManagedKimiHooks(text, command))
|
||||
return this.getStatus()
|
||||
})
|
||||
}
|
||||
|
||||
// Why: install Orca's managed Kimi hooks on a remote box over SFTP, mirroring
|
||||
@@ -227,23 +231,19 @@ export class KimiHookService {
|
||||
}
|
||||
}
|
||||
|
||||
remove(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const text = readConfigToml(configPath)
|
||||
if (text === null) {
|
||||
return {
|
||||
agent: 'kimi',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not read Kimi config.toml'
|
||||
remove(): Promise<AgentHookInstallStatus> {
|
||||
return serializeConfigMutation(async () => {
|
||||
const configPath = getConfigPath()
|
||||
const text = await readConfigToml(configPath)
|
||||
if (text === null) {
|
||||
return unreadableConfigStatus(configPath)
|
||||
}
|
||||
}
|
||||
const { text: nextText, changed } = removeManagedKimiHooks(text)
|
||||
if (changed) {
|
||||
writeConfigToml(configPath, nextText)
|
||||
}
|
||||
return this.getStatus()
|
||||
const { text: nextText, changed } = removeManagedKimiHooks(text)
|
||||
if (changed) {
|
||||
await writeConfigToml(configPath, nextText)
|
||||
}
|
||||
return this.getStatus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('libuv threadpool size', () => {
|
||||
const original = process.env.UV_THREADPOOL_SIZE
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (original === undefined) {
|
||||
delete process.env.UV_THREADPOOL_SIZE
|
||||
} else {
|
||||
process.env.UV_THREADPOOL_SIZE = original
|
||||
}
|
||||
})
|
||||
|
||||
it('raises the default pool so one stalled mount cannot starve all async fs', async () => {
|
||||
delete process.env.UV_THREADPOOL_SIZE
|
||||
await import('./libuv-threadpool-size')
|
||||
expect(Number(process.env.UV_THREADPOOL_SIZE)).toBeGreaterThan(4)
|
||||
})
|
||||
|
||||
it('leaves an operator-supplied size alone', async () => {
|
||||
process.env.UV_THREADPOOL_SIZE = '8'
|
||||
await import('./libuv-threadpool-size')
|
||||
expect(process.env.UV_THREADPOOL_SIZE).toBe('8')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
// Must be imported before anything that touches async fs: libuv reads this when it
|
||||
// lazily creates the pool, and the size is fixed from then on.
|
||||
//
|
||||
// Measured on macOS with FIFOs standing in for a hung mount: at exactly poolSize
|
||||
// concurrent stalled fs ops, every other async fs call app-wide stops making
|
||||
// progress (the event loop itself stays responsive either way). The default of 4
|
||||
// is reached by a handful of unlucky reads on one dead network mount; 16 is not a
|
||||
// bound, just enough headroom that a single stalled path cannot starve the rest.
|
||||
if (!process.env.UV_THREADPOOL_SIZE) {
|
||||
process.env.UV_THREADPOOL_SIZE = '16'
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Why: freezes #20/#42/#43. MiniMax status/save/clear ran existsSync + the secure-file
|
||||
// stat/chmod pair on the Electron main thread; a stalled ~/.orca mount froze the app.
|
||||
const { syncFsCalls, hangFs, testState, safeStorageMock } = vi.hoisted(() => ({
|
||||
syncFsCalls: [] as string[],
|
||||
hangFs: { value: false },
|
||||
testState: { homeDir: '' },
|
||||
safeStorageMock: {
|
||||
isEncryptionAvailable: vi.fn(() => true),
|
||||
encryptString: vi.fn((value: string) => Buffer.from(value)),
|
||||
decryptString: vi.fn((value: Buffer) => value.toString('utf8'))
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ safeStorage: safeStorageMock }))
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
const actual = (await vi.importActual('node:os')) as Record<string, unknown>
|
||||
const homedir = (): string => testState.homeDir
|
||||
return { ...actual, default: { ...actual, homedir }, homedir }
|
||||
})
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = (await vi.importActual('node:fs')) as Record<string, unknown>
|
||||
const wrapped: Record<string, unknown> = { ...actual }
|
||||
for (const [name, value] of Object.entries(actual)) {
|
||||
if (name.endsWith('Sync') && typeof value === 'function') {
|
||||
wrapped[name] = (...args: unknown[]) => {
|
||||
syncFsCalls.push(`${name}:${String(args[0])}`)
|
||||
return (value as (...a: unknown[]) => unknown)(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...wrapped, default: wrapped }
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = (await vi.importActual('node:fs/promises')) as typeof FsPromises
|
||||
const wrapped: Record<string, unknown> = { ...actual }
|
||||
for (const name of [
|
||||
'mkdir',
|
||||
'writeFile',
|
||||
'rename',
|
||||
'rm',
|
||||
'chmod',
|
||||
'stat',
|
||||
'access',
|
||||
'readFile'
|
||||
] as const) {
|
||||
const original = actual[name] as (...a: unknown[]) => Promise<unknown>
|
||||
wrapped[name] = (...args: unknown[]) =>
|
||||
hangFs.value ? new Promise<never>(() => {}) : original(...args)
|
||||
}
|
||||
return { ...wrapped, default: wrapped }
|
||||
})
|
||||
|
||||
import {
|
||||
clearMiniMaxSessionCookieAsync,
|
||||
hasMiniMaxSessionCookieAsync,
|
||||
readMiniMaxSessionCookieAsync,
|
||||
saveMiniMaxSessionCookieAsync
|
||||
} from './minimax-cookie-store'
|
||||
|
||||
const cookiePath = (): string => join(testState.homeDir, '.orca', 'minimax-session-cookie.enc')
|
||||
|
||||
describe('minimax cookie store off the main thread', () => {
|
||||
beforeEach(() => {
|
||||
testState.homeDir = mkdtempSync(join(tmpdir(), 'orca-minimax-async-'))
|
||||
hangFs.value = false
|
||||
syncFsCalls.length = 0
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
hangFs.value = false
|
||||
// Why: not awaited — a clear queued behind a hung write never settles, by design.
|
||||
// The cache reset it exists for happens synchronously at call time.
|
||||
void clearMiniMaxSessionCookieAsync().catch(() => undefined)
|
||||
rmSync(testState.homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('saves, reports and clears the cookie without a single synchronous fs syscall', async () => {
|
||||
await saveMiniMaxSessionCookieAsync('_token=abc; minimax_group_id_v2=42')
|
||||
await expect(hasMiniMaxSessionCookieAsync()).resolves.toBe(true)
|
||||
const afterSave = [...syncFsCalls]
|
||||
|
||||
expect(afterSave).toEqual([])
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(cookiePath()).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
|
||||
syncFsCalls.length = 0
|
||||
await clearMiniMaxSessionCookieAsync()
|
||||
const afterClear = [...syncFsCalls]
|
||||
|
||||
expect(afterClear).toEqual([])
|
||||
await expect(hasMiniMaxSessionCookieAsync()).resolves.toBe(false)
|
||||
await expect(readMiniMaxSessionCookieAsync()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('serializes an overlapping save and clear so the cleared cookie cannot survive', async () => {
|
||||
const save = saveMiniMaxSessionCookieAsync('_token=abc')
|
||||
const clear = clearMiniMaxSessionCookieAsync()
|
||||
await Promise.all([save, clear])
|
||||
|
||||
// Both layers: a bare rm unlinks before the save's rename republishes the file,
|
||||
// and the save's cache write lands after the clear nulled it.
|
||||
expect(existsSync(cookiePath())).toBe(false)
|
||||
await expect(readMiniMaxSessionCookieAsync()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the event loop alive while the cookie write hangs', async () => {
|
||||
hangFs.value = true
|
||||
syncFsCalls.length = 0
|
||||
|
||||
const pending = saveMiniMaxSessionCookieAsync('_token=abc')
|
||||
void pending.catch(() => {})
|
||||
|
||||
let timerFired = false
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
timerFired = true
|
||||
resolve()
|
||||
}, 10)
|
||||
})
|
||||
|
||||
expect(timerFired).toBe(true)
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -15,15 +15,21 @@ vi.mock('electron', () => electronMock)
|
||||
|
||||
const existsSyncMock = vi.fn()
|
||||
const readFileSyncMock = vi.fn()
|
||||
const rmSyncMock = vi.fn()
|
||||
const hardenExistingSecureFileMock = vi.fn()
|
||||
const writeSecureFileMock = vi.fn()
|
||||
const accessMock = vi.fn()
|
||||
const readFileMock = vi.fn()
|
||||
const hardenExistingSecureFileAsyncMock = vi.fn()
|
||||
const removeSecureFileAsyncMock = vi.fn()
|
||||
const writeSecureFileAsyncMock = vi.fn()
|
||||
const homedirMock = vi.fn(() => '/home/test')
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: existsSyncMock,
|
||||
readFileSync: readFileSyncMock,
|
||||
rmSync: rmSyncMock
|
||||
readFileSync: readFileSyncMock
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: accessMock,
|
||||
readFile: readFileMock
|
||||
}))
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
@@ -35,14 +41,19 @@ vi.mock('node:path', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../shared/secure-file', () => ({
|
||||
hardenExistingSecureFile: hardenExistingSecureFileMock,
|
||||
writeSecureFile: writeSecureFileMock
|
||||
hardenExistingSecureFileAsync: hardenExistingSecureFileAsyncMock,
|
||||
removeSecureFileAsync: removeSecureFileAsyncMock,
|
||||
writeSecureFileAsync: writeSecureFileAsyncMock
|
||||
}))
|
||||
|
||||
const storePath = '/home/test/.orca/minimax-session-cookie.enc'
|
||||
const envelope = (kind: 'encrypted' | 'plaintext', value: string): string =>
|
||||
`orca-minimax-cookie:v1:${kind}:${Buffer.from(value, 'utf8').toString('base64')}`
|
||||
|
||||
function missingFileError(): NodeJS.ErrnoException {
|
||||
return Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
async function loadStore(): Promise<typeof MiniMaxCookieStore> {
|
||||
return await import('./minimax-cookie-store')
|
||||
}
|
||||
@@ -51,9 +62,14 @@ describe('minimax-cookie-store', () => {
|
||||
beforeEach(() => {
|
||||
existsSyncMock.mockReset()
|
||||
readFileSyncMock.mockReset()
|
||||
rmSyncMock.mockReset()
|
||||
hardenExistingSecureFileMock.mockReset()
|
||||
writeSecureFileMock.mockReset()
|
||||
accessMock.mockReset()
|
||||
readFileMock.mockReset()
|
||||
hardenExistingSecureFileAsyncMock.mockReset()
|
||||
hardenExistingSecureFileAsyncMock.mockResolvedValue(undefined)
|
||||
removeSecureFileAsyncMock.mockReset()
|
||||
removeSecureFileAsyncMock.mockResolvedValue(undefined)
|
||||
writeSecureFileAsyncMock.mockReset()
|
||||
writeSecureFileAsyncMock.mockResolvedValue(undefined)
|
||||
safeStorageMock.isEncryptionAvailable.mockReset()
|
||||
safeStorageMock.encryptString.mockReset()
|
||||
safeStorageMock.decryptString.mockReset()
|
||||
@@ -70,60 +86,92 @@ describe('minimax-cookie-store', () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const store = await loadStore()
|
||||
expect(store.hasMiniMaxSessionCookie()).toBe(false)
|
||||
expect(hardenExistingSecureFileMock).not.toHaveBeenCalled()
|
||||
expect(hardenExistingSecureFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hardens the cookie file when checking status for an existing cookie', async () => {
|
||||
it('reports an existing cookie without hardening it', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
const store = await loadStore()
|
||||
expect(store.hasMiniMaxSessionCookie()).toBe(true)
|
||||
expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath)
|
||||
// Hardening is a write-time invariant; the status probe must stay a single syscall.
|
||||
expect(hardenExistingSecureFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still reports an existing cookie when status-path hardening fails', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
hardenExistingSecureFileMock.mockImplementation(() => {
|
||||
throw new Error('permission denied')
|
||||
})
|
||||
it('reports status asynchronously without touching sync fs', async () => {
|
||||
accessMock.mockResolvedValue(undefined)
|
||||
const store = await loadStore()
|
||||
expect(store.hasMiniMaxSessionCookie()).toBe(true)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to harden MiniMax cookie file'),
|
||||
expect.any(Error)
|
||||
)
|
||||
warn.mockRestore()
|
||||
await expect(store.hasMiniMaxSessionCookieAsync()).resolves.toBe(true)
|
||||
expect(accessMock).toHaveBeenCalledWith(storePath)
|
||||
expect(existsSyncMock).not.toHaveBeenCalled()
|
||||
|
||||
accessMock.mockRejectedValue(missingFileError())
|
||||
await expect(store.hasMiniMaxSessionCookieAsync()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('writes the cookie using safeStorage when encryption is available', async () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const store = await loadStore()
|
||||
store.saveMiniMaxSessionCookie('_token=abc; minimax_group_id_v2=42')
|
||||
await store.saveMiniMaxSessionCookieAsync('_token=abc; minimax_group_id_v2=42')
|
||||
expect(safeStorageMock.encryptString).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42')
|
||||
expect(writeSecureFileMock).toHaveBeenCalledWith(
|
||||
expect(writeSecureFileAsyncMock).toHaveBeenCalledWith(
|
||||
storePath,
|
||||
envelope('encrypted', '_token=abc; minimax_group_id_v2=42')
|
||||
)
|
||||
})
|
||||
|
||||
it('clears through the same per-path serializer the save uses', async () => {
|
||||
const store = await loadStore()
|
||||
await store.saveMiniMaxSessionCookieAsync('_token=async')
|
||||
|
||||
await store.clearMiniMaxSessionCookieAsync()
|
||||
// A bare rm() would bypass secure-file's per-path chain and unlink before an
|
||||
// in-flight save's rename republished the cookie.
|
||||
expect(removeSecureFileAsyncMock).toHaveBeenCalledWith(storePath)
|
||||
|
||||
readFileMock.mockRejectedValue(missingFileError())
|
||||
await expect(store.readMiniMaxSessionCookieAsync()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('does not resurrect a cleared cookie when a queued save lands after the clear', async () => {
|
||||
let releaseSave = (): void => undefined
|
||||
writeSecureFileAsyncMock.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
releaseSave = () => resolve()
|
||||
})
|
||||
)
|
||||
const store = await loadStore()
|
||||
|
||||
const save = store.saveMiniMaxSessionCookieAsync('_token=queued')
|
||||
const clear = store.clearMiniMaxSessionCookieAsync()
|
||||
releaseSave()
|
||||
await Promise.all([save, clear])
|
||||
|
||||
// The cache is the layer that leaks: readMiniMaxSessionCookie returns it without
|
||||
// ever reaching the (already removed) file.
|
||||
readFileSyncMock.mockImplementation(() => {
|
||||
throw missingFileError()
|
||||
})
|
||||
expect(store.readMiniMaxSessionCookie()).toBeNull()
|
||||
})
|
||||
|
||||
it('warns and writes plaintext when safeStorage is unavailable', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const store = await loadStore()
|
||||
store.saveMiniMaxSessionCookie('_token=abc')
|
||||
expect(writeSecureFileMock).toHaveBeenCalledWith(storePath, envelope('plaintext', '_token=abc'))
|
||||
await store.saveMiniMaxSessionCookieAsync('_token=abc')
|
||||
expect(writeSecureFileAsyncMock).toHaveBeenCalledWith(
|
||||
storePath,
|
||||
envelope('plaintext', '_token=abc')
|
||||
)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('refuses empty cookies', async () => {
|
||||
const store = await loadStore()
|
||||
expect(() => store.saveMiniMaxSessionCookie(' ')).toThrow(/required/)
|
||||
await expect(store.saveMiniMaxSessionCookieAsync(' ')).rejects.toThrow(/required/)
|
||||
})
|
||||
|
||||
it('reads decrypted cookie from disk and caches it', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
|
||||
safeStorageMock.decryptString.mockReturnValue('_token=cached; minimax_group_id_v2=9')
|
||||
const store = await loadStore()
|
||||
@@ -131,21 +179,37 @@ describe('minimax-cookie-store', () => {
|
||||
const second = store.readMiniMaxSessionCookie()
|
||||
expect(first).toBe('_token=cached; minimax_group_id_v2=9')
|
||||
expect(second).toBe(first)
|
||||
expect(hardenExistingSecureFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath)
|
||||
expect(hardenExistingSecureFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(hardenExistingSecureFileAsyncMock).toHaveBeenCalledWith(storePath)
|
||||
expect(safeStorageMock.decryptString).toHaveBeenCalledTimes(1)
|
||||
expect(safeStorageMock.decryptString).toHaveBeenCalledWith(Buffer.from('encrypted-payload'))
|
||||
})
|
||||
|
||||
it('still returns the cookie when read-path hardening fails', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
hardenExistingSecureFileAsyncMock.mockRejectedValue(new Error('permission denied'))
|
||||
readFileMock.mockResolvedValue(Buffer.from(envelope('plaintext', '_token=hardening-failed')))
|
||||
const store = await loadStore()
|
||||
await expect(store.readMiniMaxSessionCookieAsync()).resolves.toBe('_token=hardening-failed')
|
||||
await Promise.resolve()
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to harden MiniMax cookie file'),
|
||||
expect.any(Error)
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('returns null when no file exists', async () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
readFileSyncMock.mockImplementation(() => {
|
||||
throw missingFileError()
|
||||
})
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBeNull()
|
||||
expect(existsSyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns enveloped plaintext when safeStorage is unavailable and reads succeed', async () => {
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from(envelope('plaintext', '_token=plaintext')))
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBe('_token=plaintext')
|
||||
@@ -153,14 +217,12 @@ describe('minimax-cookie-store', () => {
|
||||
|
||||
it('reads legacy plaintext cookies when decrypting is unavailable', async () => {
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from('_token=legacy'))
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBe('_token=legacy')
|
||||
})
|
||||
|
||||
it('reads legacy plaintext cookies when decrypting fails', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from('_token=legacy'))
|
||||
safeStorageMock.decryptString.mockImplementation(() => {
|
||||
throw new Error('boom')
|
||||
@@ -171,7 +233,6 @@ describe('minimax-cookie-store', () => {
|
||||
|
||||
it('does not treat encrypted legacy bytes as plaintext when safeStorage is unavailable', async () => {
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from('encrypted-payload'))
|
||||
const store = await loadStore()
|
||||
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
|
||||
@@ -179,14 +240,12 @@ describe('minimax-cookie-store', () => {
|
||||
|
||||
it('throws for encrypted envelopes when safeStorage is unavailable', async () => {
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
|
||||
const store = await loadStore()
|
||||
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
|
||||
})
|
||||
|
||||
it('throws when decryption fails', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
|
||||
safeStorageMock.decryptString.mockImplementation(() => {
|
||||
throw new Error('boom')
|
||||
@@ -195,14 +254,24 @@ describe('minimax-cookie-store', () => {
|
||||
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
|
||||
})
|
||||
|
||||
it('surfaces a non-ENOENT read failure as a decrypt failure', async () => {
|
||||
readFileSyncMock.mockImplementation(() => {
|
||||
throw Object.assign(new Error('EACCES'), { code: 'EACCES' })
|
||||
})
|
||||
const store = await loadStore()
|
||||
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
|
||||
})
|
||||
|
||||
it('clears the cached cookie and removes the file', async () => {
|
||||
existsSyncMock.mockReturnValueOnce(true)
|
||||
readFileSyncMock.mockReturnValueOnce(Buffer.from(envelope('encrypted', 'encrypted-payload')))
|
||||
readFileSyncMock.mockImplementation(() => {
|
||||
throw missingFileError()
|
||||
})
|
||||
safeStorageMock.decryptString.mockReturnValueOnce('_token=preclear')
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBe('_token=preclear')
|
||||
store.clearMiniMaxSessionCookie()
|
||||
expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true })
|
||||
await store.clearMiniMaxSessionCookieAsync()
|
||||
expect(removeSecureFileAsyncMock).toHaveBeenCalledWith(storePath)
|
||||
expect(store.readMiniMaxSessionCookie()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { safeStorage } from 'electron'
|
||||
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { access, readFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file'
|
||||
import {
|
||||
hardenExistingSecureFileAsync,
|
||||
removeSecureFileAsync,
|
||||
writeSecureFileAsync
|
||||
} from '../../shared/secure-file'
|
||||
|
||||
const MINIMAX_COOKIE_FILE = 'minimax-session-cookie.enc'
|
||||
const COOKIE_ENVELOPE_PREFIX = 'orca-minimax-cookie:v1:'
|
||||
let cachedMiniMaxCookie: string | null = null
|
||||
let warnedMiniMaxCookieStatusHardenFailure = false
|
||||
// Why: secure-file orders the fs ops per path but not the cache, whose writes happen after
|
||||
// an await; the last caller wins the cache so a clear can't be undone by a queued save.
|
||||
let miniMaxCookieCacheGeneration = 0
|
||||
|
||||
type MiniMaxCookieEnvelope = {
|
||||
kind: 'encrypted' | 'plaintext'
|
||||
@@ -96,70 +103,114 @@ function readLegacyCookie(raw: Buffer): string {
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync twin kept for RateLimitService.getState(), which is a synchronous snapshot
|
||||
* builder. Hardening is a write-time invariant, so the status probe no longer pays
|
||||
* the stat/chmod pair — one access() instead of ~7 syscalls under a stalled HOME.
|
||||
*/
|
||||
export function hasMiniMaxSessionCookie(): boolean {
|
||||
const keyPath = getMiniMaxCookiePath()
|
||||
if (!existsSync(keyPath)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
hardenExistingSecureFile(keyPath)
|
||||
} catch (error) {
|
||||
if (!warnedMiniMaxCookieStatusHardenFailure) {
|
||||
warnedMiniMaxCookieStatusHardenFailure = true
|
||||
console.warn('[minimax] Failed to harden MiniMax cookie file while checking status', error)
|
||||
}
|
||||
}
|
||||
return true
|
||||
return existsSync(getMiniMaxCookiePath())
|
||||
}
|
||||
|
||||
export function saveMiniMaxSessionCookie(cookie: string): void {
|
||||
export async function hasMiniMaxSessionCookieAsync(): Promise<boolean> {
|
||||
try {
|
||||
await access(getMiniMaxCookiePath())
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCookieForStorage(cookie: string): string {
|
||||
const trimmed = cookie.trim()
|
||||
if (!trimmed) {
|
||||
throw new Error('MiniMax session cookie is required')
|
||||
}
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
writeSecureFile(
|
||||
getMiniMaxCookiePath(),
|
||||
encodeCookieEnvelope('encrypted', safeStorage.encryptString(trimmed))
|
||||
)
|
||||
cachedMiniMaxCookie = trimmed
|
||||
return
|
||||
return encodeCookieEnvelope('encrypted', safeStorage.encryptString(trimmed))
|
||||
}
|
||||
console.warn('[minimax] safeStorage encryption unavailable — storing MiniMax cookie in plaintext')
|
||||
writeSecureFile(
|
||||
getMiniMaxCookiePath(),
|
||||
encodeCookieEnvelope('plaintext', Buffer.from(trimmed, 'utf8'))
|
||||
)
|
||||
cachedMiniMaxCookie = trimmed
|
||||
return encodeCookieEnvelope('plaintext', Buffer.from(trimmed, 'utf8'))
|
||||
}
|
||||
|
||||
export function readMiniMaxSessionCookie(): string | null {
|
||||
if (cachedMiniMaxCookie !== null) {
|
||||
return cachedMiniMaxCookie
|
||||
export async function saveMiniMaxSessionCookieAsync(cookie: string): Promise<void> {
|
||||
const encoded = encodeCookieForStorage(cookie)
|
||||
const generation = ++miniMaxCookieCacheGeneration
|
||||
await writeSecureFileAsync(getMiniMaxCookiePath(), encoded)
|
||||
if (generation === miniMaxCookieCacheGeneration) {
|
||||
cachedMiniMaxCookie = cookie.trim()
|
||||
}
|
||||
const keyPath = getMiniMaxCookiePath()
|
||||
if (!existsSync(keyPath)) {
|
||||
return null
|
||||
}
|
||||
// Why: keep hardening out of the decode/decrypt try below so a chmod/ACL
|
||||
// failure isn't misreported as a decrypt failure (matches hasMiniMaxSessionCookie).
|
||||
}
|
||||
|
||||
function decodeStoredCookie(raw: Buffer): string {
|
||||
try {
|
||||
hardenExistingSecureFile(keyPath)
|
||||
} catch (error) {
|
||||
console.warn('[minimax] Failed to harden MiniMax cookie file while reading', error)
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(keyPath)
|
||||
const envelope = decodeCookieEnvelope(raw)
|
||||
cachedMiniMaxCookie = envelope ? readEnvelope(envelope) : readLegacyCookie(raw)
|
||||
return cachedMiniMaxCookie
|
||||
return envelope ? readEnvelope(envelope) : readLegacyCookie(raw)
|
||||
} catch (error) {
|
||||
console.error('[minimax] failed to decode/decrypt session cookie', error)
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
}
|
||||
|
||||
export function clearMiniMaxSessionCookie(): void {
|
||||
// Why: re-hardening is defence in depth, not a correctness gate — fire it off-thread so a stalled mount can't park the read.
|
||||
function scheduleCookieRehardening(keyPath: string): void {
|
||||
void hardenExistingSecureFileAsync(keyPath).catch((error: unknown) => {
|
||||
console.warn('[minimax] Failed to harden MiniMax cookie file while reading', error)
|
||||
})
|
||||
}
|
||||
|
||||
/** Sync twin kept for the MiniMax config resolver, which RateLimitService calls synchronously. */
|
||||
export function readMiniMaxSessionCookie(): string | null {
|
||||
if (cachedMiniMaxCookie !== null) {
|
||||
return cachedMiniMaxCookie
|
||||
}
|
||||
const keyPath = getMiniMaxCookiePath()
|
||||
let raw: Buffer
|
||||
try {
|
||||
// Why: a single read replaces existsSync+readFileSync; ENOENT already means "not configured".
|
||||
raw = readFileSync(keyPath)
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return null
|
||||
}
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
scheduleCookieRehardening(keyPath)
|
||||
cachedMiniMaxCookie = decodeStoredCookie(raw)
|
||||
return cachedMiniMaxCookie
|
||||
}
|
||||
|
||||
export async function readMiniMaxSessionCookieAsync(): Promise<string | null> {
|
||||
if (cachedMiniMaxCookie !== null) {
|
||||
return cachedMiniMaxCookie
|
||||
}
|
||||
const keyPath = getMiniMaxCookiePath()
|
||||
let raw: Buffer
|
||||
try {
|
||||
raw = await readFile(keyPath)
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return null
|
||||
}
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
scheduleCookieRehardening(keyPath)
|
||||
cachedMiniMaxCookie = decodeStoredCookie(raw)
|
||||
return cachedMiniMaxCookie
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
return code === 'ENOENT' || code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
export async function clearMiniMaxSessionCookieAsync(): Promise<void> {
|
||||
const generation = ++miniMaxCookieCacheGeneration
|
||||
cachedMiniMaxCookie = null
|
||||
rmSync(getMiniMaxCookiePath(), { force: true })
|
||||
// Why: a bare rm would unlink before an in-flight save's rename republished the file.
|
||||
await removeSecureFileAsync(getMiniMaxCookiePath())
|
||||
// Why: a sync read can repopulate the cache from disk while we wait for our turn.
|
||||
if (generation === miniMaxCookieCacheGeneration) {
|
||||
cachedMiniMaxCookie = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
// Async twins of the profile index store, for IPC handlers that run after the
|
||||
// window exists. The sync bootstrap in `profile-index-store.ts` stays — it runs
|
||||
// before there is a renderer to block. Everything here keeps the Electron main
|
||||
// thread out of `mkdir`/`copyFile`/`rename` syscalls, which enter an
|
||||
// uninterruptible wait when the userData path lives on a stalled network mount.
|
||||
import { access, copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import {
|
||||
DEFAULT_LOCAL_ORCA_PROFILE_ID,
|
||||
type CreateLocalOrcaProfileArgs,
|
||||
type CreateLocalOrcaProfileResult,
|
||||
type OrcaProfileIndex,
|
||||
type OrcaProfileListState
|
||||
} from '../../shared/orca-profiles'
|
||||
import {
|
||||
appendProfileToIndex,
|
||||
buildNewLocalProfile,
|
||||
createInitialProfileIndex,
|
||||
getActiveProfile,
|
||||
parseProfileIndexJson,
|
||||
serializeProfileIndex,
|
||||
withActiveProfile
|
||||
} from './profile-index-document'
|
||||
import {
|
||||
getOrcaProfileBrowserSessionMetaFile,
|
||||
getOrcaProfileDataFile,
|
||||
getOrcaProfileDirectory,
|
||||
getOrcaProfileIndexPath,
|
||||
getProfileUserDataPath,
|
||||
LEGACY_BACKUP_COUNT,
|
||||
legacyBackupPath,
|
||||
legacyBrowserSessionMetaPath,
|
||||
legacyDataFilePath,
|
||||
profileBackupPath
|
||||
} from './profile-storage-paths'
|
||||
import type { ActiveOrcaProfileState } from './profile-index-store'
|
||||
|
||||
// Why: `orcaProfiles:list` sits on the app-startup chain. Boot already resolved
|
||||
// the index, so cache it and let the handler answer with zero fs syscalls.
|
||||
const cachedIndexByPath = new Map<string, OrcaProfileIndex>()
|
||||
|
||||
// Why: async writes lose the single-thread serialization the sync store got for
|
||||
// free. Sync writers still exist (startup, cloud index); count their writes so
|
||||
// an async read-modify-write that raced one retries instead of clobbering it.
|
||||
// Keyed by index path: a global counter would let a write to one index falsely
|
||||
// veto an in-flight write to another, and three false vetoes throw.
|
||||
const syncWriteEpochs = new Map<string, number>()
|
||||
const MAX_INDEX_WRITE_ATTEMPTS = 3
|
||||
|
||||
function syncWriteEpochFor(indexPath: string): number {
|
||||
return syncWriteEpochs.get(indexPath) ?? 0
|
||||
}
|
||||
|
||||
// Why: distinct from the sync store's `.tmp` so an interleaved sync write can
|
||||
// never rename a half-written async temp file over a good index.
|
||||
const ASYNC_TMP_SUFFIX = '.async.tmp'
|
||||
|
||||
// Staged temp file of the async write currently parked on `rename`, per index
|
||||
// path. An epoch check cannot stop that rename — it is already in flight — so
|
||||
// the sync twin deletes this file instead and the rename fails with ENOENT.
|
||||
const inFlightAsyncTmpFiles = new Map<string, string>()
|
||||
|
||||
export function cacheProfileIndex(indexPath: string, index: OrcaProfileIndex): void {
|
||||
cachedIndexByPath.set(indexPath, index)
|
||||
}
|
||||
|
||||
/** Veto handshake: the sync writer takes the staged temp path and unlinks it. */
|
||||
export function claimInFlightAsyncIndexTmpPath(indexPath: string): string | null {
|
||||
const tmpPath = inFlightAsyncTmpFiles.get(indexPath)
|
||||
inFlightAsyncTmpFiles.delete(indexPath)
|
||||
return tmpPath ?? null
|
||||
}
|
||||
|
||||
export function recordSyncProfileIndexWrite(indexPath: string, index: OrcaProfileIndex): void {
|
||||
syncWriteEpochs.set(indexPath, syncWriteEpochFor(indexPath) + 1)
|
||||
cachedIndexByPath.set(indexPath, index)
|
||||
}
|
||||
|
||||
// Single-flight per index path: two overlapping writes can never rename out of
|
||||
// order, and a read-modify-write can hold the lane across its read.
|
||||
const writeChains = new Map<string, Promise<void>>()
|
||||
|
||||
function runExclusive<T>(indexPath: string, task: () => Promise<T>): Promise<T> {
|
||||
const chain = (writeChains.get(indexPath) ?? Promise.resolve()).then(task, task)
|
||||
const settled = chain.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
writeChains.set(indexPath, settled)
|
||||
void settled.then(() => {
|
||||
if (writeChains.get(indexPath) === settled) {
|
||||
writeChains.delete(indexPath)
|
||||
}
|
||||
})
|
||||
return chain
|
||||
}
|
||||
|
||||
async function readProfileIndexFileAsync(indexPath: string): Promise<OrcaProfileIndex | null> {
|
||||
try {
|
||||
return parseProfileIndexJson(await readFile(indexPath, 'utf-8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function readProfileIndexAsync(indexPath: string): Promise<OrcaProfileIndex | null> {
|
||||
// Why: a torn/corrupt index must not silently reset the app to a single
|
||||
// default profile — that would orphan every other profile's data directory.
|
||||
return (
|
||||
(await readProfileIndexFileAsync(indexPath)) ??
|
||||
(await readProfileIndexFileAsync(`${indexPath}.bak`))
|
||||
)
|
||||
}
|
||||
|
||||
/** Resolves false when a sync writer won the file; the caller must rebuild. */
|
||||
async function writeProfileIndexFile(indexPath: string, index: OrcaProfileIndex): Promise<boolean> {
|
||||
const epoch = syncWriteEpochFor(indexPath)
|
||||
await mkdir(dirname(indexPath), { recursive: true })
|
||||
// Why: only a still-parseable current index may refresh the backup; copying a
|
||||
// corrupt file over the backup would destroy the recovery copy. The read
|
||||
// doubles as the existence probe, so there is no separate exists() syscall.
|
||||
if (await readProfileIndexFileAsync(indexPath)) {
|
||||
await copyFile(indexPath, `${indexPath}.bak`).catch(() => {
|
||||
// Best-effort backup; the primary write below still proceeds.
|
||||
})
|
||||
}
|
||||
const tmpPath = `${indexPath}${ASYNC_TMP_SUFFIX}`
|
||||
await writeFile(tmpPath, serializeProfileIndex(index), 'utf-8')
|
||||
if (syncWriteEpochFor(indexPath) !== epoch) {
|
||||
// A sync writer committed while the temp file was staged. Checking in the
|
||||
// same turn that issues the rename is what makes this guard meaningful.
|
||||
await unlink(tmpPath).catch(() => {})
|
||||
return false
|
||||
}
|
||||
inFlightAsyncTmpFiles.set(indexPath, tmpPath)
|
||||
try {
|
||||
await rename(tmpPath, indexPath)
|
||||
} catch (error) {
|
||||
// ENOENT is the veto: a sync writer deleted the temp file out from under
|
||||
// the parked rename. Anything else is a real failure.
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return false
|
||||
}
|
||||
await unlink(tmpPath).catch(() => {})
|
||||
throw error
|
||||
} finally {
|
||||
if (inFlightAsyncTmpFiles.get(indexPath) === tmpPath) {
|
||||
inFlightAsyncTmpFiles.delete(indexPath)
|
||||
}
|
||||
}
|
||||
if (syncWriteEpochFor(indexPath) !== epoch) {
|
||||
// The rename beat the veto but a sync write landed on top of it. Leave that
|
||||
// writer's cache entry alone instead of publishing this stale index.
|
||||
return false
|
||||
}
|
||||
cachedIndexByPath.set(indexPath, index)
|
||||
return true
|
||||
}
|
||||
|
||||
async function loadOrCreateIndex(indexPath: string): Promise<OrcaProfileIndex> {
|
||||
const index = await readProfileIndexAsync(indexPath)
|
||||
if (index) {
|
||||
return index
|
||||
}
|
||||
const nextIndex = createInitialProfileIndex()
|
||||
// A vetoed seed write is handled by the caller's retry, which re-reads.
|
||||
await writeProfileIndexFile(indexPath, nextIndex)
|
||||
return nextIndex
|
||||
}
|
||||
|
||||
async function mutateProfileIndexAsync(
|
||||
userDataPath: string,
|
||||
mutate: (index: OrcaProfileIndex) => OrcaProfileIndex | Promise<OrcaProfileIndex>
|
||||
): Promise<OrcaProfileIndex> {
|
||||
const indexPath = getOrcaProfileIndexPath(userDataPath)
|
||||
return runExclusive(indexPath, async () => {
|
||||
for (let attempt = 1; attempt <= MAX_INDEX_WRITE_ATTEMPTS; attempt++) {
|
||||
const epoch = syncWriteEpochFor(indexPath)
|
||||
const next = await mutate(await loadOrCreateIndex(indexPath))
|
||||
// Re-read when a sync writer landed mid-mutation so its update survives.
|
||||
if (
|
||||
syncWriteEpochFor(indexPath) === epoch &&
|
||||
(await writeProfileIndexFile(indexPath, next))
|
||||
) {
|
||||
return next
|
||||
}
|
||||
}
|
||||
// Why throw instead of force-writing: reporting a mutation that a sync
|
||||
// writer overwrote is worse than surfacing the failure to the caller.
|
||||
throw new Error('orca_profile_index_write_lost_to_concurrent_sync_write')
|
||||
})
|
||||
}
|
||||
|
||||
async function copyIfPresentAsync(source: string, target: string): Promise<void> {
|
||||
try {
|
||||
await access(target)
|
||||
return
|
||||
} catch {
|
||||
// Not migrated yet.
|
||||
}
|
||||
const tmpTarget = `${target}${ASYNC_TMP_SUFFIX}`
|
||||
try {
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
// Why: tmp+rename so a crash mid-copy cannot leave a truncated target that
|
||||
// the access() guard above would then treat as a completed migration. A
|
||||
// missing source surfaces as ENOENT here instead of a second probe.
|
||||
await copyFile(source, tmpTarget)
|
||||
await rename(tmpTarget, target)
|
||||
} catch {
|
||||
await unlink(tmpTarget).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLegacyStateToProfileAsync(
|
||||
userDataPath: string,
|
||||
profileId: string
|
||||
): Promise<void> {
|
||||
const profileDataFile = getOrcaProfileDataFile(profileId, userDataPath)
|
||||
await copyIfPresentAsync(legacyDataFilePath(userDataPath), profileDataFile)
|
||||
await copyIfPresentAsync(
|
||||
legacyBrowserSessionMetaPath(userDataPath),
|
||||
getOrcaProfileBrowserSessionMetaFile(profileId, userDataPath)
|
||||
)
|
||||
for (let i = 0; i < LEGACY_BACKUP_COUNT; i++) {
|
||||
await copyIfPresentAsync(
|
||||
legacyBackupPath(userDataPath, i),
|
||||
profileBackupPath(profileDataFile, i)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves null when a sync writer replaced the index mid-flight. */
|
||||
async function resolveActiveProfileOnce(
|
||||
indexPath: string,
|
||||
userDataPath: string
|
||||
): Promise<ActiveOrcaProfileState | null> {
|
||||
const epoch = syncWriteEpochFor(indexPath)
|
||||
const fromDisk = await readProfileIndexAsync(indexPath)
|
||||
let index = fromDisk ?? createInitialProfileIndex()
|
||||
let shouldWriteIndex = fromDisk === null
|
||||
|
||||
const activeProfile = getActiveProfile(index)
|
||||
if (activeProfile.id !== index.activeProfileId) {
|
||||
index = { ...index, activeProfileId: activeProfile.id }
|
||||
shouldWriteIndex = true
|
||||
}
|
||||
|
||||
const profileDirectory = getOrcaProfileDirectory(activeProfile.id, userDataPath)
|
||||
await mkdir(profileDirectory, { recursive: true })
|
||||
if (activeProfile.id === DEFAULT_LOCAL_ORCA_PROFILE_ID) {
|
||||
await copyLegacyStateToProfileAsync(userDataPath, activeProfile.id)
|
||||
}
|
||||
|
||||
if (shouldWriteIndex) {
|
||||
if (!(await writeProfileIndexFile(indexPath, index))) {
|
||||
return null
|
||||
}
|
||||
} else if (syncWriteEpochFor(indexPath) !== epoch) {
|
||||
// The awaits above let a sync writer commit a newer index; publishing this
|
||||
// one would poison the zero-fs list cache with a stale profile set.
|
||||
return null
|
||||
} else {
|
||||
cachedIndexByPath.set(indexPath, index)
|
||||
}
|
||||
|
||||
return {
|
||||
index,
|
||||
profile: activeProfile,
|
||||
dataFile: getOrcaProfileDataFile(activeProfile.id, userDataPath),
|
||||
profileDirectory
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureActiveOrcaProfileAsync(
|
||||
userDataPath = getProfileUserDataPath()
|
||||
): Promise<ActiveOrcaProfileState> {
|
||||
const indexPath = getOrcaProfileIndexPath(userDataPath)
|
||||
return runExclusive(indexPath, async () => {
|
||||
for (let attempt = 1; attempt <= MAX_INDEX_WRITE_ATTEMPTS; attempt++) {
|
||||
const resolved = await resolveActiveProfileOnce(indexPath, userDataPath)
|
||||
if (resolved) {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
throw new Error('orca_profile_index_write_lost_to_concurrent_sync_write')
|
||||
})
|
||||
}
|
||||
|
||||
export async function getOrcaProfileListStateAsync(
|
||||
userDataPath = getProfileUserDataPath()
|
||||
): Promise<OrcaProfileListState> {
|
||||
const cached = cachedIndexByPath.get(getOrcaProfileIndexPath(userDataPath))
|
||||
const index = cached ?? (await ensureActiveOrcaProfileAsync(userDataPath)).index
|
||||
return { activeProfileId: index.activeProfileId, profiles: index.profiles }
|
||||
}
|
||||
|
||||
export async function createLocalOrcaProfileAsync(
|
||||
args: CreateLocalOrcaProfileArgs = {},
|
||||
userDataPath = getProfileUserDataPath()
|
||||
): Promise<CreateLocalOrcaProfileResult> {
|
||||
const profile = buildNewLocalProfile(args)
|
||||
const nextIndex = await mutateProfileIndexAsync(userDataPath, async (index) => {
|
||||
await mkdir(getOrcaProfileDirectory(profile.id, userDataPath), { recursive: true })
|
||||
return appendProfileToIndex(index, profile)
|
||||
})
|
||||
return {
|
||||
activeProfileId: nextIndex.activeProfileId,
|
||||
profiles: nextIndex.profiles,
|
||||
profile
|
||||
}
|
||||
}
|
||||
|
||||
export async function setActiveOrcaProfileAsync(
|
||||
profileId: string,
|
||||
userDataPath = getProfileUserDataPath()
|
||||
): Promise<OrcaProfileListState> {
|
||||
const nextIndex = await mutateProfileIndexAsync(userDataPath, async (index) => {
|
||||
// Why: validate before mkdir so an unknown id cannot leave a stray directory.
|
||||
const next = withActiveProfile(index, profileId)
|
||||
await mkdir(getOrcaProfileDirectory(profileId, userDataPath), { recursive: true })
|
||||
return next
|
||||
})
|
||||
return { activeProfileId: nextIndex.activeProfileId, profiles: nextIndex.profiles }
|
||||
}
|
||||
|
||||
// Why: a brand-new profile has no data file, which the telemetry cohort
|
||||
// migration reads as a fresh install and defaults to opted-in. Copying the
|
||||
// active profile's consent block keeps an opted-out user opted out (and keeps
|
||||
// one installId per install) when they create additional profiles.
|
||||
export async function seedNewOrcaProfileTelemetryConsentAsync(
|
||||
profileId: string,
|
||||
telemetry: GlobalSettings['telemetry'],
|
||||
userDataPath = getProfileUserDataPath()
|
||||
): Promise<void> {
|
||||
if (!telemetry) {
|
||||
return
|
||||
}
|
||||
const dataFile = getOrcaProfileDataFile(profileId, userDataPath)
|
||||
try {
|
||||
// Matches the sync twin's existsSync guard, which is also false for EACCES
|
||||
// and friends: never overwrite a data file this profile already has.
|
||||
await access(dataFile)
|
||||
return
|
||||
} catch {
|
||||
// Not seeded yet.
|
||||
}
|
||||
await mkdir(dirname(dataFile), { recursive: true })
|
||||
const tmpPath = `${dataFile}${ASYNC_TMP_SUFFIX}`
|
||||
try {
|
||||
// Why tmp+rename and not an in-place write: a torn write on a stalled mount
|
||||
// would leave a partial JSON document at the real path, which the store
|
||||
// then reads as a corrupt profile.
|
||||
await writeFile(tmpPath, JSON.stringify({ settings: { telemetry } }, null, 2), 'utf-8')
|
||||
await rename(tmpPath, dataFile)
|
||||
} catch (error) {
|
||||
await unlink(tmpPath).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Pure shaping/validation for the on-disk profile index. No fs here, so the
|
||||
// sync bootstrap store and its async twins share one source of truth.
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
createDefaultLocalOrcaProfile,
|
||||
DEFAULT_LOCAL_ORCA_PROFILE_NAME,
|
||||
ORCA_PROFILE_INDEX_SCHEMA_VERSION,
|
||||
type CreateLocalOrcaProfileArgs,
|
||||
type OrcaProfileIndex,
|
||||
type OrcaProfileSummary
|
||||
} from '../../shared/orca-profiles'
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isProfileSummary(value: unknown): value is OrcaProfileSummary {
|
||||
if (!isObject(value)) {
|
||||
return false
|
||||
}
|
||||
const avatar = value.avatar
|
||||
const cloud = value.cloud
|
||||
return (
|
||||
typeof value.id === 'string' &&
|
||||
// Why: IDs from the on-disk index become filesystem path segments; a
|
||||
// tampered index must not be able to escape the profiles directory.
|
||||
/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value.id) &&
|
||||
typeof value.name === 'string' &&
|
||||
value.name.length > 0 &&
|
||||
(value.kind === 'local' || value.kind === 'cloud-linked') &&
|
||||
typeof value.createdAt === 'number' &&
|
||||
typeof value.updatedAt === 'number' &&
|
||||
typeof value.lastOpenedAt === 'number' &&
|
||||
isObject(avatar) &&
|
||||
avatar.kind === 'initials' &&
|
||||
typeof avatar.initials === 'string' &&
|
||||
avatar.color === 'neutral' &&
|
||||
(cloud === undefined || isObject(cloud))
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeProfileIndex(raw: unknown): OrcaProfileIndex | null {
|
||||
if (!isObject(raw) || !Array.isArray(raw.profiles)) {
|
||||
return null
|
||||
}
|
||||
const profiles = raw.profiles.filter(isProfileSummary)
|
||||
const activeProfileId =
|
||||
typeof raw.activeProfileId === 'string' &&
|
||||
profiles.some((profile) => profile.id === raw.activeProfileId)
|
||||
? raw.activeProfileId
|
||||
: profiles[0]?.id
|
||||
if (!activeProfileId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
|
||||
activeProfileId,
|
||||
profiles
|
||||
}
|
||||
}
|
||||
|
||||
export function parseProfileIndexJson(contents: string): OrcaProfileIndex | null {
|
||||
try {
|
||||
return normalizeProfileIndex(JSON.parse(contents))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeProfileIndex(index: OrcaProfileIndex): string {
|
||||
return JSON.stringify(index, null, 2)
|
||||
}
|
||||
|
||||
function sanitizeProfileName(value: unknown): string {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : ''
|
||||
return trimmed.length > 0 ? trimmed.slice(0, 80) : 'New Profile'
|
||||
}
|
||||
|
||||
export function createInitialProfileIndex(now = Date.now()): OrcaProfileIndex {
|
||||
const profile = createDefaultLocalOrcaProfile(now)
|
||||
return {
|
||||
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
|
||||
activeProfileId: profile.id,
|
||||
profiles: [profile]
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveProfile(index: OrcaProfileIndex): OrcaProfileSummary {
|
||||
return (
|
||||
index.profiles.find((profile) => profile.id === index.activeProfileId) ??
|
||||
index.profiles[0] ??
|
||||
createDefaultLocalOrcaProfile(Date.now())
|
||||
)
|
||||
}
|
||||
|
||||
// Why: minted outside the index write so a retried write reuses the same id
|
||||
// (and therefore the same profile directory) instead of orphaning one.
|
||||
export function buildNewLocalProfile(
|
||||
args: CreateLocalOrcaProfileArgs,
|
||||
now = Date.now()
|
||||
): OrcaProfileSummary {
|
||||
const name = sanitizeProfileName(args.name)
|
||||
return {
|
||||
id: `local-${randomUUID()}`,
|
||||
name,
|
||||
avatar: {
|
||||
kind: 'initials',
|
||||
initials: (
|
||||
name.match(/[A-Za-z0-9]/)?.[0] ?? DEFAULT_LOCAL_ORCA_PROFILE_NAME[0]
|
||||
).toUpperCase(),
|
||||
color: 'neutral'
|
||||
},
|
||||
kind: 'local',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastOpenedAt: now
|
||||
}
|
||||
}
|
||||
|
||||
export function appendProfileToIndex(
|
||||
index: OrcaProfileIndex,
|
||||
profile: OrcaProfileSummary
|
||||
): OrcaProfileIndex {
|
||||
return { ...index, profiles: [...index.profiles, profile] }
|
||||
}
|
||||
|
||||
export function withActiveProfile(
|
||||
index: OrcaProfileIndex,
|
||||
profileId: string,
|
||||
now = Date.now()
|
||||
): OrcaProfileIndex {
|
||||
let found = false
|
||||
const profiles = index.profiles.map((profile) => {
|
||||
if (profile.id !== profileId) {
|
||||
return profile
|
||||
}
|
||||
found = true
|
||||
return { ...profile, updatedAt: now, lastOpenedAt: now }
|
||||
})
|
||||
if (!found) {
|
||||
throw new Error('unknown_orca_profile')
|
||||
}
|
||||
return { ...index, activeProfileId: profileId, profiles }
|
||||
}
|
||||
@@ -1,25 +1,40 @@
|
||||
// Sync bootstrap for the profile index. These run before there is a window to
|
||||
// block (src/main/index.ts) or from callers that cannot await. IPC handlers must
|
||||
// use the async twins in `profile-index-async-store.ts` instead — a sync fs call
|
||||
// on a stalled network mount freezes the whole app.
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { dirname } from 'node:path'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import {
|
||||
createDefaultLocalOrcaProfile,
|
||||
DEFAULT_LOCAL_ORCA_PROFILE_ID,
|
||||
DEFAULT_LOCAL_ORCA_PROFILE_NAME,
|
||||
ORCA_PROFILE_INDEX_SCHEMA_VERSION,
|
||||
type CreateLocalOrcaProfileArgs,
|
||||
type CreateLocalOrcaProfileResult,
|
||||
type OrcaProfileIndex,
|
||||
type OrcaProfileListState,
|
||||
type OrcaProfileSummary
|
||||
} from '../../shared/orca-profiles'
|
||||
import {
|
||||
appendProfileToIndex,
|
||||
buildNewLocalProfile,
|
||||
createInitialProfileIndex,
|
||||
getActiveProfile,
|
||||
parseProfileIndexJson,
|
||||
serializeProfileIndex,
|
||||
withActiveProfile
|
||||
} from './profile-index-document'
|
||||
import {
|
||||
cacheProfileIndex,
|
||||
claimInFlightAsyncIndexTmpPath,
|
||||
recordSyncProfileIndexWrite
|
||||
} from './profile-index-async-store'
|
||||
import {
|
||||
getOrcaProfileBrowserSessionMetaFile,
|
||||
getOrcaProfileDataFile,
|
||||
@@ -42,6 +57,15 @@ export {
|
||||
initOrcaProfilePaths
|
||||
} from './profile-storage-paths'
|
||||
|
||||
export {
|
||||
createLocalOrcaProfileAsync,
|
||||
ensureActiveOrcaProfileAsync,
|
||||
getOrcaProfileListStateAsync,
|
||||
readProfileIndexAsync,
|
||||
seedNewOrcaProfileTelemetryConsentAsync,
|
||||
setActiveOrcaProfileAsync
|
||||
} from './profile-index-async-store'
|
||||
|
||||
export type ActiveOrcaProfileState = {
|
||||
index: OrcaProfileIndex
|
||||
profile: OrcaProfileSummary
|
||||
@@ -49,63 +73,9 @@ export type ActiveOrcaProfileState = {
|
||||
profileDirectory: string
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isProfileSummary(value: unknown): value is OrcaProfileSummary {
|
||||
if (!isObject(value)) {
|
||||
return false
|
||||
}
|
||||
const avatar = value.avatar
|
||||
const cloud = value.cloud
|
||||
return (
|
||||
typeof value.id === 'string' &&
|
||||
// Why: IDs from the on-disk index become filesystem path segments; a
|
||||
// tampered index must not be able to escape the profiles directory.
|
||||
/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value.id) &&
|
||||
typeof value.name === 'string' &&
|
||||
value.name.length > 0 &&
|
||||
(value.kind === 'local' || value.kind === 'cloud-linked') &&
|
||||
typeof value.createdAt === 'number' &&
|
||||
typeof value.updatedAt === 'number' &&
|
||||
typeof value.lastOpenedAt === 'number' &&
|
||||
isObject(avatar) &&
|
||||
avatar.kind === 'initials' &&
|
||||
typeof avatar.initials === 'string' &&
|
||||
avatar.color === 'neutral' &&
|
||||
(cloud === undefined || isObject(cloud))
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeProfileIndex(raw: unknown): OrcaProfileIndex | null {
|
||||
if (!isObject(raw) || !Array.isArray(raw.profiles)) {
|
||||
return null
|
||||
}
|
||||
const profiles = raw.profiles.filter(isProfileSummary)
|
||||
const activeProfileId =
|
||||
typeof raw.activeProfileId === 'string' &&
|
||||
profiles.some((profile) => profile.id === raw.activeProfileId)
|
||||
? raw.activeProfileId
|
||||
: profiles[0]?.id
|
||||
if (!activeProfileId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
|
||||
activeProfileId,
|
||||
profiles
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeProfileName(value: unknown): string {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : ''
|
||||
return trimmed.length > 0 ? trimmed.slice(0, 80) : 'New Profile'
|
||||
}
|
||||
|
||||
function readProfileIndexFile(indexPath: string): OrcaProfileIndex | null {
|
||||
try {
|
||||
return normalizeProfileIndex(JSON.parse(readFileSync(indexPath, 'utf-8')))
|
||||
return parseProfileIndexJson(readFileSync(indexPath, 'utf-8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -118,6 +88,17 @@ export function readProfileIndex(indexPath: string): OrcaProfileIndex | null {
|
||||
}
|
||||
|
||||
export function writeProfileIndex(indexPath: string, index: OrcaProfileIndex): void {
|
||||
// Veto an async write already parked on `rename`: no guard can stop that
|
||||
// rename, but deleting the temp file it is renaming makes it fail with
|
||||
// ENOENT, so this write is not silently clobbered by the async twin.
|
||||
const asyncTmpPath = claimInFlightAsyncIndexTmpPath(indexPath)
|
||||
if (asyncTmpPath) {
|
||||
try {
|
||||
unlinkSync(asyncTmpPath)
|
||||
} catch {
|
||||
// Already renamed or never created; the async writer re-reads either way.
|
||||
}
|
||||
}
|
||||
mkdirSync(dirname(indexPath), { recursive: true })
|
||||
// Why: only a still-parseable current index may refresh the backup;
|
||||
// copying a corrupt file over the backup would destroy the recovery copy.
|
||||
@@ -129,8 +110,11 @@ export function writeProfileIndex(indexPath: string, index: OrcaProfileIndex): v
|
||||
}
|
||||
}
|
||||
const tmpPath = `${indexPath}.tmp`
|
||||
writeFileSync(tmpPath, JSON.stringify(index, null, 2), 'utf-8')
|
||||
writeFileSync(tmpPath, serializeProfileIndex(index), 'utf-8')
|
||||
renameSync(tmpPath, indexPath)
|
||||
// Why: keeps the zero-fs list cache correct and lets an async read-modify-write
|
||||
// detect that a sync writer landed underneath it.
|
||||
recordSyncProfileIndexWrite(indexPath, index)
|
||||
}
|
||||
|
||||
function copyIfPresent(source: string, target: string): void {
|
||||
@@ -179,15 +163,6 @@ export function seedNewOrcaProfileTelemetryConsent(
|
||||
renameSync(tmpPath, dataFile)
|
||||
}
|
||||
|
||||
function createInitialProfileIndex(now = Date.now()): OrcaProfileIndex {
|
||||
const profile = createDefaultLocalOrcaProfile(now)
|
||||
return {
|
||||
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
|
||||
activeProfileId: profile.id,
|
||||
profiles: [profile]
|
||||
}
|
||||
}
|
||||
|
||||
export function loadOrCreateProfileIndex(userDataPath: string): OrcaProfileIndex {
|
||||
const indexPath = getOrcaProfileIndexPath(userDataPath)
|
||||
const index = existsSync(indexPath) ? readProfileIndex(indexPath) : null
|
||||
@@ -199,14 +174,6 @@ export function loadOrCreateProfileIndex(userDataPath: string): OrcaProfileIndex
|
||||
return nextIndex
|
||||
}
|
||||
|
||||
function getActiveProfile(index: OrcaProfileIndex): OrcaProfileSummary {
|
||||
return (
|
||||
index.profiles.find((profile) => profile.id === index.activeProfileId) ??
|
||||
index.profiles[0] ??
|
||||
createDefaultLocalOrcaProfile(Date.now())
|
||||
)
|
||||
}
|
||||
|
||||
export function ensureActiveOrcaProfile(
|
||||
userDataPath = getProfileUserDataPath()
|
||||
): ActiveOrcaProfileState {
|
||||
@@ -233,6 +200,10 @@ export function ensureActiveOrcaProfile(
|
||||
|
||||
if (shouldWriteIndex) {
|
||||
writeProfileIndex(indexPath, index)
|
||||
} else {
|
||||
// Why: boot resolves the index here; caching it lets orcaProfiles:list
|
||||
// answer the startup chain without touching the filesystem at all.
|
||||
cacheProfileIndex(indexPath, index)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -262,27 +233,8 @@ export function createLocalOrcaProfile(
|
||||
userDataPath = getProfileUserDataPath()
|
||||
): CreateLocalOrcaProfileResult {
|
||||
const index = loadOrCreateProfileIndex(userDataPath)
|
||||
const now = Date.now()
|
||||
const name = sanitizeProfileName(args.name)
|
||||
const profile: OrcaProfileSummary = {
|
||||
id: `local-${randomUUID()}`,
|
||||
name,
|
||||
avatar: {
|
||||
kind: 'initials',
|
||||
initials: (
|
||||
name.match(/[A-Za-z0-9]/)?.[0] ?? DEFAULT_LOCAL_ORCA_PROFILE_NAME[0]
|
||||
).toUpperCase(),
|
||||
color: 'neutral'
|
||||
},
|
||||
kind: 'local',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastOpenedAt: now
|
||||
}
|
||||
const nextIndex: OrcaProfileIndex = {
|
||||
...index,
|
||||
profiles: [...index.profiles, profile]
|
||||
}
|
||||
const profile = buildNewLocalProfile(args)
|
||||
const nextIndex = appendProfileToIndex(index, profile)
|
||||
mkdirSync(getOrcaProfileDirectory(profile.id, userDataPath), { recursive: true })
|
||||
writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex)
|
||||
return {
|
||||
@@ -297,27 +249,7 @@ export function setActiveOrcaProfile(
|
||||
userDataPath = getProfileUserDataPath()
|
||||
): OrcaProfileListState {
|
||||
const index = loadOrCreateProfileIndex(userDataPath)
|
||||
const now = Date.now()
|
||||
let found = false
|
||||
const profiles = index.profiles.map((profile) => {
|
||||
if (profile.id !== profileId) {
|
||||
return profile
|
||||
}
|
||||
found = true
|
||||
return {
|
||||
...profile,
|
||||
updatedAt: now,
|
||||
lastOpenedAt: now
|
||||
}
|
||||
})
|
||||
if (!found) {
|
||||
throw new Error('unknown_orca_profile')
|
||||
}
|
||||
const nextIndex: OrcaProfileIndex = {
|
||||
...index,
|
||||
activeProfileId: profileId,
|
||||
profiles
|
||||
}
|
||||
const nextIndex = withActiveProfile(index, profileId)
|
||||
mkdirSync(getOrcaProfileDirectory(profileId, userDataPath), { recursive: true })
|
||||
writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex)
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// The veto makes a sync write win over an async write parked on `rename`, and the
|
||||
// async side then retries. That retry re-runs the caller's mutation against a
|
||||
// re-read index, so it is only safe if the vetoed attempt left nothing of its own
|
||||
// on disk — otherwise an append would be applied twice.
|
||||
|
||||
const gate = vi.hoisted(() => ({
|
||||
blockRename: false,
|
||||
waiters: [] as (() => void)[],
|
||||
renameCalls: 0
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
|
||||
const rename = (async (...args: Parameters<typeof actual.rename>) => {
|
||||
gate.renameCalls += 1
|
||||
if (gate.blockRename) {
|
||||
await new Promise<void>((resolve) => gate.waiters.push(resolve))
|
||||
}
|
||||
return actual.rename(...args)
|
||||
}) as typeof actual.rename
|
||||
return { ...actual, default: { ...actual, rename }, rename }
|
||||
})
|
||||
|
||||
describe('profile index veto retry integrity', () => {
|
||||
let userDataPath: string
|
||||
const indexPath = (): string => join(userDataPath, 'orca-profile-index.json')
|
||||
const readIndex = (): { profiles: { id: string }[] } =>
|
||||
JSON.parse(readFileSync(indexPath(), 'utf-8'))
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-profile-veto-'))
|
||||
gate.blockRename = false
|
||||
gate.waiters = []
|
||||
gate.renameCalls = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('a sync write that vetoes a parked async append does not duplicate the retried profile', async () => {
|
||||
const { createLocalOrcaProfileAsync } = await import('./profile-index-async-store')
|
||||
const { createLocalOrcaProfile, getOrcaProfileListState } =
|
||||
await import('./profile-index-store')
|
||||
|
||||
// Seed the index so the async attempt below is a real append, not a first create.
|
||||
createLocalOrcaProfile({ name: 'seed' }, userDataPath)
|
||||
const seedCount = getOrcaProfileListState(userDataPath).profiles.length
|
||||
|
||||
gate.blockRename = true
|
||||
const pending = createLocalOrcaProfileAsync({ name: 'async-profile' }, userDataPath)
|
||||
await vi.waitFor(() => expect(gate.renameCalls).toBeGreaterThan(0))
|
||||
|
||||
// The sync writer lands while the async writer is parked on its rename.
|
||||
createLocalOrcaProfile({ name: 'sync-profile' }, userDataPath)
|
||||
|
||||
gate.blockRename = false
|
||||
gate.waiters.splice(0).forEach((resolve) => resolve())
|
||||
await pending
|
||||
|
||||
// Guards the test itself: without a second rename the veto never fired and the
|
||||
// assertions below would pass without exercising the retry at all.
|
||||
expect(gate.renameCalls).toBeGreaterThan(1)
|
||||
|
||||
const names = readIndex().profiles.map((p) => (p as { name?: string }).name)
|
||||
const asyncOccurrences = names.filter((n) => n === 'async-profile').length
|
||||
const syncOccurrences = names.filter((n) => n === 'sync-profile').length
|
||||
|
||||
// Neither writer may be lost, and the retried one may not be applied twice.
|
||||
expect(asyncOccurrences).toBe(1)
|
||||
expect(syncOccurrences).toBe(1)
|
||||
expect(readIndex().profiles).toHaveLength(seedCount + 2)
|
||||
expect(new Set(readIndex().profiles.map((p) => p.id)).size).toBe(seedCount + 2)
|
||||
expect(readdirSync(userDataPath).filter((f) => f.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
// Why: `orca.yaml` lives at the repo root, which may be a stalled SMB/NFS mount or a FIFO.
|
||||
// Any *Sync fs call on that path blocks the Electron main thread for as long as the mount
|
||||
// hangs — no repaint, no menus, no Force Quit (freezes #1 and #9). These tests pin that the
|
||||
// polled/IPC hook lookups reach the file only through fs/promises.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const { syncCalls, readFileMock, accessMock } = vi.hoisted(() => ({
|
||||
syncCalls: [] as string[],
|
||||
readFileMock: vi.fn(),
|
||||
accessMock: vi.fn()
|
||||
}))
|
||||
|
||||
// Record every sync fs entry point hooks.ts could reach for; the assertions below
|
||||
// check that none of them were handed an orca.yaml / .orca path.
|
||||
vi.mock('node:fs', () => {
|
||||
const record =
|
||||
(name: string) =>
|
||||
(...args: unknown[]) => {
|
||||
syncCalls.push(`${name}:${String(args[0])}`)
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
return {
|
||||
existsSync: vi.fn(record('existsSync')),
|
||||
readFileSync: vi.fn(record('readFileSync')),
|
||||
statSync: vi.fn(record('statSync')),
|
||||
mkdirSync: vi.fn(record('mkdirSync')),
|
||||
writeFileSync: vi.fn(record('writeFileSync')),
|
||||
chmodSync: vi.fn(record('chmodSync')),
|
||||
rmSync: vi.fn(record('rmSync')),
|
||||
openSync: vi.fn(record('openSync')),
|
||||
accessSync: vi.fn(record('accessSync'))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', () => ({ readFile: readFileMock, access: accessMock }))
|
||||
|
||||
vi.mock('./git/runner', () => ({
|
||||
gitExecFileSync: vi.fn(() => ''),
|
||||
promptGuardShellEnv: (env: NodeJS.ProcessEnv) => env
|
||||
}))
|
||||
|
||||
const REPO = join('/mnt', 'stalled-nas', 'repo')
|
||||
const YAML = join(REPO, 'orca.yaml')
|
||||
const ISSUE_COMMAND = join(REPO, '.orca', 'issue-command')
|
||||
|
||||
const enoent = (): NodeJS.ErrnoException => Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||
|
||||
beforeEach(() => {
|
||||
syncCalls.length = 0
|
||||
readFileMock.mockReset()
|
||||
accessMock.mockReset()
|
||||
accessMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('orca.yaml hook lookups never issue a sync syscall', () => {
|
||||
it('answers hooks:check from one async read with no existsSync probe', async () => {
|
||||
readFileMock.mockResolvedValue('scripts:\n setup: pnpm install\n')
|
||||
|
||||
const { checkOrcaYamlHooks } = await import('./hooks')
|
||||
await expect(checkOrcaYamlHooks(REPO)).resolves.toEqual({
|
||||
hasHooksFile: true,
|
||||
hooks: { scripts: { setup: 'pnpm install' } },
|
||||
mayNeedUpdate: false
|
||||
})
|
||||
|
||||
expect(syncCalls).toEqual([])
|
||||
expect(readFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(readFileMock).toHaveBeenCalledWith(YAML, 'utf-8')
|
||||
})
|
||||
|
||||
it('reports mayNeedUpdate from the same read instead of a second one', async () => {
|
||||
readFileMock.mockResolvedValue('somethingBrandNew:\n value: 1\n')
|
||||
|
||||
const { checkOrcaYamlHooks } = await import('./hooks')
|
||||
await expect(checkOrcaYamlHooks(REPO)).resolves.toEqual({
|
||||
hasHooksFile: true,
|
||||
hooks: null,
|
||||
mayNeedUpdate: true
|
||||
})
|
||||
|
||||
expect(syncCalls).toEqual([])
|
||||
expect(readFileMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('treats ENOENT as the no-hooks branch', async () => {
|
||||
readFileMock.mockRejectedValue(enoent())
|
||||
|
||||
const { checkOrcaYamlHooks, loadHooksAsync } = await import('./hooks')
|
||||
await expect(checkOrcaYamlHooks(REPO)).resolves.toEqual({
|
||||
hasHooksFile: false,
|
||||
hooks: null,
|
||||
mayNeedUpdate: false
|
||||
})
|
||||
await expect(loadHooksAsync(REPO)).resolves.toBeNull()
|
||||
expect(syncCalls).toEqual([])
|
||||
})
|
||||
|
||||
// Why: an unreadable-but-present orca.yaml must still report hasHooksFile, matching
|
||||
// the existsSync semantics this replaced — a mode-000 *file* is still there.
|
||||
it('still reports the file as present when the read fails for another reason', async () => {
|
||||
readFileMock.mockRejectedValue(Object.assign(new Error('EACCES'), { code: 'EACCES' }))
|
||||
|
||||
const { checkOrcaYamlHooks } = await import('./hooks')
|
||||
await expect(checkOrcaYamlHooks(REPO)).resolves.toEqual({
|
||||
hasHooksFile: true,
|
||||
hooks: null,
|
||||
mayNeedUpdate: false
|
||||
})
|
||||
expect(accessMock).toHaveBeenCalledWith(YAML)
|
||||
expect(syncCalls).toEqual([])
|
||||
})
|
||||
|
||||
// Why: existsSync is access(F_OK), which is FALSE for EACCES/ELOOP/EIO — a repo directory at
|
||||
// mode 000 fails readFile with EACCES but reported hasHooksFile: false before the async
|
||||
// conversion. Mapping every non-ENOENT code to exists:true flipped hooks:check for those.
|
||||
it.each([['EACCES'], ['ELOOP'], ['EIO']])(
|
||||
'reports no hooks file when the path itself is unreachable (%s)',
|
||||
async (code) => {
|
||||
readFileMock.mockRejectedValue(Object.assign(new Error(code), { code }))
|
||||
accessMock.mockRejectedValue(Object.assign(new Error(code), { code }))
|
||||
|
||||
const { checkOrcaYamlHooks } = await import('./hooks')
|
||||
await expect(checkOrcaYamlHooks(REPO)).resolves.toEqual({
|
||||
hasHooksFile: false,
|
||||
hooks: null,
|
||||
mayNeedUpdate: false
|
||||
})
|
||||
expect(syncCalls).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('classifies an absent file without a second syscall', async () => {
|
||||
readFileMock.mockRejectedValue(enoent())
|
||||
|
||||
const { checkOrcaYamlHooks } = await import('./hooks')
|
||||
await expect(checkOrcaYamlHooks(REPO)).resolves.toMatchObject({ hasHooksFile: false })
|
||||
expect(accessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves the issue command without probing either path synchronously', async () => {
|
||||
readFileMock.mockImplementation(async (path: string) => {
|
||||
if (path === ISSUE_COMMAND) {
|
||||
throw enoent()
|
||||
}
|
||||
return 'issueCommand: |\n gh issue develop\n'
|
||||
})
|
||||
|
||||
const { readIssueCommandAsync } = await import('./hooks')
|
||||
await expect(readIssueCommandAsync(REPO)).resolves.toEqual({
|
||||
localContent: null,
|
||||
sharedContent: 'gh issue develop',
|
||||
effectiveContent: 'gh issue develop',
|
||||
localFilePath: ISSUE_COMMAND,
|
||||
source: 'shared'
|
||||
})
|
||||
|
||||
expect(syncCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the event loop alive while the orca.yaml read is stuck forever', async () => {
|
||||
// Why: a hung mount cannot be bounded, but it must not stop timers from firing —
|
||||
// that is the difference between "one stale panel" and "the whole app is frozen".
|
||||
readFileMock.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const { checkOrcaYamlHooks } = await import('./hooks')
|
||||
let settled = false
|
||||
void checkOrcaYamlHooks(REPO).then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
const timerFired = await new Promise<boolean>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(true)
|
||||
}, 1)
|
||||
})
|
||||
|
||||
expect(timerFired).toBe(true)
|
||||
expect(settled).toBe(false)
|
||||
expect(syncCalls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Why: freeze #19. Grok account status read auth.json with existsSync + readFileSync on
|
||||
// the Electron main thread — and RateLimitService still calls the sync twin on its poll
|
||||
// timer, so a stalled GROK_HOME froze the app with no user action at all.
|
||||
const { syncFsCalls, hangReads } = vi.hoisted(() => ({
|
||||
syncFsCalls: [] as string[],
|
||||
hangReads: { value: false }
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = (await vi.importActual('node:fs')) as Record<string, unknown>
|
||||
const wrapped: Record<string, unknown> = { ...actual }
|
||||
for (const [name, value] of Object.entries(actual)) {
|
||||
if (name.endsWith('Sync') && typeof value === 'function') {
|
||||
wrapped[name] = (...args: unknown[]) => {
|
||||
syncFsCalls.push(`${name}:${String(args[0])}`)
|
||||
return (value as (...a: unknown[]) => unknown)(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...wrapped, default: wrapped }
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = (await vi.importActual('node:fs/promises')) as typeof FsPromises
|
||||
const readFile = (...args: Parameters<typeof actual.readFile>): Promise<unknown> =>
|
||||
hangReads.value ? new Promise<never>(() => {}) : actual.readFile(...args)
|
||||
return { ...actual, default: { ...actual, readFile }, readFile }
|
||||
})
|
||||
|
||||
import { readGrokAuthSession, readGrokAuthSessionAsync, toGrokAccountStatus } from './grok-auth'
|
||||
|
||||
const FUTURE_EXPIRY = new Date(Date.now() + 60 * 60 * 1000).toISOString()
|
||||
|
||||
describe('grok auth reads off the main thread', () => {
|
||||
let grokHome: string
|
||||
let previousGrokHome: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
grokHome = mkdtempSync(join(tmpdir(), 'orca-grok-home-'))
|
||||
previousGrokHome = process.env.GROK_HOME
|
||||
process.env.GROK_HOME = grokHome
|
||||
hangReads.value = false
|
||||
syncFsCalls.length = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
hangReads.value = false
|
||||
if (previousGrokHome === undefined) {
|
||||
delete process.env.GROK_HOME
|
||||
} else {
|
||||
process.env.GROK_HOME = previousGrokHome
|
||||
}
|
||||
rmSync(grokHome, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeAuthJson(): void {
|
||||
writeFileSync(
|
||||
join(grokHome, 'auth.json'),
|
||||
JSON.stringify({
|
||||
'https://auth.x.ai': {
|
||||
key: 'token-1',
|
||||
email: 'pilot@x.ai',
|
||||
team_id: 'team-1',
|
||||
expires_at: FUTURE_EXPIRY
|
||||
}
|
||||
}),
|
||||
'utf-8'
|
||||
)
|
||||
syncFsCalls.length = 0
|
||||
}
|
||||
|
||||
it('resolves the account status without a single synchronous fs syscall', async () => {
|
||||
writeAuthJson()
|
||||
|
||||
const status = toGrokAccountStatus(await readGrokAuthSessionAsync())
|
||||
const recorded = [...syncFsCalls]
|
||||
|
||||
expect(recorded).toEqual([])
|
||||
expect(status).toEqual({
|
||||
signedIn: true,
|
||||
email: 'pilot@x.ai',
|
||||
teamId: 'team-1',
|
||||
tokenFresh: true,
|
||||
error: null
|
||||
})
|
||||
})
|
||||
|
||||
it('reports signed out asynchronously when auth.json is missing', async () => {
|
||||
expect(toGrokAccountStatus(await readGrokAuthSessionAsync())).toEqual({
|
||||
signedIn: false,
|
||||
email: null,
|
||||
teamId: null,
|
||||
tokenFresh: false,
|
||||
error: null
|
||||
})
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the event loop alive while the auth read hangs', async () => {
|
||||
writeAuthJson()
|
||||
hangReads.value = true
|
||||
|
||||
const pending = readGrokAuthSessionAsync()
|
||||
void pending.catch(() => {})
|
||||
|
||||
let timerFired = false
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
timerFired = true
|
||||
resolve()
|
||||
}, 10)
|
||||
})
|
||||
|
||||
expect(timerFired).toBe(true)
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
// The timer-driven sync twin keeps its existsSync gate: an unreachable auth path is
|
||||
// "signed out", and dropping the gate turned that into a status-bar error alert.
|
||||
it('keeps reporting missing (not error) from the sync twin when auth.json is unreachable', () => {
|
||||
writeAuthJson()
|
||||
|
||||
expect(readGrokAuthSession().status).toBe('ok')
|
||||
expect(syncFsCalls).toEqual([
|
||||
`existsSync:${join(grokHome, 'auth.json')}`,
|
||||
`readFileSync:${join(grokHome, 'auth.json')}`
|
||||
])
|
||||
|
||||
rmSync(join(grokHome, 'auth.json'))
|
||||
syncFsCalls.length = 0
|
||||
expect(readGrokAuthSession()).toEqual({ status: 'missing' })
|
||||
expect(syncFsCalls).toEqual([`existsSync:${join(grokHome, 'auth.json')}`])
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,26 @@ describe('readGrokAuthSession', () => {
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
vi.doUnmock('node:fs')
|
||||
vi.doUnmock('node:fs/promises')
|
||||
})
|
||||
|
||||
// A mount that answers neither stat nor read (EACCES/ELOOP/EIO) is "signed out", not a
|
||||
// failure — an 'error' here pins a status-bar alert on a user who never signed in.
|
||||
it('treats an unreachable auth path as signed out in both twins', async () => {
|
||||
vi.doMock('node:fs', () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(() => {
|
||||
throw new Error('EIO: i/o error')
|
||||
})
|
||||
}))
|
||||
vi.doMock('node:fs/promises', () => ({
|
||||
access: vi.fn(() => Promise.reject(new Error('EIO: i/o error'))),
|
||||
readFile: vi.fn(() => Promise.reject(new Error('EIO: i/o error')))
|
||||
}))
|
||||
const { readGrokAuthSession, readGrokAuthSessionAsync } = await import('./grok-auth')
|
||||
|
||||
expect(readGrokAuthSession()).toEqual({ status: 'missing' })
|
||||
await expect(readGrokAuthSessionAsync()).resolves.toEqual({ status: 'missing' })
|
||||
})
|
||||
|
||||
it('redacts filesystem paths from auth read failures', async () => {
|
||||
@@ -154,3 +174,57 @@ describe('readGrokAuthSession', () => {
|
||||
expect(readGrokAuthSession()).toEqual({ status: 'missing' })
|
||||
})
|
||||
})
|
||||
|
||||
// Coverage moved from the deleted grok-accounts/status.ts, whose sync getGrokAccountStatus
|
||||
// duplicated this mapper and lost its last production caller when the IPC handler went async.
|
||||
describe('toGrokAccountStatus', () => {
|
||||
it('reports signed out without an error when auth is missing', async () => {
|
||||
const { toGrokAccountStatus } = await import('./grok-auth')
|
||||
|
||||
expect(toGrokAccountStatus({ status: 'missing' })).toEqual({
|
||||
signedIn: false,
|
||||
email: null,
|
||||
teamId: null,
|
||||
tokenFresh: false,
|
||||
error: null
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces auth read errors without exposing token fields', async () => {
|
||||
const { toGrokAccountStatus } = await import('./grok-auth')
|
||||
|
||||
expect(toGrokAccountStatus({ status: 'error', error: 'Grok auth file is invalid' })).toEqual({
|
||||
signedIn: false,
|
||||
email: null,
|
||||
teamId: null,
|
||||
tokenFresh: false,
|
||||
error: 'Grok auth file is invalid'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns non-secret signed-in metadata and staleness', async () => {
|
||||
const { toGrokAccountStatus } = await import('./grok-auth')
|
||||
|
||||
const status = toGrokAccountStatus({
|
||||
status: 'ok',
|
||||
session: {
|
||||
accessToken: 'secret-token',
|
||||
email: 'dev@example.com',
|
||||
teamId: 'team-1',
|
||||
userId: 'user-1',
|
||||
expiresAtMs: Date.now() - 1_000,
|
||||
oidcClientId: 'client-1'
|
||||
}
|
||||
})
|
||||
|
||||
expect(status).toEqual({
|
||||
signedIn: true,
|
||||
email: 'dev@example.com',
|
||||
teamId: 'team-1',
|
||||
tokenFresh: false,
|
||||
error: null
|
||||
})
|
||||
expect(JSON.stringify(status)).not.toContain('secret-token')
|
||||
expect(JSON.stringify(status)).not.toContain('client-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { access, readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { GrokAccountStatus } from '../../shared/rate-limit-types'
|
||||
import { resolveGrokHomeDir } from '../../shared/grok-session-paths'
|
||||
|
||||
// Why: when GROK_HOME is set, auth.json must be the same path Grok CLI uses.
|
||||
@@ -82,13 +84,59 @@ function isPreferredGrokAuthKey(key: string): boolean {
|
||||
return key === PREFERRED_GROK_AUTH_ISSUER || key.startsWith(`${PREFERRED_GROK_AUTH_ISSUER}::`)
|
||||
}
|
||||
|
||||
/** Sync twin kept for RateLimitService, whose constructor and fetch cycles read this synchronously. */
|
||||
export function readGrokAuthSession(): GrokAuthReadResult {
|
||||
const path = getGrokAuthPath()
|
||||
// Why: an unreachable auth path (ENOENT, but also EACCES/ELOOP/EIO, which existsSync
|
||||
// reports as false) means "signed out", not a failure — 'error' pins a status-bar alert.
|
||||
if (!existsSync(path)) {
|
||||
return { status: 'missing' }
|
||||
}
|
||||
let contents: string
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'))
|
||||
contents = readFileSync(path, 'utf-8')
|
||||
} catch (err) {
|
||||
return grokAuthReadErrorResult(err)
|
||||
}
|
||||
return parseGrokAuthFile(contents)
|
||||
}
|
||||
|
||||
export async function readGrokAuthSessionAsync(): Promise<GrokAuthReadResult> {
|
||||
const path = getGrokAuthPath()
|
||||
// Why: same reachability gate as the sync twin, so the IPC status and the
|
||||
// RateLimitService snapshot can never disagree about being signed in.
|
||||
if (!(await pathIsReachable(path))) {
|
||||
return { status: 'missing' }
|
||||
}
|
||||
let contents: string
|
||||
try {
|
||||
contents = await readFile(path, 'utf-8')
|
||||
} catch (err) {
|
||||
return grokAuthReadErrorResult(err)
|
||||
}
|
||||
return parseGrokAuthFile(contents)
|
||||
}
|
||||
|
||||
async function pathIsReachable(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function grokAuthReadErrorResult(err: unknown): GrokAuthReadResult {
|
||||
const code = (err as NodeJS.ErrnoException | null)?.code
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
return { status: 'missing' }
|
||||
}
|
||||
return { status: 'error', error: getGrokAuthReadError(err) }
|
||||
}
|
||||
|
||||
function parseGrokAuthFile(contents: string): GrokAuthReadResult {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(contents)
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return { status: 'error', error: 'Grok auth file is invalid' }
|
||||
}
|
||||
@@ -134,6 +182,26 @@ export function hasGrokAuthSession(): boolean {
|
||||
return readGrokAuthSession().status === 'ok'
|
||||
}
|
||||
|
||||
export function toGrokAccountStatus(readResult: GrokAuthReadResult): GrokAccountStatus {
|
||||
if (readResult.status !== 'ok') {
|
||||
return {
|
||||
signedIn: false,
|
||||
email: null,
|
||||
teamId: null,
|
||||
tokenFresh: false,
|
||||
error: readResult.status === 'error' ? readResult.error : null
|
||||
}
|
||||
}
|
||||
const session = readResult.session
|
||||
return {
|
||||
signedIn: true,
|
||||
email: session.email,
|
||||
teamId: session.teamId,
|
||||
tokenFresh: isGrokAccessTokenFresh(session),
|
||||
error: null
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN_SKEW_MS = 5 * 60 * 1000
|
||||
|
||||
export function isGrokAccessTokenFresh(session: GrokAuthSession): boolean {
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
} from '../providers/ssh-git-dispatch'
|
||||
import { checkIgnoredPaths } from '../git/check-ignored-paths'
|
||||
import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories'
|
||||
import { getWorktreeSharedLinkPathsAsync } from '../git/worktree-shared-directories'
|
||||
import {
|
||||
cancelGenerateCommitMessageLocal,
|
||||
cancelGeneratePullRequestFieldsLocal,
|
||||
@@ -199,7 +199,7 @@ export class RuntimeGitCommands {
|
||||
const gitOptions = localGitOptionsForTarget(target)
|
||||
// Why: Git can't ignore a shared symlink under a directory-only rule, so tell
|
||||
// status which untracked entries are Orca's own artifacts (issue #10451).
|
||||
const sharedLinkPaths = target.repo ? getWorktreeSharedLinkPaths(target.repo) : []
|
||||
const sharedLinkPaths = target.repo ? await getWorktreeSharedLinkPathsAsync(target.repo) : []
|
||||
const sharedOptions = sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}
|
||||
return options
|
||||
? getGitStatus(target.worktree.path, { ...options, ...gitOptions, ...sharedOptions })
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Why: freeze #33. Saving/clearing the OpenAI speech key ran mkdirSync + writeFileSync
|
||||
// on the Electron main thread; a stalled ~/.orca mount froze the app mid-settings.
|
||||
const { syncFsCalls, hangWrites, testState, safeStorageMock } = vi.hoisted(() => ({
|
||||
syncFsCalls: [] as string[],
|
||||
hangWrites: { value: false },
|
||||
testState: { homeDir: '' },
|
||||
safeStorageMock: {
|
||||
isEncryptionAvailable: vi.fn(() => true),
|
||||
encryptString: vi.fn((value: string) => Buffer.from(value)),
|
||||
decryptString: vi.fn((value: Buffer) => value.toString('utf8'))
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ safeStorage: safeStorageMock }))
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
const actual = (await vi.importActual('node:os')) as Record<string, unknown>
|
||||
const homedir = (): string => testState.homeDir
|
||||
return { ...actual, default: { ...actual, homedir }, homedir }
|
||||
})
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = (await vi.importActual('node:fs')) as Record<string, unknown>
|
||||
const wrapped: Record<string, unknown> = { ...actual }
|
||||
for (const [name, value] of Object.entries(actual)) {
|
||||
if (name.endsWith('Sync') && typeof value === 'function') {
|
||||
wrapped[name] = (...args: unknown[]) => {
|
||||
syncFsCalls.push(`${name}:${String(args[0])}`)
|
||||
return (value as (...a: unknown[]) => unknown)(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...wrapped, default: wrapped }
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = (await vi.importActual('node:fs/promises')) as typeof FsPromises
|
||||
const wrapped: Record<string, unknown> = { ...actual }
|
||||
for (const name of ['mkdir', 'writeFile', 'rm', 'access'] as const) {
|
||||
const original = actual[name] as (...a: unknown[]) => Promise<unknown>
|
||||
wrapped[name] = (...args: unknown[]) =>
|
||||
hangWrites.value ? new Promise<never>(() => {}) : original(...args)
|
||||
}
|
||||
return { ...wrapped, default: wrapped }
|
||||
})
|
||||
|
||||
import {
|
||||
clearOpenAiSpeechApiKeyAsync,
|
||||
hasOpenAiSpeechApiKeyAsync,
|
||||
readOpenAiSpeechApiKey,
|
||||
saveOpenAiSpeechApiKeyAsync
|
||||
} from './openai-api-key-store'
|
||||
|
||||
const keyPath = (): string => join(testState.homeDir, '.orca', 'openai-speech-token.enc')
|
||||
|
||||
describe('openai speech key store off the main thread', () => {
|
||||
beforeEach(() => {
|
||||
testState.homeDir = mkdtempSync(join(tmpdir(), 'orca-openai-key-async-'))
|
||||
hangWrites.value = false
|
||||
syncFsCalls.length = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
hangWrites.value = false
|
||||
rmSync(testState.homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('saves, reports and clears the key without a single synchronous fs syscall', async () => {
|
||||
await saveOpenAiSpeechApiKeyAsync('sk-test-key')
|
||||
await expect(hasOpenAiSpeechApiKeyAsync()).resolves.toBe(true)
|
||||
const afterSave = [...syncFsCalls]
|
||||
|
||||
expect(afterSave).toEqual([])
|
||||
if (process.platform !== 'win32') {
|
||||
// Mode on the CREATE — the key must never exist world-readable, even briefly.
|
||||
expect(statSync(keyPath()).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
|
||||
syncFsCalls.length = 0
|
||||
await clearOpenAiSpeechApiKeyAsync()
|
||||
const afterClear = [...syncFsCalls]
|
||||
|
||||
expect(afterClear).toEqual([])
|
||||
await expect(hasOpenAiSpeechApiKeyAsync()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('serializes an overlapping save and clear so the clear cannot land first', async () => {
|
||||
const save = saveOpenAiSpeechApiKeyAsync('sk-test-key')
|
||||
const clear = clearOpenAiSpeechApiKeyAsync()
|
||||
await Promise.all([save, clear])
|
||||
|
||||
expect(existsSync(keyPath())).toBe(false)
|
||||
await expect(hasOpenAiSpeechApiKeyAsync()).resolves.toBe(false)
|
||||
// Disk alone is not enough: the in-memory cache is what SttService hands to the
|
||||
// cloud session, so a cleared key surviving there is still a live credential.
|
||||
expect(() => readOpenAiSpeechApiKey()).toThrow(/not configured/)
|
||||
})
|
||||
|
||||
it('keeps the event loop alive while the key write hangs', async () => {
|
||||
hangWrites.value = true
|
||||
syncFsCalls.length = 0
|
||||
|
||||
const pending = saveOpenAiSpeechApiKeyAsync('sk-test-key')
|
||||
void pending.catch(() => {})
|
||||
|
||||
let timerFired = false
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
timerFired = true
|
||||
resolve()
|
||||
}, 10)
|
||||
})
|
||||
|
||||
expect(timerFired).toBe(true)
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -72,12 +72,24 @@ describe('OpenAI speech API key store', () => {
|
||||
it('uses the in-memory key after save without decrypting from safeStorage', async () => {
|
||||
const store = await loadStoreModule()
|
||||
|
||||
store.saveOpenAiSpeechApiKey('saved-key')
|
||||
await store.saveOpenAiSpeechApiKeyAsync('saved-key')
|
||||
|
||||
expect(store.readOpenAiSpeechApiKey()).toBe('saved-key')
|
||||
expect(safeStorageMock.decryptString).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not resurrect a cleared key when a queued save lands after the clear', async () => {
|
||||
const store = await loadStoreModule()
|
||||
|
||||
const save = store.saveOpenAiSpeechApiKeyAsync('saved-key')
|
||||
const clear = store.clearOpenAiSpeechApiKeyAsync()
|
||||
await Promise.all([save, clear])
|
||||
|
||||
// The cache, not the disk, is where the resurrection shows: readOpenAiSpeechApiKey
|
||||
// returns the cached value without ever reaching the (already deleted) file.
|
||||
expect(() => store.readOpenAiSpeechApiKey()).toThrow(/not configured/)
|
||||
})
|
||||
|
||||
it('reports missing status without creating storage files', async () => {
|
||||
const store = await loadStoreModule()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { safeStorage } from 'electron'
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { access, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
@@ -9,29 +10,23 @@ type StoredOpenAiKey = {
|
||||
|
||||
const OPENAI_SPEECH_TOKEN_FILE = 'openai-speech-token.enc'
|
||||
let cachedOpenAiSpeechApiKey: string | null = null
|
||||
// Why: async writes lose the single-thread ordering the sync twin had; chain them so save/clear land in call order.
|
||||
let pendingOpenAiKeyWrite: Promise<void> = Promise.resolve()
|
||||
// Why: the chain orders the fs ops but not the cache, whose writes happen after an await;
|
||||
// the last caller wins the cache so a clear can't be undone by an already-queued save.
|
||||
let openAiKeyCacheGeneration = 0
|
||||
|
||||
function getOrcaDir(): string {
|
||||
return join(homedir(), '.orca')
|
||||
}
|
||||
|
||||
function ensureOrcaDir(): void {
|
||||
const dir = getOrcaDir()
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
function getOpenAiKeyPath(): string {
|
||||
return join(getOrcaDir(), OPENAI_SPEECH_TOKEN_FILE)
|
||||
}
|
||||
|
||||
function readLegacyJsonStoredOpenAiKey(): StoredOpenAiKey | null {
|
||||
const keyPath = getOpenAiKeyPath()
|
||||
if (!existsSync(keyPath)) {
|
||||
return null
|
||||
}
|
||||
function parseLegacyJsonStoredOpenAiKey(contents: string): StoredOpenAiKey | null {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(keyPath, 'utf8')) as Partial<StoredOpenAiKey>
|
||||
const parsed = JSON.parse(contents) as Partial<StoredOpenAiKey>
|
||||
if (typeof parsed.encryptedKeyBase64 !== 'string' || parsed.encryptedKeyBase64 === '') {
|
||||
return null
|
||||
}
|
||||
@@ -41,59 +36,101 @@ function readLegacyJsonStoredOpenAiKey(): StoredOpenAiKey | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync twin kept for the model-state snapshot builder, which is synchronous. */
|
||||
export function hasOpenAiSpeechApiKey(): boolean {
|
||||
// Why: Settings and model-state refresh call this on startup; checking file
|
||||
// existence avoids decrypting safeStorage and triggering macOS keychain prompts.
|
||||
return existsSync(getOpenAiKeyPath())
|
||||
}
|
||||
|
||||
export function saveOpenAiSpeechApiKey(apiKey: string): void {
|
||||
export async function hasOpenAiSpeechApiKeyAsync(): Promise<boolean> {
|
||||
try {
|
||||
await access(getOpenAiKeyPath())
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function encodeOpenAiKeyForStorage(apiKey: string): { trimmed: string; contents: string | Buffer } {
|
||||
const trimmed = apiKey.trim()
|
||||
if (!trimmed) {
|
||||
throw new Error('OpenAI API key is required')
|
||||
}
|
||||
ensureOrcaDir()
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
writeFileSync(getOpenAiKeyPath(), safeStorage.encryptString(trimmed), { mode: 0o600 })
|
||||
cachedOpenAiSpeechApiKey = trimmed
|
||||
return
|
||||
return { trimmed, contents: safeStorage.encryptString(trimmed) }
|
||||
}
|
||||
|
||||
console.warn(
|
||||
'[speech] safeStorage encryption unavailable — storing OpenAI speech key in plaintext'
|
||||
)
|
||||
writeFileSync(getOpenAiKeyPath(), trimmed, { encoding: 'utf8', mode: 0o600 })
|
||||
cachedOpenAiSpeechApiKey = trimmed
|
||||
return { trimmed, contents: trimmed }
|
||||
}
|
||||
|
||||
export function saveOpenAiSpeechApiKeyAsync(apiKey: string): Promise<void> {
|
||||
const { trimmed, contents } = encodeOpenAiKeyForStorage(apiKey)
|
||||
const generation = ++openAiKeyCacheGeneration
|
||||
return chainOpenAiKeyWrite(async () => {
|
||||
// Why: recursive mkdir is idempotent, so the existsSync probe was a wasted syscall.
|
||||
await mkdir(getOrcaDir(), { recursive: true })
|
||||
// Why: mode on the CREATE, not a later chmod, so the key is never briefly world-readable.
|
||||
await writeFile(getOpenAiKeyPath(), contents, { mode: 0o600 })
|
||||
if (generation === openAiKeyCacheGeneration) {
|
||||
cachedOpenAiSpeechApiKey = trimmed
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Sync twin kept: SttService passes this as a lazy credential callback into the cloud session. */
|
||||
export function readOpenAiSpeechApiKey(): string {
|
||||
if (cachedOpenAiSpeechApiKey !== null) {
|
||||
return cachedOpenAiSpeechApiKey
|
||||
}
|
||||
|
||||
const keyPath = getOpenAiKeyPath()
|
||||
if (!existsSync(keyPath)) {
|
||||
throw new Error('OpenAI API key is not configured')
|
||||
}
|
||||
let raw: Buffer
|
||||
try {
|
||||
const raw = readFileSync(keyPath)
|
||||
const legacyJson = readLegacyJsonStoredOpenAiKey()
|
||||
if (legacyJson) {
|
||||
cachedOpenAiSpeechApiKey = safeStorage.decryptString(
|
||||
Buffer.from(legacyJson.encryptedKeyBase64, 'base64')
|
||||
)
|
||||
return cachedOpenAiSpeechApiKey
|
||||
// Why: one read replaces existsSync + two readFileSync passes (the legacy-JSON probe re-read the same file).
|
||||
raw = readFileSync(getOpenAiKeyPath())
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw new Error('OpenAI API key is not configured')
|
||||
}
|
||||
cachedOpenAiSpeechApiKey = safeStorage.isEncryptionAvailable()
|
||||
throw new Error('OpenAI API key could not be decrypted')
|
||||
}
|
||||
cachedOpenAiSpeechApiKey = decodeStoredOpenAiKey(raw)
|
||||
return cachedOpenAiSpeechApiKey
|
||||
}
|
||||
|
||||
function decodeStoredOpenAiKey(raw: Buffer): string {
|
||||
try {
|
||||
const legacyJson = parseLegacyJsonStoredOpenAiKey(raw.toString('utf8'))
|
||||
if (legacyJson) {
|
||||
return safeStorage.decryptString(Buffer.from(legacyJson.encryptedKeyBase64, 'base64'))
|
||||
}
|
||||
return safeStorage.isEncryptionAvailable()
|
||||
? safeStorage.decryptString(raw)
|
||||
: raw.toString('utf8')
|
||||
return cachedOpenAiSpeechApiKey
|
||||
} catch {
|
||||
throw new Error('OpenAI API key could not be decrypted')
|
||||
}
|
||||
}
|
||||
|
||||
export function clearOpenAiSpeechApiKey(): void {
|
||||
export function clearOpenAiSpeechApiKeyAsync(): Promise<void> {
|
||||
const generation = ++openAiKeyCacheGeneration
|
||||
cachedOpenAiSpeechApiKey = null
|
||||
rmSync(getOpenAiKeyPath(), { force: true })
|
||||
return chainOpenAiKeyWrite(async () => {
|
||||
await rm(getOpenAiKeyPath(), { force: true })
|
||||
// Why: a sync read can repopulate the cache from disk while we wait for our turn.
|
||||
if (generation === openAiKeyCacheGeneration) {
|
||||
cachedOpenAiSpeechApiKey = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function chainOpenAiKeyWrite(run: () => Promise<void>): Promise<void> {
|
||||
const next = pendingOpenAiKeyWrite.then(run, run)
|
||||
pendingOpenAiKeyWrite = next.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Why: freezes #42/#43/#33. writeSecureFile ran ~6 sync syscalls on the Electron
|
||||
// main thread; a stalled ~/.orca mount froze the app while saving a credential.
|
||||
const { syncFsCalls, asyncFsOps, hangWrites, failWrites } = vi.hoisted(() => ({
|
||||
syncFsCalls: [] as string[],
|
||||
asyncFsOps: [] as { op: string; path: string; mode?: number }[],
|
||||
hangWrites: { value: false },
|
||||
failWrites: { value: false }
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = (await vi.importActual('node:fs')) as Record<string, unknown>
|
||||
const wrapped: Record<string, unknown> = { ...actual }
|
||||
for (const [name, value] of Object.entries(actual)) {
|
||||
if (name.endsWith('Sync') && typeof value === 'function') {
|
||||
wrapped[name] = (...args: unknown[]) => {
|
||||
syncFsCalls.push(`${name}:${String(args[0])}`)
|
||||
return (value as (...a: unknown[]) => unknown)(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...wrapped, default: wrapped }
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = (await vi.importActual('node:fs/promises')) as typeof FsPromises
|
||||
const wrapped: Record<string, unknown> = { ...actual }
|
||||
for (const name of ['mkdir', 'writeFile', 'chmod', 'rename', 'rm', 'stat'] as const) {
|
||||
const original = actual[name] as (...a: unknown[]) => Promise<unknown>
|
||||
wrapped[name] = (...args: unknown[]) => {
|
||||
const options = args[2] ?? args[1]
|
||||
asyncFsOps.push({
|
||||
op: name,
|
||||
path: String(args[0]),
|
||||
mode:
|
||||
typeof options === 'object' && options !== null && 'mode' in options
|
||||
? (options as { mode?: number }).mode
|
||||
: undefined
|
||||
})
|
||||
if (hangWrites.value && name !== 'stat') {
|
||||
return new Promise<never>(() => {})
|
||||
}
|
||||
if (failWrites.value && name === 'writeFile') {
|
||||
return Promise.reject(Object.assign(new Error('EACCES'), { code: 'EACCES' }))
|
||||
}
|
||||
return original(...args)
|
||||
}
|
||||
}
|
||||
return { ...wrapped, default: wrapped }
|
||||
})
|
||||
|
||||
import {
|
||||
__resetSecureFileHardenedPathsForTests,
|
||||
removeSecureFileAsync,
|
||||
writeSecureFileAsync,
|
||||
writeSecureJsonFileAsync
|
||||
} from './secure-file'
|
||||
|
||||
const isWindows = process.platform === 'win32'
|
||||
|
||||
describe('writeSecureFileAsync', () => {
|
||||
let rootDir: string
|
||||
let targetPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), 'orca-secure-file-async-'))
|
||||
targetPath = join(rootDir, 'nested', 'credential.enc')
|
||||
syncFsCalls.length = 0
|
||||
asyncFsOps.length = 0
|
||||
hangWrites.value = false
|
||||
failWrites.value = false
|
||||
__resetSecureFileHardenedPathsForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
hangWrites.value = false
|
||||
failWrites.value = false
|
||||
rmSync(rootDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('writes a credential without a single synchronous fs syscall', async () => {
|
||||
await writeSecureFileAsync(targetPath, 'secret-token')
|
||||
const recorded = [...syncFsCalls]
|
||||
|
||||
expect(recorded).toEqual([])
|
||||
expect(readFileSync(targetPath, 'utf-8')).toBe('secret-token')
|
||||
})
|
||||
|
||||
it('creates the temp file already mode 0600 and only renames after restricting it', async () => {
|
||||
await writeSecureFileAsync(targetPath, 'secret-token')
|
||||
|
||||
const write = asyncFsOps.findIndex((entry) => entry.op === 'writeFile')
|
||||
const renameIndex = asyncFsOps.findIndex((entry) => entry.op === 'rename')
|
||||
const tmpPath = asyncFsOps[write]?.path ?? ''
|
||||
|
||||
// Why: mode on the CREATE — the credential must never exist world-readable, even briefly.
|
||||
expect(asyncFsOps[write]?.mode).toBe(0o600)
|
||||
expect(tmpPath.endsWith('.tmp')).toBe(true)
|
||||
expect(write).toBeLessThan(renameIndex)
|
||||
if (!isWindows) {
|
||||
const restrictTmp = asyncFsOps.findIndex(
|
||||
(entry) => entry.op === 'chmod' && entry.path === tmpPath
|
||||
)
|
||||
expect(write).toBeLessThan(restrictTmp)
|
||||
expect(restrictTmp).toBeLessThan(renameIndex)
|
||||
expect(statSync(targetPath).mode & 0o777).toBe(0o600)
|
||||
expect(statSync(join(rootDir, 'nested')).mode & 0o777).toBe(0o700)
|
||||
}
|
||||
})
|
||||
|
||||
it('serializes overlapping writes so renames never land out of order', async () => {
|
||||
const first = writeSecureFileAsync(targetPath, 'first')
|
||||
const second = writeSecureJsonFileAsync(targetPath, { value: 'second' })
|
||||
await Promise.all([first, second])
|
||||
const recorded = [...syncFsCalls]
|
||||
|
||||
const renames = asyncFsOps.filter((entry) => entry.op === 'rename')
|
||||
const writes = asyncFsOps.filter((entry) => entry.op === 'writeFile')
|
||||
expect(renames).toHaveLength(2)
|
||||
// The second write must not start before the first one has published.
|
||||
expect(asyncFsOps.indexOf(writes[1]!)).toBeGreaterThan(asyncFsOps.indexOf(renames[0]!))
|
||||
expect(JSON.parse(readFileSync(targetPath, 'utf-8'))).toEqual({ value: 'second' })
|
||||
expect(recorded).toEqual([])
|
||||
})
|
||||
|
||||
// A bare rm() would unlink before the queued write's rename (four awaits later) republished
|
||||
// the file, so clearing a credential during an in-flight save was a silent no-op.
|
||||
it('serializes a removal after a queued write so the clear cannot be overwritten', async () => {
|
||||
const write = writeSecureFileAsync(targetPath, 'secret-token')
|
||||
const remove = removeSecureFileAsync(targetPath)
|
||||
await Promise.all([write, remove])
|
||||
const recorded = [...syncFsCalls]
|
||||
|
||||
const renameIndex = asyncFsOps.findIndex((entry) => entry.op === 'rename')
|
||||
const unlinkIndex = asyncFsOps.findIndex(
|
||||
(entry) => entry.op === 'rm' && entry.path === targetPath
|
||||
)
|
||||
expect(renameIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(unlinkIndex).toBeGreaterThan(renameIndex)
|
||||
expect(existsSync(targetPath)).toBe(false)
|
||||
expect(recorded).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the event loop alive while the credential write hangs', async () => {
|
||||
hangWrites.value = true
|
||||
const pending = writeSecureFileAsync(targetPath, 'secret-token')
|
||||
void pending.catch(() => {})
|
||||
|
||||
let timerFired = false
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
timerFired = true
|
||||
resolve()
|
||||
}, 10)
|
||||
})
|
||||
|
||||
expect(timerFired).toBe(true)
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
|
||||
it('removes the temp file when the write fails and never publishes it', async () => {
|
||||
failWrites.value = true
|
||||
|
||||
await expect(writeSecureFileAsync(targetPath, 'secret-token')).rejects.toThrow(/EACCES/)
|
||||
|
||||
const tmpPath = asyncFsOps.find((entry) => entry.op === 'writeFile')?.path
|
||||
expect(asyncFsOps).toContainEqual(expect.objectContaining({ op: 'rm', path: tmpPath }))
|
||||
expect(asyncFsOps.some((entry) => entry.op === 'rename')).toBe(false)
|
||||
expect(syncFsCalls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
__resetSecureFileHardenedPathsForTests,
|
||||
__resetSecureFileWindowsUserSidForTests,
|
||||
hardenExistingSecureFile,
|
||||
hardenExistingSecureFileAsync,
|
||||
hardenSecurePath,
|
||||
writeSecureFile
|
||||
} from './secure-file'
|
||||
@@ -226,6 +227,22 @@ describe('hardenSecurePath', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// The win32 directory cache is path-keyed with no metadata check, so hardening a directory
|
||||
// that does not exist would spawn PowerShell and then cache that miss for the process life —
|
||||
// the real directory would never be hardened once it appeared.
|
||||
it('does not harden or cache a missing Windows directory on the read path', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-'))
|
||||
tempDirs.push(userDataPath)
|
||||
const missingDir = join(userDataPath, 'never-created')
|
||||
const targetPath = join(missingDir, 'secret.json')
|
||||
|
||||
await hardenExistingSecureFileAsync(targetPath)
|
||||
|
||||
expect(getPowerShellCalls()).toHaveLength(0)
|
||||
expect(__getSecureFileHardeningCacheStateForTests().directories).toMatchObject({ entries: 0 })
|
||||
})
|
||||
|
||||
it('keeps post-rename target hardening on every write while caching the directory', () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-secure-file-'))
|
||||
|
||||
+143
-49
@@ -8,28 +8,23 @@ import {
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { chmod, mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import {
|
||||
SecurePathHardeningCache,
|
||||
type SecurePathHardeningCacheBounds
|
||||
} from './secure-path-hardening-cache'
|
||||
import {
|
||||
type HardenedPathCacheEntry,
|
||||
hardenedPathCacheEntriesMatch,
|
||||
toHardenedPathCacheEntry
|
||||
} from './secure-path-hardening-snapshot'
|
||||
import {
|
||||
bestEffortRestrictWindowsPath,
|
||||
resetSecureFileWindowsUserSidForTests,
|
||||
restrictWindowsPathSync
|
||||
} from './secure-path-windows-acl'
|
||||
|
||||
type HardenedPathCacheEntry = {
|
||||
isDirectory: boolean
|
||||
dev: number
|
||||
ino: number
|
||||
size: number
|
||||
mode: number
|
||||
ctimeMs: number
|
||||
mtimeMs: number
|
||||
birthtimeMs: number
|
||||
}
|
||||
|
||||
export const SECURE_PATH_HARDENING_CACHE_MAX_ENTRIES = 1024
|
||||
export const SECURE_PATH_HARDENING_CACHE_KEY_MAX_BYTES = 64 * 1024
|
||||
export const SECURE_PATH_HARDENING_CACHE_KEYS_MAX_BYTES = 512 * 1024
|
||||
@@ -89,9 +84,8 @@ export function writeSecureJsonFile(targetPath: string, value: unknown): void {
|
||||
|
||||
export function writeSecureFile(targetPath: string, contents: string): void {
|
||||
const dir = dirname(targetPath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
}
|
||||
// Why: recursive mkdir is idempotent, so the existsSync probe was a second syscall for nothing (worse on a stalled mount).
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
// Windows dir hardening stays async + path-cached (it stormed the main thread, #4901); POSIX keeps the metadata cache to catch chmod/ctime drift.
|
||||
hardenSecurePathOnce(dir, true)
|
||||
|
||||
@@ -124,6 +118,140 @@ export function hardenExistingSecureFile(targetPath: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: async twin so main-thread IPC callers never park the process on a stalled mount; the sync twin stays for CLI/startup callers that cannot await.
|
||||
|
||||
/**
|
||||
* Serializes every async mutation of a target path. The sync twin got ordering for free from
|
||||
* the single thread; without this chain two overlapping writes could rename out of order, and
|
||||
* a removal could unlink before an in-flight write's rename republished the file.
|
||||
*/
|
||||
const pendingSecureMutationsByPath = new Map<string, Promise<void>>()
|
||||
|
||||
export function writeSecureJsonFileAsync(targetPath: string, value: unknown): Promise<void> {
|
||||
return writeSecureFileAsync(targetPath, JSON.stringify(value, null, 2))
|
||||
}
|
||||
|
||||
export function writeSecureFileAsync(targetPath: string, contents: string): Promise<void> {
|
||||
return chainSecureMutation(targetPath, () => performSecureWrite(targetPath, contents))
|
||||
}
|
||||
|
||||
/** Removes a secure file in call order with writeSecureFileAsync, so a clear can't lose a race with a queued save. */
|
||||
export function removeSecureFileAsync(targetPath: string): Promise<void> {
|
||||
return chainSecureMutation(targetPath, () => rm(targetPath, { force: true }))
|
||||
}
|
||||
|
||||
function chainSecureMutation(targetPath: string, run: () => Promise<void>): Promise<void> {
|
||||
const previous = pendingSecureMutationsByPath.get(targetPath)
|
||||
const result = previous ? previous.then(run, run) : run()
|
||||
const settled: Promise<void> = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
pendingSecureMutationsByPath.set(targetPath, settled)
|
||||
void settled.then(() => {
|
||||
if (pendingSecureMutationsByPath.get(targetPath) === settled) {
|
||||
pendingSecureMutationsByPath.delete(targetPath)
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
async function performSecureWrite(targetPath: string, contents: string): Promise<void> {
|
||||
const dir = dirname(targetPath)
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 })
|
||||
await hardenSecurePathOnceAsync(dir, true, false)
|
||||
|
||||
const tmpFile = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp`
|
||||
try {
|
||||
// Why: mode on the CREATE (not a later chmod) is what keeps the credential from ever existing world-readable.
|
||||
await writeFile(tmpFile, contents, { encoding: 'utf-8', mode: 0o600 })
|
||||
// Why: writeFile mode is a no-op on Windows, so restrict the ACL before the rename publishes it under inherited ACLs.
|
||||
await applySecurePathRestrictionAsync(tmpFile, false, process.platform, true)
|
||||
await rename(tmpFile, targetPath)
|
||||
// Why: these hold auth credentials, so the published path must stay current-user only; cache only on confirmed success so failures retry.
|
||||
if (await applySecurePathRestrictionAsync(targetPath, false, process.platform, true)) {
|
||||
await rememberHardenedPathAsync(targetPath, false)
|
||||
}
|
||||
} catch (error) {
|
||||
await rm(tmpFile, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function hardenExistingSecureFileAsync(targetPath: string): Promise<void> {
|
||||
// Why: one stat per path replaces the sync twin's access()+stat pair — half the syscalls on a mount that may never answer.
|
||||
await hardenSecurePathOnceAsync(dirname(targetPath), true, true)
|
||||
await hardenSecurePathOnceAsync(targetPath, false, true)
|
||||
}
|
||||
|
||||
async function hardenSecurePathOnceAsync(
|
||||
targetPath: string,
|
||||
isDirectory: boolean,
|
||||
skipWhenMissing: boolean
|
||||
): Promise<boolean> {
|
||||
if (isDirectory && process.platform === 'win32') {
|
||||
// Why: the win32 dir cache is path-keyed with no metadata check (#4901), so hardening a
|
||||
// path that isn't there would spawn PowerShell and then cache the miss forever. The sync
|
||||
// twin gates on existsSync; mirror that gate for the read-path callers that pass it.
|
||||
if (skipWhenMissing && !(await getHardenedPathCacheEntryAsync(targetPath, isDirectory))) {
|
||||
return false
|
||||
}
|
||||
hardenSecureDirectoryOnce(targetPath)
|
||||
return true
|
||||
}
|
||||
|
||||
const currentEntry = await getHardenedPathCacheEntryAsync(targetPath, isDirectory)
|
||||
if (!currentEntry) {
|
||||
hardenedPathsThisProcess.delete(targetPath)
|
||||
if (skipWhenMissing) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const cachedEntry = hardenedPathsThisProcess.get(targetPath)
|
||||
if (currentEntry && cachedEntry && hardenedPathCacheEntriesMatch(currentEntry, cachedEntry)) {
|
||||
return true
|
||||
}
|
||||
if (await applySecurePathRestrictionAsync(targetPath, isDirectory, process.platform, false)) {
|
||||
await rememberHardenedPathAsync(targetPath, isDirectory)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function applySecurePathRestrictionAsync(
|
||||
targetPath: string,
|
||||
isDirectory: boolean,
|
||||
platform: NodeJS.Platform,
|
||||
sync: boolean
|
||||
): Promise<boolean> {
|
||||
// Why: Windows hardening is a PowerShell spawn, not an fs syscall — out of the stalled-mount blast radius, so reuse it verbatim.
|
||||
if (platform === 'win32') {
|
||||
return applySecurePathRestriction(targetPath, isDirectory, platform, sync)
|
||||
}
|
||||
await chmod(targetPath, isDirectory ? 0o700 : 0o600)
|
||||
return true
|
||||
}
|
||||
|
||||
async function rememberHardenedPathAsync(targetPath: string, isDirectory: boolean): Promise<void> {
|
||||
const entry = await getHardenedPathCacheEntryAsync(targetPath, isDirectory)
|
||||
if (entry) {
|
||||
hardenedPathsThisProcess.set(targetPath, entry)
|
||||
} else {
|
||||
hardenedPathsThisProcess.delete(targetPath)
|
||||
}
|
||||
}
|
||||
|
||||
async function getHardenedPathCacheEntryAsync(
|
||||
targetPath: string,
|
||||
isDirectory: boolean
|
||||
): Promise<HardenedPathCacheEntry | null> {
|
||||
try {
|
||||
return toHardenedPathCacheEntry(await stat(targetPath), isDirectory)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies the platform-appropriate permission restriction to a path once, bypassing the cache. */
|
||||
export function hardenSecurePath(
|
||||
targetPath: string,
|
||||
@@ -171,51 +299,17 @@ function rememberHardenedPath(targetPath: string, isDirectory: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots a path's identity, mode, and timestamps so later drift is detectable.
|
||||
* Mode is tracked directly so a chmod is caught even where coarse ctime granularity hides it.
|
||||
*/
|
||||
function getHardenedPathCacheEntry(
|
||||
targetPath: string,
|
||||
isDirectory: boolean
|
||||
): HardenedPathCacheEntry | null {
|
||||
try {
|
||||
const stats = statSync(targetPath)
|
||||
if (stats.isDirectory() !== isDirectory) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
isDirectory,
|
||||
dev: stats.dev,
|
||||
ino: stats.ino,
|
||||
size: stats.size,
|
||||
mode: stats.mode & 0o777,
|
||||
ctimeMs: stats.ctimeMs,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
birthtimeMs: stats.birthtimeMs
|
||||
}
|
||||
return toHardenedPathCacheEntry(statSync(targetPath), isDirectory)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** True when two snapshots describe the same unchanged path (identity, mode, timestamps). */
|
||||
function hardenedPathCacheEntriesMatch(
|
||||
a: HardenedPathCacheEntry,
|
||||
b: HardenedPathCacheEntry
|
||||
): boolean {
|
||||
return (
|
||||
a.isDirectory === b.isDirectory &&
|
||||
a.dev === b.dev &&
|
||||
a.ino === b.ino &&
|
||||
a.size === b.size &&
|
||||
a.mode === b.mode &&
|
||||
a.ctimeMs === b.ctimeMs &&
|
||||
a.mtimeMs === b.mtimeMs &&
|
||||
a.birthtimeMs === b.birthtimeMs
|
||||
)
|
||||
}
|
||||
|
||||
export function __resetSecureFileWindowsUserSidForTests(): void {
|
||||
resetSecureFileWindowsUserSidForTests()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Stats } from 'node:fs'
|
||||
|
||||
export type HardenedPathCacheEntry = {
|
||||
isDirectory: boolean
|
||||
dev: number
|
||||
ino: number
|
||||
size: number
|
||||
mode: number
|
||||
ctimeMs: number
|
||||
mtimeMs: number
|
||||
birthtimeMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots a path's identity, mode, and timestamps so later drift is detectable.
|
||||
* Mode is tracked directly so a chmod is caught even where coarse ctime granularity hides it.
|
||||
*/
|
||||
export function toHardenedPathCacheEntry(
|
||||
stats: Stats,
|
||||
isDirectory: boolean
|
||||
): HardenedPathCacheEntry | null {
|
||||
if (stats.isDirectory() !== isDirectory) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
isDirectory,
|
||||
dev: stats.dev,
|
||||
ino: stats.ino,
|
||||
size: stats.size,
|
||||
mode: stats.mode & 0o777,
|
||||
ctimeMs: stats.ctimeMs,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
birthtimeMs: stats.birthtimeMs
|
||||
}
|
||||
}
|
||||
|
||||
/** True when two snapshots describe the same unchanged path (identity, mode, timestamps). */
|
||||
export function hardenedPathCacheEntriesMatch(
|
||||
a: HardenedPathCacheEntry,
|
||||
b: HardenedPathCacheEntry
|
||||
): boolean {
|
||||
return (
|
||||
a.isDirectory === b.isDirectory &&
|
||||
a.dev === b.dev &&
|
||||
a.ino === b.ino &&
|
||||
a.size === b.size &&
|
||||
a.mode === b.mode &&
|
||||
a.ctimeMs === b.ctimeMs &&
|
||||
a.mtimeMs === b.mtimeMs &&
|
||||
a.birthtimeMs === b.birthtimeMs
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user