Merge remote-tracking branch 'origin/main' into brennanb2025/publish-submission-positions

This commit is contained in:
Brennan Benson
2026-10-05 07:15:08 -07:00
1114 changed files with 50099 additions and 6269 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -36,7 +36,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.',
'When reusing --terminal, pass --worktree for that terminal; current means the coordinator worktree.',
'--agent takes an Orca agent id enabled on the worker server, such as claude, codex, cursor, antigravity, muse, zcode, opencode, or opencode2.',
'--model supports Claude, Codex, Cursor, Antigravity, and Muse opaque provider model ids; --effort requires --model. OMP accepts --model only; --effort is unsupported. Neither can combine with --terminal. Other agents, including opencode and zcode, launch with the model from their own config.',
'--model supports Claude, Codex, Cursor, Antigravity, and Muse opaque provider model ids; --effort requires --model. OMP accepts --model only; --effort is unsupported. Neither can combine with --terminal. OpenCode model selection requires an existing worktree, a verified execution-host CLI, and an available model; effort is unsupported. Other agents, including zcode, launch with the model from their own config.',
'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.',
'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.',
"How the worker runs follows the user's own setting for new agent tabs; there is no flag for it and no caller needs to ask. A dispatch the setting cannot apply to still starts, so the placement, agent, and launch options passed here are always the ones honoured.",
@@ -2,9 +2,8 @@
# attributes a fresh agent the host builds; carries agentStartedTelemetry
# resume continues an existing agent session; not a new start
# mobile-followup the phone's session-tab launch; attribution is a separate follow-up
# The resolve-worktree-removal-target call also serves a bare typed command, which stays unattributed.
src/main/runtime/orca-runtime-create-agent-session.ts 2 attributes
src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts 1 attributes
# The shared execution-host builder also serves bare commands; only fresh-agent callers supply attribution.
src/main/opencode/opencode-model-startup-plan.ts 2 attributes
src/main/runtime/runtime-worktree-agent-startup.ts 3 attributes
src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts 1 resume
src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts 1 mobile-followup
@@ -25,6 +25,7 @@
* is injected as a factory instead of branched on here.
*/
import { assertOpenCodeModelLaunchPreferencesAbsent } from '../opencode/opencode-model-startup-plan'
import { parsePaneKey } from '../../shared/stable-pane-id'
import type {
AgentLaunchIntent,
@@ -76,6 +77,9 @@ export async function executeAgentLaunch(
execution: AgentLaunchExecution
): Promise<AgentLaunchResult> {
const { intent, runtime } = execution
if (intent.reuseTerminal || intent.target.kind === 'create-worktree') {
assertOpenCodeModelLaunchPreferencesAbsent(intent.agent, intent.sessionOptions)
}
const vocabulary = execution.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY
const settings = readAgentLaunchModeSettings(runtime)
const preflight = decideAgentLaunchMode({
@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentLaunchIntent } from '../../shared/agent-launch-intent'
import { executeAgentLaunch } from './agent-launch-executor'
function harness() {
const readSettings = vi.fn(() => {
throw new Error('settings_not_needed_for_refusal')
})
const createSupport = vi.fn(async () => {
throw new Error('support_not_needed_for_refusal')
})
const createTerminal = vi.fn(async () => ({ handle: 'term_new' }))
const deliverPrompt = vi.fn(async () => true)
const createWorktree = vi.fn(async () => ({
worktreeId: 'wt_new',
startupTerminalHandle: 'term_new'
}))
return {
readSettings,
createTerminal,
deliverPrompt,
createWorktree,
run: (intent: AgentLaunchIntent) =>
executeAgentLaunch({
intent,
runtime: {
getClientSettings: readSettings,
getStructuredAgentSessionCreateSupport: createSupport
},
surfaces: {
createTerminalAgent: createTerminal,
createStructuredSession: async () => {
throw new Error('structured_not_expected')
},
deliverTerminalPrompt: deliverPrompt
},
workspaces: { createWorktree }
})
}
}
const reused: AgentLaunchIntent = {
agent: 'opencode',
target: { kind: 'existing', worktree: 'folder:private' },
reuseTerminal: { handle: 'term_existing' },
prompt: { text: 'Read only', delivery: 'submit' },
sessionOptions: { model: 'private-proof/model-b' }
}
const creating: AgentLaunchIntent = {
...reused,
reuseTerminal: undefined,
target: { kind: 'create-worktree', create: { repo: 'id:private', name: 'task' } }
}
describe('OpenCode model preferences on unsupported launch placements', () => {
it.each([reused, creating])(
'refuses before reading settings or creating or delivering',
async (intent) => {
const h = harness()
await expect(h.run(intent)).rejects.toMatchObject({ code: 'capability_unsupported' })
expect(h.readSettings).not.toHaveBeenCalled()
expect(h.createTerminal).not.toHaveBeenCalled()
expect(h.createWorktree).not.toHaveBeenCalled()
expect(h.deliverPrompt).not.toHaveBeenCalled()
}
)
it('preserves prompt delivery to a reused terminal without model preferences', async () => {
const h = harness()
expect((await h.run({ ...reused, sessionOptions: undefined })).outcome).toEqual({
kind: 'terminal',
handle: 'term_existing'
})
expect(h.deliverPrompt).toHaveBeenCalledOnce()
expect(h.createWorktree).not.toHaveBeenCalled()
})
it('preserves ordinary worktree creation without model preferences', async () => {
const h = harness()
expect((await h.run({ ...creating, sessionOptions: undefined })).worktreeId).toBe('wt_new')
expect(h.createWorktree).toHaveBeenCalledOnce()
})
})
@@ -1,3 +1,4 @@
import '../runtime/rpc/unused-default-rpc-methods.test-fixture'
import { afterEach, describe, expect, it } from 'vitest'
import { AiVaultHandler } from '../../relay/ai-vault-handler'
import type { RelayDispatcher } from '../../relay/dispatcher'
+2 -1
View File
@@ -1,4 +1,5 @@
import { open } from 'node:fs/promises'
import { LOCAL_READ_OPEN_FLAGS } from '../ipc/filesystem/local-regular-file-read'
import {
LOCAL_LOG_TAIL_CHUNK_BYTES,
type LocalLogTailReadResult
@@ -23,7 +24,7 @@ export async function readLocalLogTailRange(
throw new Error('Invalid local log tail byte offset')
}
const handle = await open(filePath, 'r')
const handle = await open(filePath, LOCAL_READ_OPEN_FLAGS)
try {
const initialStats = await handle.stat()
if (!initialStats.isFile()) {
@@ -110,13 +110,29 @@ describe('streamed session document contracts', () => {
}
)
it.each(['null', '[]', '123', '"text"'])(
it.each(['null', '[]', '[{}]', '123', '"text"', '"{}"'])(
'does not publish a nonobject document %s',
async (content) => {
expect(await read(content)).toBeNull()
}
)
it('recognizes an object root after whitespace and empty byte chunks', async () => {
async function* padded() {
yield Buffer.alloc(0)
yield Buffer.from(' \t')
yield Buffer.from('\r\n')
yield Buffer.alloc(0)
yield* bytes('{"id":"日本語😀","messages":[1]}', 1)
yield Buffer.alloc(0)
yield Buffer.from(' \n')
}
expect(await read('', { bytes: padded() })).toEqual({
record: { id: '日本語😀' },
state: [1]
})
})
it('validates discarded subtrees and closes their source on malformed input', async () => {
let closed = false
async function* malformed() {
+1 -8
View File
@@ -115,15 +115,8 @@ export async function readStreamedSessionDocument<T>(args: {
assembler.consume(token)
})
const decoder = new StringDecoder('utf8')
let objectRoot: boolean | undefined
for await (const chunk of args.bytes) {
throwIfAiVaultScanCancelled(args.signal)
if (objectRoot === undefined) {
const first = chunk.find((byte) => byte !== 32 && byte !== 9 && byte !== 10 && byte !== 13)
if (first !== undefined) {
objectRoot = first === 123
}
}
parser.write(decoder.write(chunk))
await yieldToEventLoop()
}
@@ -136,5 +129,5 @@ export async function readStreamedSessionDocument<T>(args: {
if (consumeFailure) {
throw consumeFailure.error
}
return objectRoot ? { record, state } : null
return assembler.current === record ? { record, state } : null
}
@@ -1,4 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as workerSpawn from './session-scanner-opencode-sqlite-worker-spawn'
import type { SessionFileDiscovery } from './session-scanner-types'
import type { TranscriptReadOutcome } from './session-transcript-consumers'
@@ -24,6 +27,7 @@ import {
registerTranscriptConsumer,
resetTranscriptConsumersForTests
} from './session-transcript-consumers'
import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope'
const file = {
path: '/fixture/opencode.db#session',
@@ -40,6 +44,7 @@ beforeEach(() => {
resetSessionParseCacheForTests()
})
afterEach(() => {
vi.useRealTimers()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
})
@@ -48,7 +53,11 @@ function configure(agent: 'opencode' | 'opencode2') {
readers.discover.mockResolvedValue([{ agent, rootDir: '/fixture', files: [file] }])
const accumulator = createAccumulator({ agent, file, sessionId: 'session' })
accumulator.title = 'SQLite session'
return finalizeSession(accumulator, 'linux')
const session = finalizeSession(accumulator, 'linux')
if (!session) {
throw new Error('Configured SQLite session was empty')
}
return session
}
function untilAborted(signal: AbortSignal | undefined): Promise<never> {
@@ -61,6 +70,83 @@ function untilAborted(signal: AbortSignal | undefined): Promise<never> {
}
describe.each(['opencode', 'opencode2'] as const)('%s scan cancellation', (agent) => {
it('reports a deadline while retaining completed sessions and other agents, then retries', async () => {
vi.useFakeTimers()
const root = mkdtempSync(join(tmpdir(), 'orca-scan-deadline-'))
try {
const session = configure(agent)
const blocked = { ...file, path: '/fixture/opencode.db#blocked' }
const claudePath = join(root, 'claude.jsonl')
writeFileSync(
claudePath,
`${JSON.stringify({
type: 'user',
sessionId: 'retained-claude',
timestamp: '2026-05-01T10:00:00.000Z',
cwd: root,
message: { role: 'user', content: 'Retain this other-agent session' }
})}\n`
)
readers.discover.mockResolvedValue([
{ agent, rootDir: '/fixture', files: [file, blocked] },
{ agent: 'claude', rootDir: root, files: [{ ...file, path: claudePath }] }
])
readers.parse.mockImplementation(({ sessionId, signal }) =>
sessionId === 'blocked'
? runOpenCodeSqliteScanRequest(signal, untilAborted)
: Promise.resolve(session)
)
const pending = scanAiVaultSessions({ platform: 'linux' })
await vi.waitFor(() => expect(readers.parse).toHaveBeenCalledTimes(2))
await vi.advanceTimersByTimeAsync(45_000)
const result = await pending
expect(result.sessions.map((row) => row.sessionId)).toEqual(
expect.arrayContaining(['session', 'retained-claude'])
)
expect(result.sessions).toHaveLength(2)
expect(result.issues).toEqual([
expect.objectContaining({
agent,
path: blocked.path,
message: expect.stringContaining('45s work budget')
})
])
readers.parse.mockResolvedValue({ ...session, sessionId: 'blocked', filePath: blocked.path })
const recovered = await scanAiVaultSessions({ platform: 'linux' })
expect(recovered.sessions).toHaveLength(3)
expect(
readers.parse.mock.calls.filter(([args]) => args.sessionId === 'blocked')
).toHaveLength(2)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('marks a deadline capture incomplete instead of caching a failed history', async () => {
vi.useFakeTimers()
const session = configure(agent)
const outcomes: TranscriptReadOutcome[] = []
registerTranscriptConsumer({
beginRead: () => ({ message() {}, finish: (outcome) => outcomes.push(outcome) })
})
readers.capture.mockImplementationOnce(({ signal }) =>
runOpenCodeSqliteScanRequest(signal, untilAborted)
)
const pending = scanAiVaultSessions({ platform: 'linux' })
await vi.waitFor(() => expect(readers.capture).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(45_000)
const result = await pending
expect(result.sessions).toEqual([])
expect(result.issues).toEqual([
expect.objectContaining({ message: expect.stringContaining('45s work budget') })
])
expect(outcomes).toEqual([{ session: null, byteOffset: 0, incomplete: true }])
readers.capture.mockResolvedValue({ session, messages })
expect((await scanAiVaultSessions({ platform: 'linux' })).sessions).toHaveLength(1)
expect(readers.capture).toHaveBeenCalledTimes(2)
expect(outcomes.at(-1)?.incomplete).toBe(false)
})
it.each(['parse', 'capture'] as const)(
'cancels an active %s and retries the uncached read',
async (mode) => {
@@ -0,0 +1,138 @@
import { afterEach, expect, it, vi } from 'vitest'
import {
OPENCODE_SQLITE_SCAN_BUDGET_MS,
runOpenCodeSqliteScanRequest,
withOpenCodeSqliteScanScope
} from './session-scanner-opencode-sqlite-scan-scope'
afterEach(() => vi.useRealTimers())
function waitForAbort(signal: AbortSignal | undefined): Promise<never> {
if (!signal) {
throw new Error('Missing scoped request signal')
}
return new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
})
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
it('spends the budget while admission is pending and refuses later work in that scan', async () => {
vi.useFakeTimers()
const admitted = vi.fn()
const outcome = withOpenCodeSqliteScanScope(async () => {
const error = await runOpenCodeSqliteScanRequest(undefined, waitForAbort).catch((err) => err)
expect(error).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
await expect(runOpenCodeSqliteScanRequest(undefined, admitted)).rejects.toBe(error)
})
await vi.advanceTimersByTimeAsync(OPENCODE_SQLITE_SCAN_BUDGET_MS)
await outcome
expect(admitted).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it('banks only outstanding work across legs and does not spend other-agent time', async () => {
vi.useFakeTimers()
const outcome = withOpenCodeSqliteScanScope(async () => {
await runOpenCodeSqliteScanRequest(undefined, () => wait(20_000))
await wait(70_000)
return runOpenCodeSqliteScanRequest(undefined, waitForAbort)
}).catch((error) => error)
await vi.advanceTimersByTimeAsync(90_000)
let completed = false
void outcome.then(() => {
completed = true
})
await vi.advanceTimersByTimeAsync(24_999)
expect(completed).toBe(false)
await vi.advanceTimersByTimeAsync(1)
expect(await outcome).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
expect(vi.getTimerCount()).toBe(0)
})
it('counts overlapping preparation and worker waits once', async () => {
vi.useFakeTimers()
const outcome = withOpenCodeSqliteScanScope(() =>
runOpenCodeSqliteScanRequest(undefined, () =>
Promise.all([
runOpenCodeSqliteScanRequest(undefined, waitForAbort),
runOpenCodeSqliteScanRequest(undefined, waitForAbort)
])
)
).catch((error) => error)
await vi.advanceTimersByTimeAsync(OPENCODE_SQLITE_SCAN_BUDGET_MS - 1)
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(1)
expect(await outcome).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
expect(vi.getTimerCount()).toBe(0)
})
it('keeps concurrent scans independent and gives the next scan a fresh owner and budget', async () => {
vi.useFakeTimers()
const owners: unknown[] = []
const first = withOpenCodeSqliteScanScope(() =>
runOpenCodeSqliteScanRequest(undefined, (signal, owner) => {
owners.push(owner)
return waitForAbort(signal)
})
).catch((error) => error)
await vi.advanceTimersByTimeAsync(30_000)
const second = withOpenCodeSqliteScanScope(() =>
runOpenCodeSqliteScanRequest(undefined, (signal, owner) => {
owners.push(owner)
return waitForAbort(signal)
})
).catch((error) => error)
await vi.advanceTimersByTimeAsync(15_000)
expect(await first).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(30_000)
expect(await second).toMatchObject({ name: 'OpenCodeSqliteScanDeadlineError' })
await withOpenCodeSqliteScanScope(() =>
runOpenCodeSqliteScanRequest(undefined, async (_signal, owner) => {
owners.push(owner)
})
)
expect(new Set(owners).size).toBe(3)
expect(vi.getTimerCount()).toBe(0)
})
it('preserves the caller cancellation reason and disposes its timer', async () => {
vi.useFakeTimers()
const controller = new AbortController()
const reason = new Error('caller cancelled')
const outcome = withOpenCodeSqliteScanScope(() =>
runOpenCodeSqliteScanRequest(controller.signal, waitForAbort)
).catch((error) => error)
controller.abort(reason)
expect(await outcome).toBe(reason)
expect(vi.getTimerCount()).toBe(0)
})
it('leaves unrelated native-chat and Zcode calls unscoped and retires the scan signal', async () => {
vi.useFakeTimers()
let scopedSignal: AbortSignal | undefined
const caller = new AbortController()
await withOpenCodeSqliteScanScope(async () => {
for (const agent of ['zcode', 'native-chat'] as const) {
await runOpenCodeSqliteScanRequest(
caller.signal,
async (signal, owner) => {
expect(signal).toBe(caller.signal)
expect(owner).toBeUndefined()
expect(vi.getTimerCount()).toBe(0)
},
agent
)
}
await runOpenCodeSqliteScanRequest(undefined, async (signal) => {
scopedSignal = signal
})
})
expect(scopedSignal?.aborted).toBe(true)
expect(caller.signal.aborted).toBe(false)
expect(vi.getTimerCount()).toBe(0)
})
@@ -0,0 +1,75 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { throwIfSignalAborted } from '../../shared/abort-signal-reason'
import type { WorkerThreadRequestOwner } from '../worker-thread-request-queue'
export const OPENCODE_SQLITE_SCAN_BUDGET_MS = 45_000
class OpenCodeSqliteScanScope implements WorkerThreadRequestOwner {
private readonly controller = new AbortController()
readonly signal = this.controller.signal
private remainingMs = OPENCODE_SQLITE_SCAN_BUDGET_MS
private outstanding = 0
private armedAt = 0
private timer: NodeJS.Timeout | undefined
async run<T>(
callerSignal: AbortSignal | undefined,
fn: (signal: AbortSignal, owner: WorkerThreadRequestOwner) => Promise<T>
): Promise<T> {
throwIfSignalAborted(callerSignal)
throwIfSignalAborted(this.signal)
if (this.outstanding++ === 0) {
this.armedAt = Date.now()
this.timer = setTimeout(() => {
const error = new Error(
`OpenCode SQLite scan exceeded its ${OPENCODE_SQLITE_SCAN_BUDGET_MS / 1000}s work budget`
)
error.name = 'OpenCodeSqliteScanDeadlineError'
this.controller.abort(error)
}, this.remainingMs)
this.timer.unref?.()
}
const signal = callerSignal ? AbortSignal.any([callerSignal, this.signal]) : this.signal
try {
return await fn(signal, this)
} finally {
if (--this.outstanding === 0) {
this.pause()
}
}
}
dispose(): void {
this.pause()
this.controller.abort(new Error('OpenCode SQLite scan ended'))
}
private pause(): void {
if (this.timer) {
clearTimeout(this.timer)
this.timer = undefined
this.remainingMs = Math.max(0, this.remainingMs - (Date.now() - this.armedAt))
}
}
}
const scanScope = new AsyncLocalStorage<OpenCodeSqliteScanScope>()
export async function withOpenCodeSqliteScanScope<T>(fn: () => Promise<T>): Promise<T> {
const scope = new OpenCodeSqliteScanScope()
try {
return await scanScope.run(scope, fn)
} finally {
scope.dispose()
}
}
// The clock covers outstanding SQLite work, including admission and WSL preparation.
export function runOpenCodeSqliteScanRequest<T>(
signal: AbortSignal | undefined,
fn: (signal?: AbortSignal, owner?: WorkerThreadRequestOwner) => Promise<T>,
agent?: 'opencode2' | 'zcode' | 'native-chat'
): Promise<T> {
const scope = agent === 'zcode' || agent === 'native-chat' ? undefined : scanScope.getStore()
return scope ? scope.run(signal, fn) : fn(signal)
}
@@ -12,6 +12,7 @@ import type {
OpenCodeSqliteWorkerResponse
} from './session-scanner-opencode-sqlite-worker-protocol'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope'
// A worker_threads stand-in the tests drive directly: it records posted requests
// and lets a test emit message/error/exit without a built worker bundle.
@@ -75,6 +76,63 @@ function makeFactory(workers: FakeWorker[]): () => Worker {
}
describe('OpenCodeSqliteWorkerClient', () => {
it('expires a scan in the FIFO without cancelling ordinary reads or a later scan', async () => {
vi.useFakeTimers()
const workers: FakeWorker[] = []
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
const issues: AiVaultScanIssue[] = []
try {
const ordinary = [1, 2].map((id) =>
client.list({
dbPaths: [`/ordinary-${id}.db`],
limit: 1,
issues: []
})
)
const scan = withOpenCodeSqliteScanScope(() =>
client.list({
dbPaths: ['/scan.db'],
limit: 1,
issues
})
)
await vi.advanceTimersByTimeAsync(25_000)
workers[0].emit('message', {
id: workers[0].lastId(),
ok: true,
value: { candidates: [], issues: [] }
})
await vi.advanceTimersByTimeAsync(20_000)
await expect(scan).resolves.toEqual([])
expect(issues).toEqual([
expect.objectContaining({
kind: 'scope',
message: expect.stringContaining('45s work budget')
})
])
expect(workers[0].postedRequests).toHaveLength(2)
expect(workers[0].terminated).toBe(false)
workers[0].emit('message', {
id: workers[0].lastId(),
ok: true,
value: { candidates: [], issues: [] }
})
await expect(Promise.all(ordinary)).resolves.toEqual([[], []])
const next = withOpenCodeSqliteScanScope(() =>
client.list({ dbPaths: ['/next.db'], limit: 1, issues: [] })
)
workers[0].emit('message', {
id: workers[0].lastId(),
ok: true,
value: { candidates: [], issues: [] }
})
await expect(next).resolves.toEqual([])
} finally {
client.dispose()
vi.useRealTimers()
}
})
it('correlates responses by id and ignores stale ids', async () => {
const workers: FakeWorker[] = []
const client = new OpenCodeSqliteWorkerClient({ workerFactory: makeFactory(workers), log() {} })
@@ -188,7 +246,7 @@ describe('OpenCodeSqliteWorkerClient', () => {
client.list({ dbPaths: ['/tmp/opencode.db'], limit: 10, issues: listIssues })
).resolves.toEqual([])
expect(
listIssues.some((issue) => /background scanner could not start/.test(issue.message))
listIssues.some((issue) => issue.message.includes('background scanner could not start'))
).toBe(true)
await expect(
client.parse({ dbPath: '/tmp/opencode.db', sessionId: 'ses_skipped', platform: 'darwin' })
@@ -264,7 +322,7 @@ describe('OpenCodeSqliteWorkerClient', () => {
const first = await client.list({ dbPaths: ['/db'], limit: 10, issues: firstIssues })
expect(first).toEqual([])
expect(
firstIssues.some((issue) => /background scanner could not start/.test(issue.message))
firstIssues.some((issue) => issue.message.includes('background scanner could not start'))
).toBe(true)
await expect(
client.parse({ dbPath: '/db', sessionId: 'ses_heal', platform: 'darwin' })
@@ -12,6 +12,7 @@ import type {
import { parseOpenCodeSqliteCaptureValue } from './session-scanner-opencode-sqlite-worker-response'
import type { SessionFileCandidate } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope'
// Why (#8864): a lazily-spawned, unref'd worker runs OpenCode SQLite reads off
// the main-process event loop. This module owns only the OpenCode legs; the
@@ -117,7 +118,8 @@ export class OpenCodeSqliteWorkerClient {
...(args.agent ? { agent: args.agent } : {})
}),
LIST_TIMEOUT_MS,
args.signal
args.signal,
args.agent
)) as OpenCodeSqliteListValue
args.issues.push(...value.issues)
return value.candidates
@@ -178,7 +180,8 @@ export class OpenCodeSqliteWorkerClient {
...(args.agent ? { agent: args.agent } : {})
}),
PARSE_TIMEOUT_MS,
args.signal
args.signal,
args.agent
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the worker's parse leg returns exactly this, built by the repo's own reader on the other side of a structured clone.
return value as AiVaultSession | null
@@ -217,7 +220,8 @@ export class OpenCodeSqliteWorkerClient {
...(args.agent ? { agent: args.agent } : {})
}),
CAPTURE_TIMEOUT_MS,
args.signal
args.signal,
args.agent
)
return parseOpenCodeSqliteCaptureValue(value)
} catch (err) {
@@ -229,7 +233,12 @@ export class OpenCodeSqliteWorkerClient {
args: Omit<OpenCodeNativeChatReadRequest, 'id'>,
signal?: AbortSignal
): Promise<OpenCodeNativeChatReadValue> {
const value = await this.dispatch((id) => ({ ...args, id }), PARSE_TIMEOUT_MS, signal)
const value = await this.dispatch(
(id) => ({ ...args, id }),
PARSE_TIMEOUT_MS,
signal,
'native-chat'
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only this build's internal worker dispatch constructs page/signal results; they are not client-supplied paths or frames.
return value as OpenCodeNativeChatReadValue
}
@@ -241,13 +250,20 @@ export class OpenCodeSqliteWorkerClient {
private async dispatch(
buildRequest: (id: number) => OpenCodeSqliteWorkerRequest,
timeoutMs: number,
signal?: AbortSignal
signal?: AbortSignal,
agent?: 'opencode2' | 'zcode' | 'native-chat'
): Promise<unknown> {
const deadline = this.requestTimeoutMs ?? timeoutMs
const response = await this.requests.dispatch(
(id) => ({ ...buildRequest(id), timeoutMs: deadline }),
deadline,
signal
const response = await runOpenCodeSqliteScanRequest(
signal,
(requestSignal, owner) =>
this.requests.dispatch(
(id) => ({ ...buildRequest(id), timeoutMs: deadline }),
deadline,
requestSignal,
owner
),
agent
)
if (!response.ok) {
throw new Error(response.error)
@@ -16,6 +16,7 @@ import {
openCodeWslPath
} from './session-scanner-opencode-wsl-client'
import { findForeignSqliteReaderEntry } from '../foreign-sqlite-readers/foreign-sqlite-reader-entry-path'
import { runOpenCodeSqliteScanRequest } from './session-scanner-opencode-sqlite-scan-scope'
// Why: resolve the built worker entry + own the process-wide shared client so
// the client class stays free of Electron (require'd lazily here) and the
@@ -179,8 +180,14 @@ async function listForHost(
const first = paths.values().next().value!
const issues: AiVaultScanIssue[] = []
try {
const client = await openCodeWslClient(distro, first, args.signal)
const result = await client.list({ ...args, dbPaths: [...paths.keys()], issues })
const result = await runOpenCodeSqliteScanRequest(
args.signal,
async (signal) => {
const client = await openCodeWslClient(distro, first, signal)
return client.list({ ...args, signal, dbPaths: [...paths.keys()], issues })
},
args.agent
)
return result.flatMap((candidate) => {
const parsed = splitOpenCodeSqliteCandidate(candidate.file.path, args.agent)
const original = parsed && paths.get(parsed.dbPath)
@@ -223,8 +230,14 @@ async function parseForHost(
if (!wsl) {
return getSharedClient().parse(args)
}
const client = await openCodeWslClient(wsl.distro, args.dbPath, args.signal)
const session = await client.parse({ ...args, dbPath: wsl.linuxPath, platform: 'linux' })
const session = await runOpenCodeSqliteScanRequest(
args.signal,
async (signal) => {
const client = await openCodeWslClient(wsl.distro, args.dbPath, signal)
return client.parse({ ...args, signal, dbPath: wsl.linuxPath, platform: 'linux' })
},
args.agent
)
return mapOpenCodeWslSession(session, args.dbPath)
}
@@ -235,8 +248,14 @@ async function captureForHost(
if (!wsl) {
return getSharedClient().capture(args)
}
const client = await openCodeWslClient(wsl.distro, args.dbPath, args.signal)
const capture = await client.capture({ ...args, dbPath: wsl.linuxPath, platform: 'linux' })
const capture = await runOpenCodeSqliteScanRequest(
args.signal,
async (signal) => {
const client = await openCodeWslClient(wsl.distro, args.dbPath, signal)
return client.capture({ ...args, signal, dbPath: wsl.linuxPath, platform: 'linux' })
},
args.agent
)
return { ...capture, session: mapOpenCodeWslSession(capture.session, args.dbPath) }
}
@@ -3,6 +3,7 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { createAccumulator, finalizeSession } from './session-scanner-accumulator'
import type { SessionFileCandidate } from './session-scanner-types'
import type * as wslClientModule from './session-scanner-opencode-wsl-client'
import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope'
const mocks = vi.hoisted(() => ({
native: {
@@ -59,9 +60,53 @@ beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
})
afterEach(() => vi.restoreAllMocks())
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('OpenCode SQLite execution-host routes', () => {
it('budgets WSL preparation while retaining a native source that already answered', async () => {
vi.useFakeTimers()
mocks.native.list.mockResolvedValueOnce([row(native)])
mocks.guest.mockImplementationOnce((_distro, _path, signal: AbortSignal | undefined) => {
if (!signal) {
throw new Error('Missing scoped preparation signal')
}
return new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
)
})
const issues: AiVaultScanIssue[] = []
const result = withOpenCodeSqliteScanScope(() =>
listOpenCodeSqliteSessionsViaWorker({
dbPaths: [native, ubuntu],
limit: 2,
issues
})
)
await vi.advanceTimersByTimeAsync(45_000)
expect((await result).map((entry) => entry.file.path)).toEqual([`${native}#same-session`])
expect(issues).toEqual([
expect.objectContaining({
path: ubuntu,
kind: 'scope',
message: expect.stringContaining('45s work budget')
})
])
mocks.guest.mockResolvedValueOnce({ list: vi.fn(async () => [row(guest)]) })
const recoveredIssues: AiVaultScanIssue[] = []
const recovered = await withOpenCodeSqliteScanScope(() =>
listOpenCodeSqliteSessionsViaWorker({
dbPaths: [ubuntu],
limit: 2,
issues: recoveredIssues
})
)
expect(recovered).toHaveLength(1)
expect(recoveredIssues).toEqual([])
})
it('separates native and distro databases and preserves equal IDs in different distros', async () => {
const list = vi.fn(async (args) => [row(args.dbPaths[0])])
mocks.guest.mockResolvedValue({ list })
+5
View File
@@ -45,6 +45,7 @@ import { clampPositiveInteger, errorMessage } from './session-scanner-values'
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { DEFAULT_AI_VAULT_SCAN_LIMIT } from '../../shared/ai-vault-session-depth'
import { withDevinSessionsDbScan } from './session-scanner-devin-db'
import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope'
const SESSION_PARSE_CONCURRENCY = 8
const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2
@@ -61,6 +62,10 @@ const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2
export async function scanAiVaultSessions(
options: AiVaultScanOptions = {}
): Promise<AiVaultListResult> {
return withOpenCodeSqliteScanScope(() => scanAiVaultSessionStores(options))
}
async function scanAiVaultSessionStores(options: AiVaultScanOptions): Promise<AiVaultListResult> {
// The span makes scan cost visible in the local trace file: STA-1278-style
// "one core pegged" reports need to show whether transcript scanning is the
// subsystem burning CPU, and how much of each scan the cache absorbed.
@@ -28,7 +28,9 @@ import { THREAD_ID } from './codex-structured-session-adapter-fixture'
/** The delegation the parent's newest tool run reads as, the row a running chat's frontier judges. */
async function newestRunDelegation(frames: Frame[]): Promise<NativeChatSubagentDelegation | null> {
const { conversation } = projectNativeChatTranscript(
projectStructuredAgentSessionMessages(await publishedRows(frames), [], [])
projectStructuredAgentSessionMessages(await publishedRows(frames), [], [], {
rejectedInPlace: true
})
)
const runs = conversation.filter((message: NativeChatMessage) =>
message.blocks.some((block) => block.type === 'tool-call')
@@ -47,7 +47,9 @@ function positionalRuns(rows: AgentJournalRenderItem[]): {
})
})
const transcript = projectNativeChatTranscript(
projectStructuredAgentSessionMessages(rows, [], []).map(withoutCallIds)
projectStructuredAgentSessionMessages(rows, [], [], { rejectedInPlace: true }).map(
withoutCallIds
)
)
// The conversation's runs, then each helper's section's.
const runs = [
@@ -86,6 +86,32 @@ export async function prepareLegacySharedCodexSessionResume(
return { useRealCodexHome: true }
}
/** Explicit account restarts must move the verified rollout before changing credentials. */
export async function prepareCodexAccountRestartResume(args: {
sourceHome: string
transcriptPath: string
targetHome: string
systemCodexHomePath: string
}): Promise<string> {
if (sameRuntimePath(args.sourceHome, args.targetHome)) {
return args.targetHome
}
const relativePath = relative(
resolve(join(args.sourceHome, 'sessions')),
resolve(args.transcriptPath)
)
if (!isDatedRolloutRelativePath(relativePath)) {
throw new Error(RETRYABLE_RESUME_ERROR)
}
const paths = resolveCodexSessionBackfillPaths(args.systemCodexHomePath)
await materializeLegacyRollout(
args.transcriptPath,
join(args.targetHome, 'sessions', relativePath),
paths.auditLogPath
)
return args.targetHome
}
/**
* Repins a per-account resume to the selected account's home, or null to keep
* the session's own home.
@@ -206,6 +206,7 @@ function drawnPromptRows(): string[][] {
client.items,
[],
client.submissions,
{ rejectedInPlace: true },
projectStructuredQuestionMessages
)
)
@@ -243,9 +244,9 @@ describe('a Codex ask with several questions', () => {
])
// Mobile draws the shared projection in journal order, one row per question.
expect(
projectStructuredAgentSessionMessages(client.items, [], client.submissions).map(
({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null)
)
projectStructuredAgentSessionMessages(client.items, [], client.submissions, {
rejectedInPlace: false
}).map(({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null))
).toEqual(ASKED.map(({ question }) => question))
})
@@ -157,4 +157,79 @@ describe('readTerminalCursorLineContext', () => {
expect(detectTerminalComposerDraft(context)?.text).toBe(draft)
emulator.dispose()
})
it.each([true, false])('bounds blank-row cell reads with reusable cells %s', (reusable) => {
const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true })
try {
const rig = observeCellReads(terminal)
const active = rig.source.buffer.active
const source = {
...rig.source,
buffer: { active: { ...active, getNullCell: reusable ? active.getNullCell : undefined } }
}
const context = readTerminalCursorLineContext(source, terminal.rows)
expect(context?.typedRows).toEqual([''])
expect(context?.typedRowsBelow).toEqual(['', '', '', ''])
expect(context?.promptGlyphBoldRows).toEqual([false])
expect(context?.rowsBelowCustomForeground).toEqual([false, false, false, false])
// A blank glyph search must share the full row scan; the cursor suffix is separate.
expect(rig.targets.length).toBeLessThanOrEqual(terminal.cols * (terminal.rows + 1))
expect(rig.allocated).toHaveLength(reusable ? 1 : 0)
expect(rig.targets.every((cell) => cell === rig.allocated[0])).toBe(true)
} finally {
terminal.dispose()
}
})
it('keeps dimmed first-glyph styles while excluding their text', () => {
const terminal = new Terminal({ cols: 32, rows: 5, allowProposedApi: true })
try {
writeSync(terminal, '\x1b[2;1m›\x1b[0m typed\x1b7\r\n\x1b[2;31mfooter\x1b[0m\x1b8')
const context = readTerminalCursorLineContext(terminal, terminal.rows)
expect(context?.typedRows).toEqual([' typed'])
expect(context?.promptGlyphBoldRows).toEqual([true])
expect(context?.typedRowsBelow[0]).toBe('')
expect(context?.rowsBelowCustomForeground?.[0]).toBe(true)
} finally {
terminal.dispose()
}
})
it('uses the first visible glyph even when later glyphs have styles', () => {
const terminal = new Terminal({ cols: 32, rows: 5, allowProposedApi: true })
try {
writeSync(
terminal,
'\x1b[1m \u00a0\x1b[22m›\x1b[1mX\x1b[0m\x1b7\r\n' +
'\x1b[31m \x1b[39mplain \x1b[32mlater\x1b[0m\x1b8'
)
const context = readTerminalCursorLineContext(terminal, terminal.rows)
expect(context?.promptGlyphBoldRows).toEqual([false])
expect(context?.rowsBelowCustomForeground?.[0]).toBe(false)
} finally {
terminal.dispose()
}
})
it('finds styled first glyphs after unstyled blank cells', () => {
const terminal = new Terminal({ cols: 32, rows: 5, allowProposedApi: true })
try {
writeSync(terminal, ' \x1b[1m›\x1b[0m typed\x1b7\r\n \x1b[31mfooter\x1b[0m\x1b8')
const context = readTerminalCursorLineContext(terminal, terminal.rows)
expect(context?.promptGlyphBoldRows).toEqual([true])
expect(context?.rowsBelowCustomForeground?.[0]).toBe(true)
} finally {
terminal.dispose()
}
})
it('keeps an unwritten cell between typed glyphs as a space', () => {
const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true })
try {
writeSync(terminal, 'a\x1b[3Gb')
expect(readTerminalCursorLineContext(terminal, terminal.rows)?.typedRows).toEqual(['a b'])
} finally {
terminal.dispose()
}
})
})
@@ -1,21 +1,15 @@
import type { TerminalModes } from './types'
import { splitAtAlternateScreenEntry } from '../../shared/terminal-alternate-screen-split'
export function splitTerminalSnapshotAnsi(
snapshotAnsi: string,
modes: TerminalModes
): { snapshotAnsi: string; scrollbackAnsi: string } {
if (!modes.alternateScreen) {
return { snapshotAnsi, scrollbackAnsi: '' }
}
const alternateScreenMarker = '\x1b[?1049h'
const start = snapshotAnsi.lastIndexOf(alternateScreenMarker)
if (start === -1) {
const split = modes.alternateScreen ? splitAtAlternateScreenEntry(snapshotAnsi) : null
if (!split) {
return { snapshotAnsi, scrollbackAnsi: '' }
}
// Why: rehydrateSequences owns the alt-screen transition. Keeping the
// normal buffer separate lets an already-alt renderer rebuild it safely.
return {
scrollbackAnsi: snapshotAnsi.slice(0, start),
snapshotAnsi: snapshotAnsi.slice(start + alternateScreenMarker.length)
}
return { scrollbackAnsi: split.normalAnsi, snapshotAnsi: split.alternateAnsi }
}
@@ -12,6 +12,7 @@ const { copyFileMock, handleMock, lstatMock, realpathMock, renameMock } = vi.hoi
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
vi.mock('electron', () => ({
app: { getPath: () => '/orca-test-user-data' },
ipcMain: { handle: handleMock }
}))
@@ -22,6 +22,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
['main/gitea/client.ts', 1],
// Generated OpenCode claim source consumes JSON or cancels its body in finally.
['main/opencode/opencode-startup-prompt-source.ts', 1],
// Authenticated loopback preflight consumes bounded JSON and cancels every body in finally.
['main/opencode/opencode-launch-model-context.ts', 1],
['main/orca-profiles/profile-cloud-client.ts', 1],
['main/orca-profiles/profile-cloud-org-members-client.ts', 1],
['main/rate-limits/codex-fetcher.ts', 3],
+5 -5
View File
@@ -10,7 +10,7 @@ const {
destroySystemTrayMock,
relaunchAppMock,
showOpenDialogMock,
grantFloatingWorkspaceDirectoryMock,
trustFloatingWorkspaceDirectoryMock,
registerRendererShutdownCheckpointHandlerMock,
registerMacKeyboardLayoutChangeNotificationsMock
} = vi.hoisted(() => ({
@@ -22,7 +22,7 @@ const {
destroySystemTrayMock: vi.fn(),
relaunchAppMock: vi.fn(),
showOpenDialogMock: vi.fn(),
grantFloatingWorkspaceDirectoryMock: vi.fn(),
trustFloatingWorkspaceDirectoryMock: vi.fn(),
registerRendererShutdownCheckpointHandlerMock: vi.fn(),
registerMacKeyboardLayoutChangeNotificationsMock: vi.fn()
}))
@@ -103,7 +103,7 @@ vi.mock('../app-relaunch', () => ({
vi.mock('./floating-workspace-directory', () => ({
ensureDefaultFloatingWorkspacePath: vi.fn(),
grantFloatingWorkspaceDirectory: grantFloatingWorkspaceDirectoryMock,
trustFloatingWorkspaceDirectory: trustFloatingWorkspaceDirectoryMock,
resolveFloatingTerminalCwd: vi.fn()
}))
@@ -155,7 +155,7 @@ describe('registerAppHandlers', () => {
relaunchAppMock.mockReset()
relaunchAppMock.mockImplementation(() => appRelaunchMock())
showOpenDialogMock.mockReset()
grantFloatingWorkspaceDirectoryMock.mockReset()
trustFloatingWorkspaceDirectoryMock.mockReset()
registerRendererShutdownCheckpointHandlerMock.mockReset()
registerMacKeyboardLayoutChangeNotificationsMock.mockReset()
for (const probe of Object.values(windowsProbes)) {
@@ -416,7 +416,7 @@ describe('registerAppHandlers', () => {
expect(showOpenDialogMock).toHaveBeenCalledWith({
properties: ['openDirectory']
})
expect(grantFloatingWorkspaceDirectoryMock).toHaveBeenCalledWith(store, '/Users/kaylee/notes')
expect(trustFloatingWorkspaceDirectoryMock).toHaveBeenCalledWith(store, '/Users/kaylee/notes')
})
// Why: the renderer reads these on every Windows capability refresh; the sync probes
+4 -6
View File
@@ -15,10 +15,9 @@ import { isWslAvailableAsync, listWslDistrosAsync } from '../wsl'
import { isGitBashAvailable } from '../git-bash'
import { setUnreadDockBadgeCount } from '../dock/unread-badge'
import { destroySystemTray } from '../tray/system-tray'
import { authorizeExternalPath } from './filesystem-auth'
import {
ensureDefaultFloatingWorkspacePath,
grantFloatingWorkspaceDirectory,
trustFloatingWorkspaceDirectory,
resolveFloatingTerminalCwd
} from './floating-workspace-directory'
import { isMarkdownDocumentName, markdownDocumentFromFilePath } from './markdown-documents'
@@ -60,7 +59,6 @@ async function pickFloatingMarkdownDocument(
if (!isMarkdownDocumentName(filePath)) {
throw new Error('Selected file is not a markdown document.')
}
authorizeExternalPath(filePath)
return markdownDocumentFromFilePath(cwd, filePath, { outsideRootRelativePath: 'basename' })
}
@@ -70,7 +68,7 @@ async function pickFloatingWorkspaceDirectory(
): Promise<string | null> {
const parentWindow = BrowserWindow.fromWebContents(event.sender)
const options = {
// Why: this picker only grants access to an existing directory; creation belongs to explicit file actions.
// Why: this picker only chooses an existing directory; creation belongs to explicit file actions.
properties: ['openDirectory']
} satisfies Electron.OpenDialogOptions
const result = parentWindow
@@ -80,8 +78,8 @@ async function pickFloatingWorkspaceDirectory(
return null
}
const selectedDir = result.filePaths[0]
// Why: a user-approved picker selection is a trust grant for later markdown creation, unlike typed settings text.
await grantFloatingWorkspaceDirectory(store, selectedDir)
// Why: only a user-approved picker selection may become the floating terminal's cwd, unlike typed settings text.
await trustFloatingWorkspaceDirectory(store, selectedDir)
return selectedDir
}
+47
View File
@@ -0,0 +1,47 @@
import { basename, sep } from 'node:path'
import { parseWslUncPath } from '../../shared/wsl-paths'
// Why the path flavour, not the OS: these are Windows path rules, and tests exercise them with
// path.win32 on any host.
function usesWindowsPaths(): boolean {
return sep === '\\'
}
const WINDOWS_RESERVED_DEVICE_STEM =
/^(?:con|prn|aux|nul|conin\$|conout\$|clock\$|com[0-9¹²³]|lpt[0-9¹²³])$/i
/**
* `NUL.png`, `com1 .jpg`, `Aux.`, `NUL:stream.png` name a Windows device, not a file, whatever the
* extension or alternate data stream (`:`).
*/
export function isWindowsReservedDeviceName(filePath: string): boolean {
if (!usesWindowsPaths()) {
return false
}
const stem =
basename(filePath)
.replace(/[. ]+$/, '')
.split(/[.:]/)[0] ?? ''
return WINDOWS_RESERVED_DEVICE_STEM.test(stem.replace(/ +$/, ''))
}
function toBackslashes(filePath: string): string {
return filePath.replace(/\//g, '\\')
}
/** A Windows device-namespace path (`\\?\`, `\\.\`), which can name devices and shares alike. */
export function isDeviceNamespacePath(filePath: string): boolean {
return usesWindowsPaths() && /^\\\\[?.]\\/.test(toBackslashes(filePath))
}
/**
* A network share (`\\host\share`). WSL paths are UNC in form but stay on this machine, so they
* are not network paths.
*/
export function isNetworkSharePath(filePath: string): boolean {
if (!usesWindowsPaths() || isDeviceNamespacePath(filePath)) {
return false
}
const normalized = toBackslashes(filePath)
return normalized.startsWith('\\\\') && parseWslUncPath(normalized) === null
}
+1 -20
View File
@@ -15,7 +15,7 @@ import type { ProjectGroup } from '../../shared/project-group-types'
import type { Project } from '../../shared/project-types'
import type { Repo } from '../../shared/repo-types'
import { getAllowedRoots } from './filesystem-allowed-roots'
import { authorizeExternalPath, resolveAuthorizedPath } from './filesystem-auth'
import { resolveAuthorizedPath } from './filesystem-auth'
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic'
@@ -358,25 +358,6 @@ describe('resolveAuthorizedPath allowed-root reuse', () => {
}
)
it('builds no allowed-root list at all for a granted external path', async () => {
const external = join(outsideRoot, 'external.md')
await writeFile(external, 'notes\n')
authorizeExternalPath(external)
counts.getRepos = 0
counts.getProjects = 0
counts.getFolderWorkspaces = 0
for (let index = 0; index < 5; index += 1) {
await expect(resolveAuthorizedPath(external, store)).resolves.toBe(external)
}
// The grant answers on its own; hoisting the snapshot must not turn zero builds into one per read.
expect.soft(counts.getRepos).toBe(0)
expect.soft(counts.getProjects).toBe(0)
expect.soft(counts.getFolderWorkspaces).toBe(0)
expect.soft(vi.mocked(buildProjectGroupChildIndex)).not.toHaveBeenCalled()
})
it.skipIf(process.platform === 'win32')(
'still refuses a directory symlink that escapes every allowed root',
async () => {
+1 -45
View File
@@ -10,12 +10,7 @@ import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { Repo } from '../../shared/repo-types'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import {
AUTHORIZED_EXTERNAL_PATHS_MAX,
authorizeExternalPath,
isPathAllowed,
resolveAuthorizedPath
} from './filesystem-auth'
import { resolveAuthorizedPath } from './filesystem-auth'
import { isDescendantOrEqual, validateGitRelativeFilePath } from './filesystem-path-containment'
import {
__resetCreatedWorktreeRootsForTests,
@@ -419,42 +414,3 @@ describe('filesystem-auth path containment', () => {
}
})
})
describe('filesystem-auth authorized external path bound', () => {
// Empty allow-list store, so a path is allowed only if it (or an ancestor) is
// in the session-authorized external-path set.
const emptyStore = makeStore([])
const flood = (n: number): string =>
resolve(`/leak-audit-ext/flood-${String(n).padStart(6, '0')}`)
it('bounds the authorized external path set with LRU eviction', () => {
const keep = resolve('/leak-audit-ext/keep')
authorizeExternalPath(keep)
// Flood past the cap with distinct external paths, re-authorizing `keep`
// periodically so LRU keeps it hot.
const total = AUTHORIZED_EXTERNAL_PATHS_MAX + 200
for (let i = 0; i < total; i += 1) {
authorizeExternalPath(flood(i))
if (i % 250 === 0) {
authorizeExternalPath(keep)
}
}
// The oldest never-re-touched entries fell out of the bounded set...
expect(isPathAllowed(flood(0), emptyStore)).toBe(false)
// ...while the periodically re-authorized path and the most recent survive.
expect(isPathAllowed(keep, emptyStore)).toBe(true)
expect(isPathAllowed(flood(total - 1), emptyStore)).toBe(true)
})
it('re-authorizes an evicted path on next use (self-healing)', () => {
const path = resolve('/leak-audit-ext/evicted-then-reused')
for (let i = 0; i < AUTHORIZED_EXTERNAL_PATHS_MAX + 50; i += 1) {
authorizeExternalPath(flood(100_000 + i))
}
expect(isPathAllowed(path, emptyStore)).toBe(false)
authorizeExternalPath(path)
expect(isPathAllowed(path, emptyStore)).toBe(true)
})
})
+11 -46
View File
@@ -1,7 +1,7 @@
import { resolve, dirname, basename } from 'node:path'
import { realpathSync } from 'node:fs'
import { realpath } from 'node:fs/promises'
import type { Store } from '../persistence'
import { PATH_OUTSIDE_ALLOWED_DIRECTORIES } from '../../shared/local-file-access'
import { getAllowedRoots } from './filesystem-allowed-roots'
import { isDescendantOrEqual, isENOENT, normalizeExistingPath } from './filesystem-path-containment'
import {
@@ -16,45 +16,16 @@ export { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cach
export { invalidateAuthorizedRootsCacheForRepo } from './registered-worktree-roots-scoped-invalidation'
export { isENOENT } from './filesystem-path-containment'
export const PATH_ACCESS_DENIED_MESSAGE =
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
// Why: authorized external paths accumulate all session; LRU-bound the set. Safe to evict because every caller re-authorizes before operating.
export const AUTHORIZED_EXTERNAL_PATHS_MAX = 4096
const authorizedExternalPaths = new Set<string>()
function rememberAuthorizedExternalPath(path: string): void {
// Delete-then-add makes re-authorized paths most-recent so LRU eviction sheds only the oldest untouched entries.
authorizedExternalPaths.delete(path)
authorizedExternalPaths.add(path)
while (authorizedExternalPaths.size > AUTHORIZED_EXTERNAL_PATHS_MAX) {
const oldest = authorizedExternalPaths.keys().next().value
if (oldest === undefined) {
break
}
authorizedExternalPaths.delete(oldest)
}
}
export function authorizeExternalPath(targetPath: string): void {
const resolvedTarget = resolve(targetPath)
rememberAuthorizedExternalPath(resolvedTarget)
try {
// Why: macOS canonicalizes /tmp to /private/tmp during read authorization.
rememberAuthorizedExternalPath(realpathSync(resolvedTarget))
} catch {}
}
/**
* One allowed-root list shared by every check in a single authorization.
*
* Lazy so a path already covered by an external grant still builds nothing at all, the way it did
* before the list was hoisted out of the individual checks.
*/
export const PATH_ACCESS_DENIED_MESSAGE = `${PATH_OUTSIDE_ALLOWED_DIRECTORIES}. If this blocks a legitimate workflow, please file a GitHub issue.`
/** One allowed-root list shared by every check in a single authorization, built on first use. */
type AllowedRootsSnapshot = { get: () => readonly string[] }
function createAllowedRootsSnapshot(store: Store): AllowedRootsSnapshot {
function createAllowedRootsSnapshot(
store: Store,
extraRoots: readonly string[] = []
): AllowedRootsSnapshot {
let roots: readonly string[] | undefined
return { get: () => (roots ??= getAllowedRoots(store)) }
return { get: () => (roots ??= [...getAllowedRoots(store), ...extraRoots]) }
}
export function isPathAllowed(
@@ -63,14 +34,6 @@ export function isPathAllowed(
allowedRoots?: AllowedRootsSnapshot
): boolean {
const resolvedTarget = resolve(targetPath)
if (authorizedExternalPaths.has(resolvedTarget)) {
return true
}
for (const authorizedPath of authorizedExternalPaths) {
if (isDescendantOrEqual(resolvedTarget, authorizedPath)) {
return true
}
}
return (allowedRoots?.get() ?? getAllowedRoots(store)).some((root) =>
isDescendantOrEqual(resolvedTarget, root)
)
@@ -81,6 +44,8 @@ export type ResolveAuthorizedPathOptions = {
* Canonicalize the parent but preserve the leaf so delete/rename target the symlink itself, not its destination (which may live outside allowed roots).
*/
preserveSymlink?: boolean
/** Roots only the desktop window may use (never runtime RPC), checked like any other root. */
extraRoots?: readonly string[]
}
export async function resolveAuthorizedPath(
@@ -91,7 +56,7 @@ export async function resolveAuthorizedPath(
const resolvedTarget = resolve(targetPath)
// Why: the roots depend only on store state, not on the candidate path, so one snapshot serves
// every authorization below; each candidate is still checked against it in full.
const allowedRoots = createAllowedRootsSnapshot(store)
const allowedRoots = createAllowedRootsSnapshot(store, options.extraRoots)
if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store, { allowedRoots }))) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
@@ -0,0 +1,324 @@
import { mkdir, mkdtemp, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
import type * as RepoWorktrees from '../repo-worktrees'
import {
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
type Handler = (event: unknown, args: unknown) => Promise<unknown>
const { handlers, userData } = vi.hoisted(() => ({
handlers: new Map<string, Handler>(),
userData: { path: '' }
}))
vi.mock('electron', () => ({
app: { getPath: () => userData.path },
ipcMain: { handle: (channel: string, handler: Handler) => handlers.set(channel, handler) }
}))
vi.mock('../repo-worktrees', async () => {
const actual = await vi.importActual<typeof RepoWorktrees>('../repo-worktrees')
return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) }
})
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
let projectPaths: string[] = []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members.
const STORE = {
getRepos: () =>
projectPaths.map((path, index) => ({
id: `repo-${index}`,
path,
displayName: 'project',
badgeColor: '#000',
addedAt: 0
})),
getProjects: () => [],
getProjectGroups: () => [],
getFolderWorkspaces: () => [],
getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' })
} as unknown as Store
const documentFolder = (documentPath: string) => ({ kind: 'document-folder', documentPath })
async function settles(promise: Promise<unknown>): Promise<'ok' | 'denied'> {
return promise.then(
() => 'ok',
() => 'denied'
)
}
function call(channel: string, args: unknown): Promise<unknown> {
const handler = handlers.get(channel)
if (!handler) {
throw new Error(`no handler for ${channel}`)
}
return handler(null, args)
}
let base: string
let docFolder: string
let note: string
beforeEach(async () => {
invalidateAuthorizedRootsCache()
handlers.clear()
base = await mkdtemp(join(await realpath(tmpdir()), 'orca-document-folder-'))
userData.path = join(base, 'user-data')
docFolder = join(base, 'notes')
note = join(docFolder, 'note.md')
await mkdir(join(userData.path, 'floating-workspace'), { recursive: true })
await mkdir(docFolder)
await writeFile(note, '# note\n')
await writeFile(join(base, 'shot.png'), 'png')
projectPaths = []
registerFilesystemMutationHandlers(STORE)
})
afterEach(async () => {
await rm(base, { recursive: true, force: true })
})
describe('renaming a document the user opened outside every project', () => {
it('renames it within its own folder', async () => {
const renamed = join(docFolder, 'renamed.md')
await call('fs:rename', { oldPath: note, newPath: renamed, access: documentFolder(note) })
expect(await readdir(docFolder)).toEqual(['renamed.md'])
})
it('refuses another file, a relative new name, and a request with no access', async () => {
await writeFile(join(docFolder, 'other.md'), 'other')
expect(
await settles(
call('fs:rename', {
oldPath: join(docFolder, 'other.md'),
newPath: join(docFolder, 'moved.md'),
access: documentFolder(note)
})
)
).toBe('denied')
expect(
await settles(
call('fs:rename', { oldPath: note, newPath: 'moved.md', access: documentFolder(note) })
)
).toBe('denied')
expect(
await settles(call('fs:rename', { oldPath: note, newPath: join(docFolder, 'plain.md') }))
).toBe('denied')
expect((await readdir(docFolder)).sort()).toEqual(['note.md', 'other.md'])
})
it('undoes a rename with the renamed file declared as the document', async () => {
const renamed = join(docFolder, 'renamed.md')
await call('fs:rename', { oldPath: note, newPath: renamed, access: documentFolder(note) })
await call('fs:rename', { oldPath: renamed, newPath: note, access: documentFolder(renamed) })
expect(await readdir(docFolder)).toEqual(['note.md'])
})
// Why each destination is undone: the Undo declares the moved file, so it must come back from anywhere.
it('moves it into another outside folder, and Undo brings it back', async () => {
await mkdir(join(base, 'other'))
const moved = join(base, 'other', 'note.md')
await call('fs:rename', { oldPath: note, newPath: moved, access: documentFolder(note) })
expect(await readdir(join(base, 'other'))).toEqual(['note.md'])
await call('fs:rename', { oldPath: moved, newPath: note, access: documentFolder(moved) })
expect(await readdir(join(base, 'other'))).toEqual([])
expect(await readdir(docFolder)).toEqual(['note.md'])
})
it('moves it into a project, and Undo brings it back out', async () => {
const project = join(base, 'proj')
await mkdir(project)
projectPaths = [project]
invalidateAuthorizedRootsCache()
const moved = join(project, 'note.md')
await call('fs:rename', { oldPath: note, newPath: moved, access: documentFolder(note) })
expect(await readdir(project)).toEqual(['note.md'])
await call('fs:rename', { oldPath: moved, newPath: note, access: documentFolder(moved) })
expect(await readdir(project)).toEqual([])
expect(await readdir(docFolder)).toEqual(['note.md'])
})
it('moves it into a subfolder, and Undo brings it back', async () => {
await mkdir(join(docFolder, 'archive'))
const moved = join(docFolder, 'archive', 'note.md')
await call('fs:rename', { oldPath: note, newPath: moved, access: documentFolder(note) })
expect(await readdir(join(docFolder, 'archive'))).toEqual(['note.md'])
await call('fs:rename', { oldPath: moved, newPath: note, access: documentFolder(moved) })
expect(await readdir(join(docFolder, 'archive'))).toEqual([])
expect((await readdir(docFolder)).sort()).toEqual(['archive', 'note.md'])
})
})
describe('inserting an image into a document the user opened outside every project', () => {
it('copies the image into the document folder', async () => {
const outcome = await call('fs:importExternalPaths', {
sourcePaths: [join(base, 'shot.png')],
destDir: docFolder,
access: documentFolder(note)
})
expect(outcome).toMatchObject({ results: [{ status: 'imported' }] })
expect((await readdir(docFolder)).sort()).toEqual(['note.md', 'shot.png'])
})
it('copies the image into a subfolder of the document folder', async () => {
await mkdir(join(docFolder, 'images'))
await call('fs:importExternalPaths', {
sourcePaths: [join(base, 'shot.png')],
destDir: join(docFolder, 'images'),
access: documentFolder(note)
})
expect(await readdir(join(docFolder, 'images'))).toEqual(['shot.png'])
})
it.skipIf(process.platform === 'win32')(
'refuses an import through a linked subfolder that leads out',
async () => {
await mkdir(join(base, 'elsewhere'))
await symlink(join(base, 'elsewhere'), join(docFolder, 'linked'))
expect(
await settles(
call('fs:importExternalPaths', {
sourcePaths: [join(base, 'shot.png')],
destDir: join(docFolder, 'linked'),
access: documentFolder(note)
})
)
).toBe('denied')
expect(await readdir(join(base, 'elsewhere'))).toEqual([])
}
)
it('refuses the parent folder, and any outside folder without access', async () => {
const importInto = (destDir: string, access?: unknown) =>
settles(
call('fs:importExternalPaths', {
sourcePaths: [join(base, 'shot.png')],
destDir,
access
})
)
expect(await importInto(base, documentFolder(note))).toBe('denied')
expect(await importInto(docFolder)).toBe('denied')
expect(await readdir(docFolder)).toEqual(['note.md'])
})
})
describe('a project file opened by its full path', () => {
it('renames into a subfolder of the project, which the project check allows', async () => {
const project = join(base, 'project')
await mkdir(join(project, 'docs', 'old'), { recursive: true })
await writeFile(join(project, 'docs', 'plan.md'), '# plan\n')
projectPaths = [project]
invalidateAuthorizedRootsCache()
const plan = join(project, 'docs', 'plan.md')
await call('fs:rename', {
oldPath: plan,
newPath: join(project, 'docs', 'old', 'plan.md'),
access: documentFolder(plan)
})
expect(await readdir(join(project, 'docs', 'old'))).toEqual(['plan.md'])
})
it('renames into another folder of the same project, as with no declared access', async () => {
const project = join(base, 'project')
await mkdir(join(project, 'docs'), { recursive: true })
await mkdir(join(project, 'archive'))
await writeFile(join(project, 'docs', 'plan.md'), '# plan\n')
projectPaths = [project]
invalidateAuthorizedRootsCache()
const plan = join(project, 'docs', 'plan.md')
await call('fs:rename', {
oldPath: plan,
newPath: join(project, 'archive', 'plan.md'),
access: documentFolder(plan)
})
expect(await readdir(join(project, 'archive'))).toEqual(['plan.md'])
expect(await readdir(join(project, 'docs'))).toEqual([])
})
it('moves out of the project, and Undo brings it back in', async () => {
const project = join(base, 'project')
await mkdir(join(project, 'docs'), { recursive: true })
await writeFile(join(project, 'docs', 'plan.md'), '# plan\n')
projectPaths = [project]
invalidateAuthorizedRootsCache()
const plan = join(project, 'docs', 'plan.md')
const moved = join(docFolder, 'plan.md')
await call('fs:rename', { oldPath: plan, newPath: moved, access: documentFolder(plan) })
expect((await readdir(docFolder)).sort()).toEqual(['note.md', 'plan.md'])
await call('fs:rename', { oldPath: moved, newPath: plan, access: documentFolder(moved) })
expect(await readdir(join(project, 'docs'))).toEqual(['plan.md'])
expect(await readdir(docFolder)).toEqual(['note.md'])
})
it('never moves another project file out by naming the opened document', async () => {
const project = join(base, 'project')
await mkdir(project)
await writeFile(join(project, 'secret.md'), 'secret')
projectPaths = [project]
invalidateAuthorizedRootsCache()
expect(
await settles(
call('fs:rename', {
oldPath: join(project, 'secret.md'),
newPath: join(docFolder, 'secret.md'),
access: documentFolder(note)
})
)
).toBe('denied')
expect(await readdir(project)).toEqual(['secret.md'])
})
})
describe('an SSH rename', () => {
it('goes to the remote host as before, whatever access it declares', async () => {
const renameNoClobber = vi.fn().mockResolvedValue(undefined)
registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never)
try {
await call('fs:rename', {
oldPath: note,
newPath: join(base, 'note.md'),
access: documentFolder(note),
connectionId: 'ssh-1',
expectedSshTargetId: 'ssh-1',
expectedSshConnectionGeneration: 0
})
} finally {
unregisterSshFilesystemProvider('ssh-1')
}
expect(renameNoClobber).toHaveBeenCalledWith(note, join(base, 'note.md'))
expect(await readdir(docFolder)).toEqual(['note.md'])
})
})
@@ -12,7 +12,6 @@ const { lstatMock, mkdirMock, openMock, readdirMock, rmMock, unlinkMock } = vi.h
unlinkMock: vi.fn()
}))
vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: vi.fn() }))
vi.mock('node:fs/promises', () => ({
lstat: lstatMock,
mkdir: mkdirMock,
+2 -7
View File
@@ -1,6 +1,5 @@
import { lstat } from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { authorizeExternalPath } from './filesystem-auth'
import { isENOENT } from './filesystem-path-containment'
import type { ImportItemResult } from '../../shared/filesystem-import-result-types'
import {
@@ -10,8 +9,8 @@ import {
} from './filesystem-import-local-tree-copy'
/**
* Import a single top-level source into destDir, handling authorization,
* validation, pre-scan, deconfliction, and copy.
* Import a single top-level source into destDir, handling validation, pre-scan,
* deconfliction, and copy.
*/
export async function importOneSource(
sourcePath: string,
@@ -20,10 +19,6 @@ export async function importOneSource(
): Promise<ImportItemResult> {
const resolvedSource = resolve(sourcePath)
// Why: authorize the external source path so downstream filesystem
// operations (lstat, readdir, copyFile) are permitted by Electron.
authorizeExternalPath(resolvedSource)
// Why: validate source using lstat on the unresolved path *before*
// canonicalization so top-level symlinks are rejected instead of being
// silently dereferenced by realpath.
@@ -15,9 +15,6 @@ vi.mock('node:fs/promises', () => ({
readdir: readdirMock,
realpath: realpathMock
}))
vi.mock('./filesystem-auth', () => ({
authorizeExternalPath: vi.fn()
}))
vi.mock('./filesystem-path-containment', () => ({
isENOENT: (error: NodeJS.ErrnoException) => error.code === 'ENOENT'
}))
+4 -1
View File
@@ -27,7 +27,10 @@ const {
getConnMgrMock: vi.fn()
}))
vi.mock('electron', () => ({ ipcMain: { handle: handleMock } }))
vi.mock('electron', () => ({
app: { getPath: () => '/orca-test-user-data' },
ipcMain: { handle: handleMock }
}))
vi.mock('fs/promises', () => ({
lstat: lstatMock,
mkdir: mkdirMock,
-3
View File
@@ -1,6 +1,5 @@
import { lstat } from 'node:fs/promises'
import { basename, posix, resolve } from 'node:path'
import { authorizeExternalPath } from './filesystem-auth'
import { isENOENT } from './filesystem-path-containment'
import { getSshConnectionManager } from './ssh'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
@@ -97,8 +96,6 @@ async function importOneSourceSsh(
): Promise<ImportItemResult> {
const resolvedSource = resolve(sourcePath)
authorizeExternalPath(resolvedSource)
const originalName = basename(resolvedSource)
try {
assertSafeRemotePathSegment(originalName, remotePathFlavor)
+1
View File
@@ -29,6 +29,7 @@ const {
}))
vi.mock('electron', () => ({
app: { getPath: () => '/orca-test-user-data' },
ipcMain: { handle: handleMock }
}))
@@ -14,6 +14,7 @@ const { handleMock, copyFileMock, lstatMock, mkdirMock, renameMock, writeFileMoc
}))
vi.mock('electron', () => ({
app: { getPath: () => '/orca-test-user-data' },
ipcMain: { handle: handleMock }
}))
+32 -10
View File
@@ -3,7 +3,12 @@ import { constants } from 'node:fs'
import { copyFile, mkdir, writeFile } from 'node:fs/promises'
import { basename, dirname } from 'node:path'
import type { Store } from '../persistence'
import { resolveAuthorizedPath } from './filesystem-auth'
import {
resolveDesktopAuthorizedPath,
resolveLocalRenamePaths,
resolveLocalRequestPath
} from './local-file-access-resolution'
import type { LocalFileAccess } from '../../shared/local-file-access'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution'
import { importExternalPathsSsh } from './filesystem-import-ssh'
@@ -49,7 +54,7 @@ export function registerFilesystemMutationHandlers(store: Store): void {
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.createFile(args.filePath)
}
const filePath = await resolveAuthorizedPath(args.filePath, store)
const filePath = await resolveDesktopAuthorizedPath(args.filePath, store)
await mkdir(dirname(filePath), { recursive: true })
try {
// Use the 'wx' flag for atomic create-if-not-exists, avoiding TOCTOU races
@@ -76,7 +81,7 @@ export function registerFilesystemMutationHandlers(store: Store): void {
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.createDir(args.dirPath)
}
const dirPath = await resolveAuthorizedPath(args.dirPath, store)
const dirPath = await resolveDesktopAuthorizedPath(args.dirPath, store)
await assertNotExists(dirPath)
await mkdir(dirPath, { recursive: true })
}
@@ -89,7 +94,12 @@ export function registerFilesystemMutationHandlers(store: Store): void {
'fs:rename',
async (
_event,
args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation
args: {
oldPath: string
newPath: string
connectionId?: string
access?: LocalFileAccess
} & SshMutationExpectation
): Promise<void> => {
assertSshMutationExpectation(
args.connectionId,
@@ -107,9 +117,14 @@ export function registerFilesystemMutationHandlers(store: Store): void {
// target file (potentially elsewhere in the worktree) and leave the
// symlink dangling. newPath must also preserve its leaf so we don't
// accidentally write into a symlinked destination name.
const oldPath = await resolveAuthorizedPath(args.oldPath, store, { preserveSymlink: true })
const newPath = await resolveAuthorizedPath(args.newPath, store, { preserveSymlink: true })
await renameLocalPathSerializedByDestination(oldPath, newPath)
// Outside every project, a document the user opened may still be renamed, to any path.
const { from, to } = await resolveLocalRenamePaths(
args.oldPath,
args.newPath,
args.access,
store
)
await renameLocalPathSerializedByDestination(from, to)
}
)
@@ -133,10 +148,10 @@ export function registerFilesystemMutationHandlers(store: Store): void {
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.copy(args.sourcePath, args.destinationPath)
}
const sourcePath = await resolveAuthorizedPath(args.sourcePath, store, {
const sourcePath = await resolveDesktopAuthorizedPath(args.sourcePath, store, {
preserveSymlink: true
})
const destinationPath = await resolveAuthorizedPath(args.destinationPath, store, {
const destinationPath = await resolveDesktopAuthorizedPath(args.destinationPath, store, {
preserveSymlink: true
})
await mkdir(dirname(destinationPath), { recursive: true })
@@ -155,6 +170,7 @@ export function registerFilesystemMutationHandlers(store: Store): void {
destDir: string
connectionId?: string
ensureDir?: boolean
access?: LocalFileAccess
} & SshMutationExpectation
): Promise<{ results: ImportItemResult[] }> => {
assertSshMutationExpectation(
@@ -180,7 +196,13 @@ export function registerFilesystemMutationHandlers(store: Store): void {
// destination is outside allowed roots, the entire import fails.
// This only applies to local imports — remote paths are authorized by
// the SSH connection boundary (see importExternalPathsSsh).
const resolvedDest = await resolveAuthorizedPath(args.destDir, store)
// An image inserted into a document the user opened lands in that document's own folder.
const resolvedDest = await resolveLocalRequestPath(
args.destDir,
args.access,
store,
'import-into'
)
const results: ImportItemResult[] = []
const reservedNames = new Set<string>()
@@ -6,7 +6,6 @@ import type * as RuntimeImportLimits from './runtime-import-limits'
type RuntimeImportLimitsModule = typeof RuntimeImportLimits
vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} }))
// Why: real ceilings are gigabytes, and truncate() is not sparse on NTFS, so a
// literal over-limit fixture would allocate that much on Windows CI.
vi.mock('./runtime-import-limits', async (importOriginal) => ({
@@ -6,7 +6,6 @@ import {
import { constants } from 'node:fs'
import { lstat, open, readdir, realpath } from 'node:fs/promises'
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { authorizeExternalPath } from './filesystem-auth'
import { isENOENT } from './filesystem-path-containment'
import type {
StagedExternalImportEntry,
@@ -36,10 +35,6 @@ export async function stageOneSourceForRuntimeUpload(
): Promise<StagedExternalImportSource> {
const resolvedSource = resolve(sourcePath)
// Why: runtime uploads read client-local paths in the client main process;
// authorize before lstat just like local copy imports.
authorizeExternalPath(resolvedSource)
let sourceStat: Awaited<ReturnType<typeof lstat>>
try {
sourceStat = await lstat(resolvedSource)
@@ -24,6 +24,7 @@ const {
const handlers = new Map<string, (event: unknown, args: unknown) => unknown>()
vi.mock('electron', () => ({
app: { getPath: () => '/orca-test-user-data' },
ipcMain: {
handle: handleMock
},
@@ -43,7 +44,6 @@ vi.mock('../wsl', () => ({
}))
vi.mock('./filesystem-auth', () => ({
authorizeExternalPath: vi.fn(async (value: string) => value),
resolveAuthorizedPath: resolveAuthorizedPathMock
}))
+32 -9
View File
@@ -60,6 +60,7 @@ export const recordCrashBreadcrumbMock: IpcMock = vi.fn()
export const promoteLocalDownloadedFolderMock: IpcMock = vi.fn()
export const electronMock = {
app: { getPath: () => '/orca-test-user-data' },
BrowserWindow: { fromWebContents: fromWebContentsMock },
dialog: { showSaveDialog: showSaveDialogMock, showOpenDialog: showOpenDialogMock },
ipcMain: { handle: handleMock },
@@ -237,6 +238,36 @@ const ALL_MOCKS = [
pullRequestLinkedIssueMock
].flatMap(collectMocks)
/** A FileHandle double over `content`: positional reads copy from it, and stat reports its size. */
export function localFileHandleMock(
content: Buffer,
{ isFile = true, size = content.byteLength }: { isFile?: boolean; size?: number } = {}
): Record<string, unknown> {
return {
stat: vi.fn(async () => ({
size,
isFile: () => isFile,
isDirectory: () => false,
mtimeMs: 123,
dev: 1,
ino: 2,
birthtimeMs: 3
})),
read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
const bytesRead = content.copy(
buffer,
offset,
position,
Math.min(position + length, content.length)
)
return { bytesRead, buffer }
}),
write: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
close: vi.fn()
}
}
/** Resets every filesystem IPC mock and reinstalls the defaults every suite starts from. */
export function resetFilesystemIpcMocks(): void {
handlers.clear()
@@ -272,14 +303,6 @@ export function resetFilesystemIpcMocks(): void {
statMock.mockResolvedValue({ size: 10, isDirectory: () => false, mtimeMs: 123 })
renameMock.mockResolvedValue(undefined)
rmMock.mockResolvedValue(undefined)
openMock.mockResolvedValue({
read: vi.fn(async (buffer: Buffer) => {
buffer.fill(0x61)
return { bytesRead: buffer.length, buffer }
}),
write: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
close: vi.fn()
})
openMock.mockResolvedValue(localFileHandleMock(Buffer.from('a'.repeat(10))))
lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
}
+22 -30
View File
@@ -18,7 +18,8 @@ import {
getSshFilesystemProviderMock,
tryDeleteWslUncPathMock,
recordCrashBreadcrumbMock,
resetFilesystemIpcMocks
resetFilesystemIpcMocks,
localFileHandleMock
} from './filesystem-test-harness'
vi.mock('electron', async () => (await import('./filesystem-test-harness')).electronMock)
@@ -83,6 +84,13 @@ describe('registerFilesystemHandlers', () => {
invalidateAuthorizedRootsCache()
})
it('registers no channel that remembers a path grant', () => {
registerFilesystemHandlers(store as never)
expect(handlers.has('fs:readFile')).toBe(true)
expect(handlers.has('fs:authorizeExternalPath')).toBe(false)
})
it('re-sorts SSH provider listings directories-first in natural order', async () => {
// Why: the remote relay may be an older build that still sorts lexicographically.
getSshFilesystemProviderMock.mockReturnValueOnce({
@@ -361,8 +369,7 @@ describe('registerFilesystemHandlers', () => {
}
])('returns base64 content for supported $ext binaries', async ({ ext, mime, data }) => {
const buf = Buffer.from(data)
statMock.mockResolvedValue({ size: buf.length, isDirectory: () => false, mtimeMs: 123 })
readFileMock.mockResolvedValue(buf)
openMock.mockResolvedValue(localFileHandleMock(buf))
registerFilesystemHandlers(store as never)
await expect(
handlers.get('fs:readFile')!(null, { filePath: path.resolve(`/workspace/repo/file.${ext}`) })
@@ -376,8 +383,7 @@ describe('registerFilesystemHandlers', () => {
it('opens text files larger than the old 5MB guard', async () => {
const content = 'a'.repeat(6 * 1024 * 1024)
statMock.mockResolvedValue({ size: content.length, isDirectory: () => false, mtimeMs: 123 })
readFileMock.mockResolvedValue(Buffer.from(content))
openMock.mockResolvedValue(localFileHandleMock(Buffer.from(content)))
registerFilesystemHandlers(store as never)
@@ -391,17 +397,8 @@ describe('registerFilesystemHandlers', () => {
it('returns stable byte metadata only for opted-in local log snapshots', async () => {
const content = Buffer.from('first\npartial')
const close = vi.fn()
openMock.mockResolvedValue({
stat: vi.fn().mockResolvedValue({
size: content.byteLength,
dev: 1,
ino: 2,
birthtimeMs: 3
}),
readFile: vi.fn().mockResolvedValue(content),
close
})
const handle = localFileHandleMock(content)
openMock.mockResolvedValue(handle)
registerFilesystemHandlers(store as never)
await expect(
@@ -414,12 +411,13 @@ describe('registerFilesystemHandlers', () => {
isBinary: false,
fileIdentity: '1:2:3'
})
expect(close).toHaveBeenCalledTimes(1)
expect(handle.close).toHaveBeenCalledTimes(1)
expect(readFileMock).not.toHaveBeenCalled()
})
it('rejects text files beyond the editor read budget', async () => {
statMock.mockResolvedValue({ size: 51 * 1024 * 1024, isDirectory: () => false, mtimeMs: 123 })
const handle = localFileHandleMock(Buffer.alloc(0), { size: 51 * 1024 * 1024 })
openMock.mockResolvedValue(handle)
registerFilesystemHandlers(store as never)
@@ -427,18 +425,13 @@ describe('registerFilesystemHandlers', () => {
handlers.get('fs:readFile')!(null, { filePath: path.resolve('/workspace/repo/huge.json') })
).rejects.toThrow('exceeds 50MB limit')
expect(readFileMock).not.toHaveBeenCalled()
expect(handle.read).not.toHaveBeenCalled()
expect(handle.close).toHaveBeenCalled()
})
it('probes large unknown binaries without reading the full file', async () => {
statMock.mockResolvedValue({ size: 6 * 1024 * 1024, isDirectory: () => false, mtimeMs: 123 })
openMock.mockResolvedValue({
read: vi.fn(async (buffer: Buffer) => {
buffer[0] = 0x00
return { bytesRead: 1, buffer }
}),
close: vi.fn()
})
const handle = localFileHandleMock(Buffer.alloc(6 * 1024 * 1024))
openMock.mockResolvedValue(handle)
registerFilesystemHandlers(store as never)
@@ -449,7 +442,7 @@ describe('registerFilesystemHandlers', () => {
isBinary: true
})
expect(readFileMock).not.toHaveBeenCalled()
expect(handle.read).toHaveBeenCalledTimes(1)
})
it('moves files to trash', async () => {
@@ -519,8 +512,7 @@ describe('registerFilesystemHandlers', () => {
})
it('keeps non-image binaries hidden from the editor payload', async () => {
statMock.mockResolvedValue({ size: 4, isDirectory: () => false, mtimeMs: 123 })
readFileMock.mockResolvedValue(Buffer.from([0x00, 0x01, 0x02]))
openMock.mockResolvedValue(localFileHandleMock(Buffer.from([0x00, 0x01, 0x02])))
registerFilesystemHandlers(store as never)
@@ -0,0 +1,42 @@
import { ipcMain } from 'electron'
import { readLocalFileRange } from './local-file-range-read'
import { validateFileRangeRequest } from '../../../shared/file-range-read'
import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
import { readSshFileExplorerChunk } from '../../runtime/ssh-file-explorer-chunk-read'
import { resolveLocalFileRequestPath } from '../local-file-access-resolution'
import type { LocalFileAccess } from '../../../shared/local-file-access'
import type { FilesystemHandlerContext } from './filesystem-handler-context'
export function registerFilesystemChunkReadHandler({ store }: FilesystemHandlerContext): void {
ipcMain.handle(
'fs:readFileChunk',
async (
_event,
args: {
filePath: string
connectionId?: string
access?: LocalFileAccess
offset: number
length: number
}
) => {
validateFileRangeRequest(args.offset, args.length)
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
const stats = await provider.stat(args.filePath)
if (stats.type === 'directory') {
throw new Error('Cannot read a directory')
}
return readSshFileExplorerChunk(
provider,
args.filePath,
stats.size,
args.offset,
args.length
)
}
const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store)
return readLocalFileRange(filePath, args.offset, args.length)
}
)
}
@@ -1,6 +1,11 @@
import { open } from 'node:fs/promises'
import type { FileHandle } from 'node:fs/promises'
import { extname } from 'node:path'
import { localLogFileIdentity } from '../../ai-vault/local-log-tail-reader'
import {
fileTooLargeError,
openLocalRegularFile,
readLocalFileBounded,
readLocalFilePrefix
} from './local-regular-file-read'
// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files.
export const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB
@@ -9,6 +14,7 @@ export const BINARY_PROBE_BYTES = 8192
export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB
export const PREVIEWABLE_BINARY_MIME_TYPES: Record<string, string> = {
'.png': 'image/png',
'.avif': 'image/avif',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
@@ -19,25 +25,57 @@ export const PREVIEWABLE_BINARY_MIME_TYPES: Record<string, string> = {
'.pdf': 'application/pdf'
}
export async function readLocalLogSnapshot(filePath: string): Promise<{
export type LocalFileContent = {
content: string
isBinary: boolean
isImage?: boolean
mimeType?: string
fileIdentity?: string
}> {
const handle = await open(filePath, 'r')
}
/** One open, one handle: the size check, binary probe and read all see the same regular file. */
export async function readLocalFileContent(filePath: string): Promise<LocalFileContent> {
const { handle, stats } = await openLocalRegularFile(filePath)
try {
const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()]
const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE
if (stats.size > sizeLimit) {
throw fileTooLargeError(stats.size, sizeLimit)
}
if (mimeType) {
const buffer = await readLocalFileBounded(handle, sizeLimit, stats.size)
return {
content: buffer.toString('base64'),
isBinary: true,
// Why: the renderer keys previewable-binary rendering off `isImage`, so set it for PDFs too to stay compatible.
isImage: true,
mimeType
}
}
// Why: probe large unknown files first so archives aren't fully buffered only to discover they aren't editable text.
if (
stats.size > BINARY_PROBE_BYTES &&
isBinaryBuffer(await readLocalFilePrefix(handle, BINARY_PROBE_BYTES))
) {
return { content: '', isBinary: true }
}
const buffer = await readLocalFileBounded(handle, sizeLimit, stats.size)
if (isBinaryBuffer(buffer)) {
return { content: '', isBinary: true }
}
return { content: buffer.toString('utf-8'), isBinary: false }
} finally {
await handle.close()
}
}
export async function readLocalLogSnapshot(filePath: string): Promise<LocalFileContent> {
const { handle, stats } = await openLocalRegularFile(filePath)
try {
const stats = await handle.stat()
if (stats.size > MAX_TEXT_FILE_SIZE) {
throw new Error(
`File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit`
)
}
const buffer = await handle.readFile()
if (buffer.byteLength > MAX_TEXT_FILE_SIZE) {
throw new Error(
`File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit`
)
throw fileTooLargeError(stats.size, MAX_TEXT_FILE_SIZE)
}
const buffer = await readLocalFileBounded(handle, MAX_TEXT_FILE_SIZE, stats.size)
if (isBinaryBuffer(buffer)) {
return { content: '', isBinary: true }
}
@@ -62,17 +100,6 @@ export function isBinaryBuffer(buffer: Buffer): boolean {
return false
}
export async function isBinaryFilePrefix(filePath: string): Promise<boolean> {
const handle: FileHandle = await open(filePath, 'r')
try {
const probe = Buffer.alloc(BINARY_PROBE_BYTES)
const { bytesRead } = await handle.read(probe, 0, probe.length, 0)
return isBinaryBuffer(probe.subarray(0, bytesRead))
} finally {
await handle.close()
}
}
export function isDirectoryEntry(entry: {
isDirectory(): boolean
isSymbolicLink(): boolean
@@ -4,31 +4,32 @@ import {
type PathExistenceResult
} from '../../../shared/path-existence-batch'
import { ipcMain } from 'electron'
import { readdir, readFile, stat } from 'node:fs/promises'
import { extname } from 'node:path'
import { readdir, stat } from 'node:fs/promises'
import type { DirEntry, MarkdownDocument } from '../../../shared/filesystem-entry-types'
import { sortDirEntries } from '../../../shared/file-name-sort'
import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache'
import { resolveAuthorizedPath } from '../filesystem-auth'
import type { LocalFileAccess } from '../../../shared/local-file-access'
import {
resolveDesktopAuthorizedPath,
resolveLocalFileRequestPath
} from '../local-file-access-resolution'
import { isENOENT } from '../filesystem-path-containment'
import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from '../markdown-documents'
import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options'
import { recordCrashBreadcrumb } from '../../crash-reporting/crash-breadcrumb-store'
import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from '../readdir-error-diagnostics'
import type { FilesystemHandlerContext } from './filesystem-handler-context'
import { registerFilesystemChunkReadHandler } from './filesystem-chunk-read-handler'
import {
BINARY_PROBE_BYTES,
isBinaryBuffer,
isBinaryFilePrefix,
isDirectoryEntry,
MAX_PREVIEWABLE_BINARY_SIZE,
MAX_TEXT_FILE_SIZE,
PREVIEWABLE_BINARY_MIME_TYPES,
readLocalLogSnapshot
readLocalFileContent,
readLocalLogSnapshot,
type LocalFileContent
} from './filesystem-file-content-inspection'
export function registerFilesystemReadHandlers(context: FilesystemHandlerContext): void {
registerFilesystemChunkReadHandler(context)
const { store } = context
ipcMain.handle(
@@ -43,7 +44,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext
// Why: re-sort locally — the remote relay may be an older build with lexicographic ordering.
return sortDirEntries(await provider.readDir(args.dirPath))
}
const dirPath = await resolveAuthorizedPath(args.dirPath, store)
const dirPath = await resolveDesktopAuthorizedPath(args.dirPath, store)
throwSite = 'readdir'
const entries = await readdir(dirPath, { withFileTypes: true })
const mapped = entries.map((entry) => ({
@@ -71,52 +72,21 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext
'fs:readFile',
async (
_event,
args: { filePath: string; connectionId?: string; includeLocalLogMetadata?: boolean }
): Promise<{
content: string
isBinary: boolean
isImage?: boolean
mimeType?: string
fileIdentity?: string
}> => {
args: {
filePath: string
connectionId?: string
includeLocalLogMetadata?: boolean
access?: LocalFileAccess
}
): Promise<LocalFileContent> => {
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.readFile(args.filePath)
}
const filePath = await resolveAuthorizedPath(args.filePath, store)
if (args.includeLocalLogMetadata === true) {
return readLocalLogSnapshot(filePath)
}
const stats = await stat(filePath)
const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()]
const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE
if (stats.size > sizeLimit) {
throw new Error(
`File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit`
)
}
if (mimeType) {
const buffer = await readFile(filePath)
return {
content: buffer.toString('base64'),
isBinary: true,
// Why: the renderer keys previewable-binary rendering off `isImage`, so set it for PDFs too to stay compatible.
isImage: true,
mimeType
}
}
// Why: probe large unknown files first so archives aren't fully buffered only to discover they aren't editable text.
if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) {
return { content: '', isBinary: true }
}
const buffer = await readFile(filePath)
if (isBinaryBuffer(buffer)) {
return { content: '', isBinary: true }
}
return { content: buffer.toString('utf-8'), isBinary: false }
const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store)
return args.includeLocalLogMetadata === true
? readLocalLogSnapshot(filePath)
: readLocalFileContent(filePath)
}
)
@@ -143,14 +113,14 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext
'fs:stat',
async (
_event,
args: { filePath: string; connectionId?: string }
args: { filePath: string; connectionId?: string; access?: LocalFileAccess }
): Promise<{ size: number; isDirectory: boolean; mtime: number }> => {
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
const result = await provider.stat(args.filePath)
return { size: result.size, isDirectory: result.type === 'directory', mtime: result.mtime }
}
const filePath = await resolveAuthorizedPath(args.filePath, store)
const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store)
const stats = await stat(filePath)
return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs }
}
@@ -173,7 +143,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext
try {
await (provider
? provider.stat(filePath)
: stat(await resolveAuthorizedPath(filePath, store)))
: stat(await resolveDesktopAuthorizedPath(filePath, store)))
return true
} catch (error) {
if (isENOENT(error)) {
@@ -189,14 +159,17 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext
ipcMain.handle(
'fs:pathExists',
async (_event, args: { filePath: string; connectionId?: string }): Promise<boolean> => {
async (
_event,
args: { filePath: string; connectionId?: string; access?: LocalFileAccess }
): Promise<boolean> => {
try {
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
await provider.stat(args.filePath)
return true
}
const filePath = await resolveAuthorizedPath(args.filePath, store)
const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store)
await stat(filePath)
return true
} catch (error) {
@@ -26,7 +26,7 @@ import {
getSshFilesystemProvider,
requireSshFilesystemProvider
} from '../../providers/ssh-filesystem-dispatch'
import { resolveAuthorizedPath } from '../filesystem-auth'
import { resolveDesktopAuthorizedPath } from '../local-file-access-resolution'
import { listQuickOpenFiles } from '../filesystem-list-files'
import {
isFileNameFilterQueryTooLarge,
@@ -52,7 +52,7 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.search(args)
}
const rootPath = await resolveAuthorizedPath(args.rootPath, store)
const rootPath = await resolveDesktopAuthorizedPath(args.rootPath, store)
const localGitOptions = getLocalGitOptionsForRegisteredWorktree(
store,
args.rootPath,
@@ -4,10 +4,15 @@ import type { SshMutationExpectation } from '../../../shared/ssh-types'
import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generation'
import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch'
import { tryDeleteWslUncPath } from '../../wsl-unc-delete'
import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth'
import type { LocalFileAccess } from '../../../shared/local-file-access'
import {
resolveDesktopAuthorizedPath,
resolveLocalWriteRequestPath
} from '../local-file-access-resolution'
import { isENOENT } from '../filesystem-path-containment'
import { registerFilesystemMutationHandlers } from '../filesystem-mutations'
import type { FilesystemHandlerContext } from './filesystem-handler-context'
import { assertLocalWriteTargetIsRegularFile } from './local-regular-file-read'
export function registerFilesystemWriteHandlers(context: FilesystemHandlerContext): void {
const { store } = context
@@ -16,7 +21,12 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
'fs:writeFile',
async (
_event,
args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation
args: {
filePath: string
content: string
connectionId?: string
access?: LocalFileAccess
} & SshMutationExpectation
): Promise<void> => {
assertSshMutationExpectation(
args.connectionId,
@@ -28,7 +38,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.writeFile(args.filePath, args.content)
}
const filePath = await resolveAuthorizedPath(args.filePath, store)
const filePath = await resolveLocalWriteRequestPath(args.filePath, args.access, store)
try {
const fileStats = await lstat(filePath)
if (fileStats.isDirectory()) {
@@ -39,6 +49,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
throw error
}
}
await assertLocalWriteTargetIsRegularFile(filePath)
await writeFile(filePath, args.content, 'utf-8')
}
)
@@ -64,7 +75,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
return provider.deletePath(args.targetPath, args.recursive)
}
// Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots).
const targetPath = await resolveAuthorizedPath(args.targetPath, store, {
const targetPath = await resolveDesktopAuthorizedPath(args.targetPath, store, {
preserveSymlink: true
})
// Why: WSL UNC targets have no Recycle Bin (shell.trashItem throws), so hard-delete via `rm` inside the distro (issue #6415).
@@ -84,8 +95,4 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex
)
registerFilesystemMutationHandlers(store)
ipcMain.handle('fs:authorizeExternalPath', (_event, args: { targetPath: string }): void => {
authorizeExternalPath(args.targetPath)
})
}
@@ -0,0 +1,50 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { readLocalFileRange } from './local-file-range-read'
let folder: string
beforeEach(async () => {
folder = await mkdtemp(join(tmpdir(), 'orca-file-range-'))
})
afterEach(async () => {
await rm(folder, { recursive: true, force: true })
})
it('reads only the requested bytes and reports partial and past-EOF ranges', async () => {
const file = join(folder, 'records.csv')
await writeFile(file, '0123456789')
await expect(readLocalFileRange(file, 3, 4)).resolves.toEqual({
contentBase64: Buffer.from('3456').toString('base64'),
bytesRead: 4,
eof: false
})
await expect(readLocalFileRange(file, 8, 4)).resolves.toEqual({
contentBase64: Buffer.from('89').toString('base64'),
bytesRead: 2,
eof: true
})
await expect(readLocalFileRange(file, 12, 4)).resolves.toEqual({
contentBase64: '',
bytesRead: 0,
eof: true
})
})
it('refuses directories through the shared regular-file guard', async () => {
await expect(readLocalFileRange(folder, 0, 4)).rejects.toThrow()
})
it('rejects invalid or excessive ranges before opening the path', async () => {
for (const [offset, length] of [
[-1, 1],
[0, 0],
[0, 512 * 1024 + 1],
[Number.MAX_SAFE_INTEGER, 2]
]) {
await expect(readLocalFileRange(join(folder, 'missing.csv'), offset, length)).rejects.toThrow(
'Invalid file read range'
)
}
})
@@ -0,0 +1,31 @@
import type { RuntimeFileReadChunkResult } from '../../../shared/runtime-types'
import { openLocalRegularFile } from './local-regular-file-read'
export async function readLocalFileRange(
filePath: string,
offset: number,
length: number
): Promise<RuntimeFileReadChunkResult> {
if (
!Number.isSafeInteger(offset) ||
offset < 0 ||
!Number.isSafeInteger(length) ||
length < 1 ||
length > 512 * 1024 ||
offset > Number.MAX_SAFE_INTEGER - (length - 1)
) {
throw new Error('Invalid file read range')
}
const { handle, stats } = await openLocalRegularFile(filePath)
try {
const buffer = Buffer.alloc(Math.min(length, Math.max(0, stats.size - offset)))
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset)
return {
contentBase64: buffer.subarray(0, bytesRead).toString('base64'),
bytesRead,
eof: offset + bytesRead >= stats.size
}
} finally {
await handle.close()
}
}
@@ -0,0 +1,106 @@
import { mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readLocalFileContent } from './filesystem-file-content-inspection'
import {
assertLocalWriteTargetIsRegularFile,
NOT_A_REGULAR_FILE_MESSAGE,
openLocalRegularFile,
readLocalFileBounded
} from './local-regular-file-read'
let base: string
beforeEach(async () => {
base = await mkdtemp(join(await realpath(tmpdir()), 'orca-regular-file-read-'))
})
afterEach(async () => {
await rm(base, { recursive: true, force: true })
})
describe('local regular-file reads', () => {
it('reads a regular file', async () => {
const filePath = join(base, 'notes.txt')
await writeFile(filePath, 'hello\n')
await expect(readLocalFileContent(filePath)).resolves.toEqual({
content: 'hello\n',
isBinary: false
})
})
it('refuses a directory', async () => {
await expect(readLocalFileContent(base)).rejects.toThrow()
})
it('caps a read at the limit even when the file is bigger than its handle claimed', async () => {
const filePath = join(base, 'big.txt')
await writeFile(filePath, 'a'.repeat(2048))
const { handle } = await openLocalRegularFile(filePath)
try {
await expect(readLocalFileBounded(handle, 1024)).rejects.toThrow('File too large')
} finally {
await handle.close()
}
})
it('sizes the read from fstat, so a small file costs a small buffer', async () => {
const filePath = join(base, 'small.txt')
await writeFile(filePath, 'hello')
const { handle, stats } = await openLocalRegularFile(filePath)
const read = vi.spyOn(handle, 'read')
try {
await expect(readLocalFileBounded(handle, 1024 * 1024, stats.size)).resolves.toEqual(
Buffer.from('hello')
)
// One read sized past the reported length, then a 1-byte probe confirming EOF.
expect(read.mock.calls.map((call) => call.at(2))).toEqual([6, 1])
} finally {
await handle.close()
}
})
it('keeps reading a file that grew past its reported size, up to the cap', async () => {
const filePath = join(base, 'grew.txt')
await writeFile(filePath, 'a'.repeat(5000))
const { handle } = await openLocalRegularFile(filePath)
try {
await expect(readLocalFileBounded(handle, 1024 * 1024, 10)).resolves.toHaveLength(5000)
await expect(readLocalFileBounded(handle, 4096, 10)).rejects.toThrow('File too large')
} finally {
await handle.close()
}
})
// Why: device files are POSIX-only.
describe.skipIf(process.platform === 'win32')('non-regular files', () => {
it.each(['/dev/zero', '/dev/urandom'])(
'refuses %s before reading any of it',
async (device) => {
await expect(readLocalFileContent(device)).rejects.toThrow(NOT_A_REGULAR_FILE_MESSAGE)
}
)
it('refuses a symlink to a device', async () => {
const link = join(base, 'zero.png')
await symlink('/dev/zero', link)
await expect(readLocalFileContent(link)).rejects.toThrow(NOT_A_REGULAR_FILE_MESSAGE)
})
it('refuses writing over a device but allows a regular or missing file', async () => {
const filePath = join(base, 'notes.txt')
await writeFile(filePath, 'x')
await expect(assertLocalWriteTargetIsRegularFile('/dev/null')).rejects.toThrow(
NOT_A_REGULAR_FILE_MESSAGE
)
await expect(assertLocalWriteTargetIsRegularFile(filePath)).resolves.toBeUndefined()
await expect(
assertLocalWriteTargetIsRegularFile(join(base, 'missing.txt'))
).resolves.toBeUndefined()
})
})
})
@@ -0,0 +1,89 @@
import { constants, type Stats } from 'node:fs'
import { open, type FileHandle } from 'node:fs/promises'
import { isENOENT } from '../filesystem-path-containment'
export const NOT_A_REGULAR_FILE_MESSAGE = 'Not a regular file'
// Why O_NONBLOCK: opening a FIFO would otherwise wait for a writer forever; regular files ignore
// the flag and Windows has none.
export const LOCAL_READ_OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0)
const LOCAL_WRITE_PROBE_FLAGS = constants.O_WRONLY | (constants.O_NONBLOCK ?? 0)
const READ_CHUNK_BYTES = 1024 * 1024
export function fileTooLargeError(size: number, limit: number): Error {
return new Error(
`File too large: ${(size / 1024 / 1024).toFixed(1)}MB exceeds ${limit / 1024 / 1024}MB limit`
)
}
/** Opens a path for reading, refusing anything but a regular file (FIFO, device, socket, directory). */
export async function openLocalRegularFile(
filePath: string
): Promise<{ handle: FileHandle; stats: Stats }> {
const handle = await open(filePath, LOCAL_READ_OPEN_FLAGS)
try {
const stats = await handle.stat()
if (!stats.isFile()) {
throw new Error(NOT_A_REGULAR_FILE_MESSAGE)
}
return { handle, stats }
} catch (error) {
await handle.close()
throw error
}
}
/**
* Reads from the start of the handle, sized from its fstat so a small file costs a small buffer.
* The size is only a hint: a file that grows past it is read on in bounded chunks, and the cap holds
* even when a file reports a false size.
*/
export async function readLocalFileBounded(
handle: FileHandle,
limit: number,
expectedSize = 0
): Promise<Buffer> {
const chunks: Buffer[] = []
let total = 0
let nextChunkBytes = Math.max(0, Math.min(expectedSize, limit)) + 1
while (true) {
const chunk = Buffer.allocUnsafe(Math.min(nextChunkBytes, limit + 1 - total))
const { bytesRead } = await handle.read(chunk, 0, chunk.length, total)
if (bytesRead === 0) {
return chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, total)
}
chunks.push(chunk.subarray(0, bytesRead))
total += bytesRead
if (total > limit) {
throw fileTooLargeError(total, limit)
}
// Why a 1-byte probe after a short read: it confirms EOF without another full-size buffer.
nextChunkBytes = bytesRead < chunk.length ? 1 : READ_CHUNK_BYTES
}
}
export async function readLocalFilePrefix(handle: FileHandle, bytes: number): Promise<Buffer> {
const probe = Buffer.alloc(bytes)
const { bytesRead } = await handle.read(probe, 0, probe.length, 0)
return probe.subarray(0, bytesRead)
}
/** Refuses writing over an existing non-regular file, e.g. a device or FIFO. */
export async function assertLocalWriteTargetIsRegularFile(filePath: string): Promise<void> {
let handle: FileHandle
try {
handle = await open(filePath, LOCAL_WRITE_PROBE_FLAGS)
} catch (error) {
if (isENOENT(error)) {
return
}
throw error
}
try {
if (!(await handle.stat()).isFile()) {
throw new Error(NOT_A_REGULAR_FILE_MESSAGE)
}
} finally {
await handle.close()
}
}
@@ -4,9 +4,8 @@ import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../shared/global-settings-types'
const { appGetPathMock, authorizeExternalPathMock } = vi.hoisted(() => ({
appGetPathMock: vi.fn(),
authorizeExternalPathMock: vi.fn()
const { appGetPathMock } = vi.hoisted(() => ({
appGetPathMock: vi.fn()
}))
vi.mock('electron', () => ({
@@ -15,13 +14,9 @@ vi.mock('electron', () => ({
}
}))
vi.mock('./filesystem-auth', () => ({
authorizeExternalPath: authorizeExternalPathMock
}))
import {
ensureDefaultFloatingWorkspacePath,
grantFloatingWorkspaceDirectory,
trustFloatingWorkspaceDirectory,
resolveFloatingTerminalCwd,
sanitizeFloatingWorkspaceDirectorySetting
} from './floating-workspace-directory'
@@ -48,7 +43,7 @@ function createStore(settings: Partial<GlobalSettings> = {}): TestStore {
return store
}
describe('floating workspace directory authorization', () => {
describe('floating workspace directory', () => {
let tempRoot: string
let homeDir: string
let userDataDir: string
@@ -67,7 +62,6 @@ describe('floating workspace directory authorization', () => {
}
throw new Error(`unexpected app path: ${name}`)
})
authorizeExternalPathMock.mockClear()
})
afterEach(async () => {
@@ -78,46 +72,37 @@ describe('floating workspace directory authorization', () => {
await symlink(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
}
it('defaults terminal cwd to home without authorizing home for markdown writes', async () => {
it('defaults terminal cwd to home', async () => {
const store = createStore()
await expect(resolveFloatingTerminalCwd(store as never)).resolves.toBe(homeDir)
expect(authorizeExternalPathMock).not.toHaveBeenCalledWith(homeDir)
})
it('keeps the app-owned directory for floating markdown notes', async () => {
await expect(ensureDefaultFloatingWorkspacePath()).resolves.toBe(
path.join(userDataDir, 'floating-workspace')
)
expect(authorizeExternalPathMock).toHaveBeenCalledWith(
path.join(userDataDir, 'floating-workspace')
)
})
it('persists picker-approved directories and reauthorizes them on resolution', async () => {
it('persists picker-approved directories and resolves them as the cwd', async () => {
const store = createStore()
const selectedDir = path.join(tempRoot, 'notes')
await mkdir(selectedDir)
const canonicalSelectedDir = await realpath(selectedDir)
await grantFloatingWorkspaceDirectory(store as never, selectedDir)
await trustFloatingWorkspaceDirectory(store, selectedDir)
expect(store.settings.floatingTerminalTrustedCwds).toEqual([canonicalSelectedDir])
expect(authorizeExternalPathMock).toHaveBeenCalledWith(canonicalSelectedDir)
authorizeExternalPathMock.mockClear()
await expect(
resolveFloatingTerminalCwd(store as never, {
path: selectedDir,
requireTrusted: true
})
).resolves.toBe(canonicalSelectedDir)
expect(authorizeExternalPathMock).toHaveBeenCalledWith(canonicalSelectedDir)
})
it('stores symlink grants as canonical targets and rejects the link after retargeting', async () => {
it('stores a symlinked choice as its canonical target and rejects the link after retargeting', async () => {
const store = createStore()
const originalTarget = path.join(tempRoot, 'original-target')
const retargetedTarget = path.join(tempRoot, 'retargeted-target')
@@ -127,16 +112,12 @@ describe('floating workspace directory authorization', () => {
await symlinkDirectory(originalTarget, selectedLink)
const canonicalOriginalTarget = await realpath(originalTarget)
await grantFloatingWorkspaceDirectory(store as never, selectedLink)
await trustFloatingWorkspaceDirectory(store, selectedLink)
expect(store.settings.floatingTerminalTrustedCwds).toEqual([canonicalOriginalTarget])
expect(authorizeExternalPathMock).toHaveBeenCalledWith(canonicalOriginalTarget)
await unlink(selectedLink)
await symlinkDirectory(retargetedTarget, selectedLink)
const canonicalRetargetedTarget = await realpath(retargetedTarget)
authorizeExternalPathMock.mockClear()
await expect(
resolveFloatingTerminalCwd(store as never, {
path: selectedLink,
@@ -146,10 +127,9 @@ describe('floating workspace directory authorization', () => {
await expect(
sanitizeFloatingWorkspaceDirectorySetting(store as never, selectedLink)
).resolves.toBe('')
expect(authorizeExternalPathMock).not.toHaveBeenCalledWith(canonicalRetargetedTarget)
})
it('keeps temporarily inaccessible trusted directories when adding a new grant', async () => {
it('keeps temporarily inaccessible trusted directories when adding a new one', async () => {
const missingTrustedDir = path.join(tempRoot, 'offline-drive', 'notes')
const selectedDir = path.join(tempRoot, 'new-notes')
await mkdir(selectedDir)
@@ -158,7 +138,7 @@ describe('floating workspace directory authorization', () => {
floatingTerminalTrustedCwds: [missingTrustedDir]
})
await grantFloatingWorkspaceDirectory(store as never, selectedDir)
await trustFloatingWorkspaceDirectory(store, selectedDir)
expect(store.settings.floatingTerminalTrustedCwds).toEqual([
missingTrustedDir,
@@ -200,6 +180,5 @@ describe('floating workspace directory authorization', () => {
await expect(resolveFloatingTerminalCwd(store as never, { path: arbitraryDir })).resolves.toBe(
arbitraryDir
)
expect(authorizeExternalPathMock).not.toHaveBeenCalledWith(arbitraryDir)
})
})
+9 -11
View File
@@ -5,7 +5,6 @@ import { app } from 'electron'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types'
import type { Store } from '../persistence'
import { authorizeExternalPath } from './filesystem-auth'
const FLOATING_WORKSPACE_DIRNAME = 'floating-workspace'
@@ -68,12 +67,14 @@ function isTrustedFloatingWorkspaceDirectory(
return getTrustedFloatingWorkspaceDirectories(settings).has(path.resolve(canonicalDirPath))
}
/** The app-owned folder floating markdown documents are created in; a desktop-only root. */
export function getDefaultFloatingWorkspacePath(): string {
return path.join(app.getPath('userData'), FLOATING_WORKSPACE_DIRNAME)
}
export async function ensureDefaultFloatingWorkspacePath(): Promise<string> {
const cwd = path.join(app.getPath('userData'), FLOATING_WORKSPACE_DIRNAME)
const cwd = getDefaultFloatingWorkspacePath()
await mkdir(cwd, { recursive: true })
// Why: the default floating workspace lives outside repo roots by design;
// authorize only this app-owned directory instead of widening access to ~.
authorizeExternalPath(cwd)
return cwd
}
@@ -94,18 +95,16 @@ export async function resolveFloatingTerminalCwd(
return ensureDefaultFloatingWorkspacePath()
}
// Why: only picker-approved directories may become the cwd, so arbitrary settings text can't.
if (isTrustedFloatingWorkspaceDirectory(canonicalCwd, store.getSettings())) {
// Why: picker-approved directories are persisted as explicit grants, so a
// restart can restore file creation access without trusting arbitrary text.
authorizeExternalPath(canonicalCwd)
return canonicalCwd
}
return args?.requireTrusted === true ? ensureDefaultFloatingWorkspacePath() : cwd
}
export async function grantFloatingWorkspaceDirectory(
store: Store,
export async function trustFloatingWorkspaceDirectory(
store: Pick<Store, 'getSettings' | 'updateSettings'>,
dirPath: string
): Promise<void> {
const resolvedDir = resolveFloatingWorkspaceInput(dirPath)
@@ -113,7 +112,6 @@ export async function grantFloatingWorkspaceDirectory(
if (!canonicalDir) {
return
}
authorizeExternalPath(canonicalDir)
const trustedDirectories = await getPreservedTrustedFloatingWorkspaceDirectories(
store.getSettings()
)
+44
View File
@@ -0,0 +1,44 @@
import { ipcMain } from 'electron'
import { listAssignableUsers, listAssignableUsersForProject, searchUsers } from '../jira/issues'
function normalizeSiteId(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
/** Registers the `jira:*` user-search IPC handlers (assignable and site-wide). */
export function registerJiraUserSearchHandlers(): void {
ipcMain.handle(
'jira:listAssignableUsers',
async (_event, args: { key: string; query?: string; siteId?: string }) => {
if (typeof args?.key !== 'string' || !args.key.trim()) {
return []
}
return listAssignableUsers(
args.key.trim(),
typeof args.query === 'string' ? args.query : undefined,
normalizeSiteId(args.siteId)
)
}
)
ipcMain.handle(
'jira:listAssignableUsersForProject',
async (_event, args: { projectIdOrKey: string; query?: string; siteId?: string }) => {
if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) {
return []
}
return listAssignableUsersForProject(
args.projectIdOrKey.trim(),
typeof args.query === 'string' ? args.query : undefined,
normalizeSiteId(args.siteId)
)
}
)
ipcMain.handle('jira:searchUsers', async (_event, args?: { query?: string; siteId?: string }) => {
return searchUsers(
typeof args?.query === 'string' ? args.query : undefined,
normalizeSiteId(args?.siteId)
)
})
}
+2 -22
View File
@@ -2,6 +2,7 @@ import { ipcMain } from 'electron'
import { connect, disconnect, getStatus, selectSite, testConnection } from '../jira/client'
import { _resetPreflightCache } from './preflight'
import { JiraCancellableRequests } from './jira-cancellable-requests'
import { registerJiraUserSearchHandlers } from './jira-user-search'
import {
addIssueComment,
createIssue,
@@ -9,7 +10,6 @@ import {
getIssueSummary,
getIssueComments,
getProjectStatusOrder,
listAssignableUsers,
listCreateFields,
listIssueTypes,
listIssues,
@@ -17,7 +17,6 @@ import {
listProjects,
listTransitions,
searchIssues,
searchUsers,
updateIssue
} from '../jira/issues'
import type {
@@ -285,26 +284,7 @@ export function registerJiraHandlers(): void {
return listPriorities(normalizeSiteId(args?.siteId))
})
ipcMain.handle(
'jira:listAssignableUsers',
async (_event, args: { key: string; query?: string; siteId?: string }) => {
if (typeof args?.key !== 'string' || !args.key.trim()) {
return []
}
return listAssignableUsers(
args.key.trim(),
typeof args.query === 'string' ? args.query : undefined,
normalizeSiteId(args.siteId)
)
}
)
ipcMain.handle('jira:searchUsers', async (_event, args?: { query?: string; siteId?: string }) => {
return searchUsers(
typeof args?.query === 'string' ? args.query : undefined,
normalizeSiteId(args?.siteId)
)
})
registerJiraUserSearchHandlers()
ipcMain.handle('jira:listTransitions', async (_event, args: { key: string; siteId?: string }) => {
if (typeof args?.key !== 'string' || !args.key.trim()) {
+10 -24
View File
@@ -1,21 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { KeybindingFileSnapshot } from '../../shared/keybindings'
const {
authorizeExternalPathMock,
getAllWindowsMock,
handleMock,
openPathMock,
rebuildAppMenuMock,
showItemInFolderMock
} = vi.hoisted(() => ({
authorizeExternalPathMock: vi.fn(),
getAllWindowsMock: vi.fn(() => []),
handleMock: vi.fn(),
openPathMock: vi.fn(),
rebuildAppMenuMock: vi.fn(),
showItemInFolderMock: vi.fn()
}))
const { getAllWindowsMock, handleMock, openPathMock, rebuildAppMenuMock, showItemInFolderMock } =
vi.hoisted(() => ({
getAllWindowsMock: vi.fn(() => []),
handleMock: vi.fn(),
openPathMock: vi.fn(),
rebuildAppMenuMock: vi.fn(),
showItemInFolderMock: vi.fn()
}))
vi.mock('electron', () => ({
BrowserWindow: {
@@ -30,10 +23,6 @@ vi.mock('electron', () => ({
}
}))
vi.mock('./filesystem-auth', () => ({
authorizeExternalPath: authorizeExternalPathMock
}))
vi.mock('../menu/register-app-menu', () => ({
rebuildAppMenu: rebuildAppMenuMock
}))
@@ -60,7 +49,6 @@ function getHandler(channel: string): (...args: unknown[]) => unknown {
describe('registerKeybindingHandlers', () => {
beforeEach(() => {
authorizeExternalPathMock.mockReset()
getAllWindowsMock.mockReturnValue([])
handleMock.mockReset()
openPathMock.mockReset()
@@ -68,11 +56,10 @@ describe('registerKeybindingHandlers', () => {
showItemInFolderMock.mockReset()
})
it('authorizes the keybindings file for in-app editing when ensuring it exists', () => {
it('returns the keybindings file when ensuring it exists', () => {
registerKeybindingHandlers({ ensureFile: vi.fn(() => snapshot) } as never)
expect(getHandler('keybindings:ensureFile')()).toBe(snapshot)
expect(authorizeExternalPathMock).toHaveBeenCalledWith(snapshot.path)
})
it('reconciles plugin command conflicts after a shortcut edit', () => {
@@ -92,12 +79,11 @@ describe('registerKeybindingHandlers', () => {
expect(onChanged).toHaveBeenCalledOnce()
})
it('authorizes the keybindings file before opening it outside Orca', async () => {
it('opens the keybindings file outside Orca', async () => {
openPathMock.mockResolvedValue('')
registerKeybindingHandlers({ ensureFile: vi.fn(() => snapshot) } as never)
await expect(getHandler('keybindings:openFile')()).resolves.toBe(snapshot)
expect(authorizeExternalPathMock).toHaveBeenCalledWith(snapshot.path)
expect(openPathMock).toHaveBeenCalledWith(snapshot.path)
})
})
-6
View File
@@ -2,7 +2,6 @@ import { BrowserWindow, ipcMain, shell } from 'electron'
import type { KeybindingActionId, KeybindingFileSnapshot } from '../../shared/keybindings'
import type { KeybindingService } from '../keybindings/keybinding-service'
import { rebuildAppMenu } from '../menu/register-app-menu'
import { authorizeExternalPath } from './filesystem-auth'
function broadcastKeybindingsChanged(snapshot: KeybindingFileSnapshot): void {
for (const window of BrowserWindow.getAllWindows()) {
@@ -21,9 +20,6 @@ export function registerKeybindingHandlers(
ipcMain.handle('keybindings:ensureFile', () => {
const snapshot = service.ensureFile()
// 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)
broadcastKeybindingsChanged(snapshot)
onChanged?.()
return snapshot
@@ -48,7 +44,6 @@ export function registerKeybindingHandlers(
ipcMain.handle('keybindings:openFile', async () => {
const snapshot = service.ensureFile()
authorizeExternalPath(snapshot.path)
const error = await shell.openPath(snapshot.path)
if (error) {
throw new Error(error)
@@ -58,7 +53,6 @@ export function registerKeybindingHandlers(
ipcMain.handle('keybindings:revealFile', () => {
const snapshot = service.ensureFile()
authorizeExternalPath(snapshot.path)
shell.showItemInFolder(snapshot.path)
return snapshot
})
@@ -0,0 +1,382 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFs from 'node:fs'
import type * as NodePath from 'node:path'
import type { Store } from '../persistence'
// Why win32 paths on every host: a UNC image in a document is a Windows credential leak, and the
// guarantee is that its path text is refused before any filesystem call can reach the network.
vi.mock('node:path', async () => {
const actual = await vi.importActual<typeof NodePath>('node:path')
return { ...actual.win32, default: actual.win32 }
})
const { fsCalls } = vi.hoisted(() => {
const calls: string[] = []
return { fsCalls: calls }
})
vi.mock('node:fs/promises', () => {
const record = (name: string) =>
vi.fn(async (target: unknown) => {
fsCalls.push(`${name} ${String(target)}`)
if (name !== 'realpath') {
return { isFile: () => true, isDirectory: () => false }
}
// A project image that is really a link to a device name.
if (String(target).endsWith('share-link.png')) {
// A local image that is really a link onto a network share.
return '\\\\nas\\pics\\shot.png'
}
return String(target).endsWith('dev-link.png') ? 'C:\\repo\\CON.png' : target
})
return { realpath: record('realpath'), stat: record('stat'), open: record('open') }
})
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFs>()
return {
...actual,
statSync: vi.fn((target: unknown) => {
fsCalls.push(`statSync ${String(target)}`)
throw new Error('statSync is not expected')
})
}
})
vi.mock('electron', () => ({ app: { getPath: () => 'C:\\Users\\me\\AppData\\Roaming\\Orca' } }))
vi.mock('../repo-worktrees', () => ({ listRepoWorktreeGraph: vi.fn(async () => []) }))
import {
resolveLocalFileRequestPath,
resolveLocalRenamePaths,
resolveLocalRequestPath
} from './local-file-access-resolution'
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members.
const store = {
getRepos: () => [
{ id: 'repo', path: 'C:\\repo', displayName: 'repo', badgeColor: '#000', addedAt: 0 }
],
getProjects: () => [],
getProjectGroups: () => [],
getFolderWorkspaces: () => [],
getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' })
} as unknown as Store
const besideTodo = { kind: 'document-folder', documentPath: 'C:\\Users\\me\\notes\\todo.md' }
const networkTargets = [
'\\\\attacker.example\\share\\x.png',
'//attacker.example/share/x.png',
'\\\\?\\UNC\\attacker.example\\share\\x.png'
]
describe('document images on a network share', () => {
beforeEach(() => {
fsCalls.length = 0
})
it.each(networkTargets)(
'refuses %s from a project document without touching it',
async (target) => {
await expect(
resolveLocalFileRequestPath(
target,
{ kind: 'document-resource', documentPath: 'C:\\repo\\README.md' },
store
)
).rejects.toThrow('Access denied')
expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([])
}
)
it.each(networkTargets)(
'refuses %s from a document outside every project without touching it',
async (target) => {
await expect(
resolveLocalFileRequestPath(
target,
{ kind: 'document-resource', documentPath: 'C:\\Users\\me\\notes\\todo.md' },
store
)
).rejects.toThrow('Access denied')
expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([])
}
)
it('refuses it with no declared access too', async () => {
await expect(resolveLocalFileRequestPath(networkTargets[0], undefined, store)).rejects.toThrow(
'Access denied'
)
expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([])
})
})
const CHAT_IMAGE = { kind: 'chat-image' } as const
describe('chat transcript images on Windows', () => {
beforeEach(() => {
fsCalls.length = 0
})
it.each([...networkTargets, '\\\\.\\C:\\x.png', '//?/C:/x.png'])(
'refuses %s without touching it',
async (target) => {
await expect(resolveLocalFileRequestPath(target, CHAT_IMAGE, store)).rejects.toThrow(
'Access denied'
)
expect(fsCalls).toEqual([])
}
)
it.each([
'C:\\Users\\me\\Pictures\\shot.png',
'\\\\wsl.localhost\\Ubuntu\\home\\me\\shot.png',
'\\\\wsl$\\Ubuntu\\tmp\\agent.webp'
])('reads the local image %s in place', async (target) => {
await expect(resolveLocalFileRequestPath(target, CHAT_IMAGE, store)).resolves.toBe(target)
})
})
describe('Windows reserved device names in automatic image loads', () => {
beforeEach(() => {
fsCalls.length = 0
})
const deviceTargets = [
'C:\\Users\\me\\notes\\NUL.png',
'C:\\Users\\me\\notes\\com1.jpg',
'C:\\Users\\me\\notes\\Lpt9 .gif',
'C:\\Users\\me\\notes\\aux..png',
'C:\\Users\\me\\notes\\CON.tar.png',
'C:\\Users\\me\\notes\\NUL:.png',
'C:\\Users\\me\\notes\\COM1:.png',
'C:\\Users\\me\\notes\\NUL:stream.png',
'C:\\Users\\me\\notes\\CONIN$.png',
'C:\\Users\\me\\notes\\CONOUT$',
'C:\\Users\\me\\notes\\clock$.jpg',
'C:\\Users\\me\\notes\\COM0.png',
'C:\\Users\\me\\notes\\LPT0.png',
'C:\\Users\\me\\notes\\com¹.png'
]
it.each(deviceTargets)('refuses %s as a chat image without touching it', async (target) => {
await expect(resolveLocalFileRequestPath(target, CHAT_IMAGE, store)).rejects.toThrow(
'Access denied'
)
expect(fsCalls).toEqual([])
})
it.each(deviceTargets)(
'refuses %s from a document beside it without touching it',
async (target) => {
await expect(
resolveLocalFileRequestPath(
target,
{ kind: 'document-resource', documentPath: 'C:\\Users\\me\\notes\\todo.md' },
store
)
).rejects.toThrow('Access denied')
expect(fsCalls).toEqual([])
}
)
it.each(deviceTargets)(
'refuses %s as a new file beside an opened document without touching it',
async (target) => {
await expect(
resolveLocalRequestPath(target, besideTodo, store, 'import-into')
).rejects.toThrow('Access denied')
expect(fsCalls).toEqual([])
}
)
it.each([...networkTargets, 'C:\\Users\\me\\other\\shot.png'])(
'refuses %s outside an opened document folder without touching it',
async (target) => {
await expect(
resolveLocalRequestPath(target, besideTodo, store, 'import-into')
).rejects.toThrow('Access denied')
expect(fsCalls).toEqual([])
}
)
it('still reads an ordinary image whose name only starts like a device', async () => {
await expect(
resolveLocalFileRequestPath('C:\\Users\\me\\notes\\console.png', CHAT_IMAGE, store)
).resolves.toBe('C:\\Users\\me\\notes\\console.png')
})
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members.
const shareProjectStore = {
getRepos: () => [
{
id: 'repo',
path: '\\\\server\\share\\repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
],
getProjects: () => [],
getProjectGroups: () => [],
getFolderWorkspaces: () => [],
getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' })
} as unknown as Store
describe('automatic image loads and dot segments that resolve to a device name', () => {
beforeEach(() => {
fsCalls.length = 0
})
it.each(['C:\\d\\NUL.png\\.', 'C:\\d\\COM1.png\\x\\..', 'C:/d/COM1.jpg/.'])(
'refuses %s for both access kinds without touching it',
async (target) => {
await expect(resolveLocalFileRequestPath(target, CHAT_IMAGE, store)).rejects.toThrow(
'Access denied'
)
await expect(
resolveLocalFileRequestPath(
target,
{ kind: 'document-resource', documentPath: 'C:\\d\\README.md' },
store
)
).rejects.toThrow('Access denied')
expect(fsCalls).toEqual([])
}
)
it('refuses a device name inside a project for automatic loads without touching it', async () => {
await expect(
resolveLocalFileRequestPath('C:\\repo\\NUL.png', CHAT_IMAGE, store)
).rejects.toThrow('Access denied')
expect(fsCalls).toEqual([])
})
it('refuses a project image whose real target is a device name', async () => {
await expect(
resolveLocalFileRequestPath(
'C:\\repo\\dev-link.png',
{ kind: 'document-resource', documentPath: 'C:\\repo\\README.md' },
store
)
).rejects.toThrow('Access denied')
})
})
describe('the default check runs first for every declared kind', () => {
beforeEach(() => {
fsCalls.length = 0
})
it.each([
['user-file', 'read', { kind: 'user-file' }],
[
'document-resource',
'read',
{ kind: 'document-resource', documentPath: 'C:\\repo\\README.md' }
],
['chat-image', 'read', CHAT_IMAGE],
['document-folder', 'import-into', besideTodo]
] as const)(
'never touches a share outside every project while resolving %s %s',
async (_kind, operation, access) => {
for (const target of networkTargets) {
await resolveLocalRequestPath(target, access, store, operation).catch(() => undefined)
}
expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([])
}
)
it('never touches a share while resolving a rename of an opened document', async () => {
for (const target of networkTargets) {
await resolveLocalRenamePaths(besideTodo.documentPath, target, besideTodo, store)
}
expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([])
})
})
const besideShareDocument = {
kind: 'document-resource',
documentPath: '\\\\nas\\notes\\todo.md'
}
const besideAccentedShareDocument = {
kind: 'document-resource',
documentPath: '\\\\n\u00e1s\\notes\\todo.md'
}
describe('automatic image loads on a network share', () => {
beforeEach(() => {
fsCalls.length = 0
})
it('reads a chat or document image inside a project the user added from the share', async () => {
const image = '\\\\server\\share\\repo\\out.png'
await expect(resolveLocalFileRequestPath(image, CHAT_IMAGE, shareProjectStore)).resolves.toBe(
image
)
await expect(
resolveLocalFileRequestPath(
image,
{ kind: 'document-resource', documentPath: '\\\\server\\share\\repo\\README.md' },
shareProjectStore
)
).resolves.toBe(image)
})
it('refuses a local link that leads onto a share outside every project', async () => {
await expect(
resolveLocalFileRequestPath('C:\\Users\\me\\share-link.png', CHAT_IMAGE, store)
).rejects.toThrow('Access denied')
})
it('refuses a chat share image outside every project without touching the share', async () => {
await expect(
resolveLocalFileRequestPath('\\\\server\\share\\other\\x.png', CHAT_IMAGE, shareProjectStore)
).rejects.toThrow('Access denied')
expect(fsCalls.filter((call) => call.includes('other'))).toEqual([])
})
// Why beside a share document too: a host spelled with a look-alike (U+212A KELVIN SIGN, an NFD
// accent) can pass a case-folded folder comparison, so no share is loaded outside a project.
it.each([
'\\\\nas\\notes\\x.png',
'\\\\NAS\\Notes\\img\\y.png',
'//nas/notes/z.png',
'\\\\nas\\other\\x.png',
'\\\\evil\\notes\\x.png',
'\\\\nas\\notes-evil\\x.png',
'\\\\attacker.example\\share\\x.png'
])('refuses %s beside a share document without touching any share', async (target) => {
await expect(
resolveLocalFileRequestPath(target, besideShareDocument, shareProjectStore)
).rejects.toThrow('Access denied')
expect(fsCalls).toEqual([])
})
it.each(['\\\\bac\u212Aup\\notes\\x.png', '//bac\u212Aup/notes/x.png'])(
'refuses the KELVIN SIGN host spelling %s beside a document on \\\\backup without touching it',
async (target) => {
await expect(
resolveLocalFileRequestPath(
target,
{ kind: 'document-resource', documentPath: '\\\\backup\\notes\\todo.md' },
shareProjectStore
)
).rejects.toThrow('Access denied')
expect(fsCalls).toEqual([])
}
)
it('refuses an NFD spelling of an accented share host beside a document on it, without touching it', async () => {
await expect(
resolveLocalFileRequestPath(
'\\\\na\u0301s\\notes\\x.png',
besideAccentedShareDocument,
shareProjectStore
)
).rejects.toThrow('Access denied')
expect(fsCalls).toEqual([])
})
})
@@ -0,0 +1,469 @@
import { mkdir, mkdtemp, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
import type * as RepoWorktrees from '../repo-worktrees'
import { resolveAuthorizedPath } from './filesystem-auth'
import {
resolveDesktopAuthorizedPath,
resolveLocalFileRequestPath,
resolveLocalRenamePaths,
resolveLocalRequestPath,
resolveLocalWriteRequestPath,
type LocalRequestOperation
} from './local-file-access-resolution'
import { readLocalFileContent } from './filesystem/filesystem-file-content-inspection'
import {
assertLocalWriteTargetIsRegularFile,
NOT_A_REGULAR_FILE_MESSAGE
} from './filesystem/local-regular-file-read'
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
const { userData } = vi.hoisted(() => ({ userData: { path: '' } }))
vi.mock('electron', () => ({ app: { getPath: () => userData.path } }))
vi.mock('../repo-worktrees', async () => {
const actual = await vi.importActual<typeof RepoWorktrees>('../repo-worktrees')
return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) }
})
type Registration = { repoPaths?: string[]; folderPath?: string }
function makeStore({ repoPaths = [], folderPath }: Registration): Store {
const repos = repoPaths.map((path, index) => ({
id: `repo-${index}`,
path,
displayName: 'project',
badgeColor: '#000',
addedAt: 0
}))
const folders = folderPath ? [{ id: 'folder', folderPath, projectGroupId: 'none' }] : []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members.
return {
getRepos: () => repos.map((repo) => ({ ...repo })),
getProjects: () => [],
getProjectGroups: () => [],
getFolderWorkspaces: () => folders.map((folder) => ({ ...folder })),
getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' })
} as unknown as Store
}
const USER_FILE = { kind: 'user-file' } as const
const documentResource = (documentPath: string) =>
({ kind: 'document-resource', documentPath }) as const
const documentFolder = (documentPath: string) =>
({ kind: 'document-folder', documentPath }) as const
async function settles(promise: Promise<unknown>): Promise<'ok' | 'denied'> {
return promise.then(
() => 'ok',
() => 'denied'
)
}
let base: string
let project: string
let outside: string
beforeEach(async () => {
invalidateAuthorizedRootsCache()
base = await mkdtemp(join(await realpath(tmpdir()), 'orca-file-access-'))
project = join(base, 'project')
outside = join(base, 'outside')
userData.path = join(base, 'user-data')
await mkdir(project)
await mkdir(outside)
await mkdir(join(userData.path, 'floating-workspace'), { recursive: true })
await writeFile(join(outside, 'notes.txt'), 'outside notes\n')
})
afterEach(async () => {
await rm(base, { recursive: true, force: true })
})
describe('user-file requests', () => {
it('read a regular file outside every project in place', async () => {
const store = makeStore({})
const filePath = await resolveLocalFileRequestPath(join(outside, 'notes.txt'), USER_FILE, store)
await expect(readLocalFileContent(filePath)).resolves.toEqual({
content: 'outside notes\n',
isBinary: false
})
})
it.each(['notes.txt', 'C:notes.txt'])('refuse the non-absolute path %s', async (relative) => {
await expect(resolveLocalFileRequestPath(relative, USER_FILE, makeStore({}))).rejects.toThrow(
'absolute path'
)
})
it('stat a directory but never read one', async () => {
const dirPath = await resolveLocalFileRequestPath(outside, USER_FILE, makeStore({}))
expect((await stat(dirPath)).isDirectory()).toBe(true)
await expect(readLocalFileContent(dirPath)).rejects.toThrow()
})
it('save an outside file, but never onto a device', async () => {
const store = makeStore({})
const filePath = join(outside, 'notes.txt')
await expect(resolveLocalWriteRequestPath(filePath, USER_FILE, store)).resolves.toBe(filePath)
await expect(resolveLocalWriteRequestPath(filePath, undefined, store)).rejects.toThrow(
'Access denied'
)
if (process.platform !== 'win32') {
await expect(assertLocalWriteTargetIsRegularFile('/dev/null')).rejects.toThrow(
NOT_A_REGULAR_FILE_MESSAGE
)
}
})
it.skipIf(process.platform === 'win32')('refuse /dev/zero promptly', async () => {
const filePath = await resolveLocalFileRequestPath('/dev/zero', USER_FILE, makeStore({}))
await expect(readLocalFileContent(filePath)).rejects.toThrow(NOT_A_REGULAR_FILE_MESSAGE)
})
})
describe('a declared kind never refuses what the default project check allows', () => {
const outsideDocument = () => join(outside, 'doc-folder', 'note.md')
const declaredKinds = (): [string, unknown, LocalRequestOperation][] => [
['user-file read', USER_FILE, 'read'],
['user-file write', USER_FILE, 'write'],
['document-resource read', documentResource(outsideDocument()), 'read'],
['chat-image read', { kind: 'chat-image' }, 'read'],
[
'document-folder import-into',
{ kind: 'document-folder', documentPath: outsideDocument() },
'import-into'
]
]
beforeEach(async () => {
await mkdir(join(project, 'src'), { recursive: true })
await mkdir(join(outside, 'doc-folder'), { recursive: true })
await writeFile(join(project, 'src', 'notes.txt'), 'text')
})
it('accepts every in-project path the default accepts, for every kind and operation', async () => {
const store = makeStore({ repoPaths: [project] })
const targets = [
join(project, 'src', 'notes.txt'),
join(project, 'src'),
join(project, 'src', 'new-name.txt'),
join(userData.path, 'floating-workspace', 'scratch.md')
]
for (const [label, access, operation] of declaredKinds()) {
for (const target of targets) {
const byDefault = await resolveLocalRequestPath(target, undefined, store, operation)
await expect(
resolveLocalRequestPath(target, access, store, operation),
`${label} ${target}`
).resolves.toBe(byDefault)
}
}
})
it.skipIf(process.platform === 'win32')(
'accepts an in-project link for every kind exactly as the default does',
async () => {
await symlink(join(project, 'src', 'notes.txt'), join(project, 'notes-link'))
const store = makeStore({ repoPaths: [project] })
for (const [label, access, operation] of declaredKinds()) {
const byDefault = await resolveLocalRequestPath(
join(project, 'notes-link'),
undefined,
store,
operation
)
await expect(
resolveLocalRequestPath(join(project, 'notes-link'), access, store, operation),
label
).resolves.toBe(byDefault)
}
}
)
it('renames an in-project document exactly as the default does', async () => {
const store = makeStore({ repoPaths: [project] })
const source = join(project, 'src', 'notes.txt')
for (const target of [
join(project, 'src', 'new-name.txt'),
join(project, 'new-name.txt'),
join(userData.path, 'floating-workspace', 'scratch.md')
]) {
const byDefault = await resolveLocalRenamePaths(source, target, undefined, store)
await expect(
resolveLocalRenamePaths(source, target, documentFolder(source), store),
target
).resolves.toEqual(byDefault)
}
})
})
describe('requests with no declared access (roots only)', () => {
it('refuse a file outside every project, even one a user-file request may read', async () => {
const store = makeStore({ repoPaths: [project] })
await expect(
resolveLocalFileRequestPath(join(outside, 'notes.txt'), undefined, store)
).rejects.toThrow('Access denied')
await expect(
resolveLocalFileRequestPath(join(outside, 'notes.txt'), { kind: 'grant-everything' }, store)
).rejects.toThrow('Access denied')
})
it('allow the app-owned floating-workspace folder on the desktop only', async () => {
const store = makeStore({ repoPaths: [project] })
const untitled = join(userData.path, 'floating-workspace', 'untitled.md')
await expect(resolveDesktopAuthorizedPath(untitled, store)).resolves.toBe(untitled)
await expect(resolveAuthorizedPath(untitled, store)).rejects.toThrow('Access denied')
})
it('refuse the home folder, which the floating workspace starts in', async () => {
await expect(
resolveDesktopAuthorizedPath(homedir(), makeStore({ repoPaths: [project] }))
).rejects.toThrow('Access denied')
})
})
// Why: creating a symlink on Windows needs elevation or Developer Mode.
describe.skipIf(process.platform === 'win32')('project symlinks under every spelling', () => {
let real: string
let alias: string
let alias2: string
let privateBase: string
let varAlias: string
let secret: string
beforeEach(async () => {
// Mirrors macOS /var -> /private/var: an alias of an ancestor above the project root.
privateBase = join(base, 'private')
varAlias = join(base, 'var')
real = join(privateBase, 'real', 'project')
alias = join(base, 'alias-project')
alias2 = join(base, 'alias2-project')
await mkdir(join(real, 'sub'), { recursive: true })
await symlink(privateBase, varAlias)
await symlink(real, alias)
await symlink(real, alias2)
secret = join(outside, 'secret.txt')
await writeFile(secret, 'secret\n')
await writeFile(join(real, 'notes.md'), 'notes\n')
await symlink(secret, join(real, 'escape.txt'))
await symlink(outside, join(real, 'dir-link'))
await symlink(outside, join(real, 'sub', 'deep-link'))
})
const viaDirLink = (root: string): string => join(root, 'dir-link', 'secret.txt')
it.each<[string, () => Registration, () => string]>([
['a leaf symlink', () => ({ repoPaths: [real] }), () => join(real, 'escape.txt')],
['a directory symlink', () => ({ repoPaths: [real] }), () => viaDirLink(real)],
['a directory symlink via an alias', () => ({ repoPaths: [real] }), () => viaDirLink(alias)],
[
'a directory symlink, registered and named via two aliases',
() => ({ repoPaths: [alias] }),
() => viaDirLink(alias2)
],
[
'a directory symlink, registered via alias and named canonically',
() => ({ folderPath: alias }),
() => viaDirLink(real)
],
[
'a deep directory symlink in a folder workspace',
() => ({ folderPath: real }),
() => join(alias, 'sub', 'deep-link', 'secret.txt')
],
[
'a directory symlink named with `..`',
() => ({ repoPaths: [real] }),
() => join(alias, 'sub', '..', 'dir-link', 'secret.txt')
],
[
'a directory symlink via an ancestor alias',
() => ({ repoPaths: [real] }),
() => viaDirLink(real.replace(privateBase, varAlias))
],
[
'a leaf symlink via an ancestor alias',
() => ({ repoPaths: [real] }),
() => join(real.replace(privateBase, varAlias), 'escape.txt')
]
])('never read or write through %s out of the project', async (_label, register, named) => {
const store = makeStore(register())
expect(await settles(resolveLocalFileRequestPath(named(), undefined, store))).toBe('denied')
expect(await settles(resolveLocalWriteRequestPath(named(), undefined, store))).toBe('denied')
expect(await settles(resolveLocalFileRequestPath(secret, undefined, store))).toBe('denied')
})
it('reads a project link the user opened by name in place, while project requests stay refused', async () => {
const store = makeStore({ repoPaths: [real] })
const link = join(real, 'escape.txt')
expect(await settles(resolveLocalFileRequestPath(link, undefined, store))).toBe('denied')
const named = await resolveLocalFileRequestPath(link, USER_FILE, store)
await expect(readLocalFileContent(named)).resolves.toEqual({
content: 'secret\n',
isBinary: false
})
})
})
describe('document-resource requests', () => {
let otherProject: string
beforeEach(async () => {
otherProject = join(base, 'other-project')
await mkdir(join(project, 'docs'), { recursive: true })
await mkdir(otherProject)
await writeFile(join(project, 'docs', 'README.md'), '# doc\n')
await writeFile(join(project, 'logo.png'), 'png')
await writeFile(join(otherProject, 'shared.png'), 'png')
await writeFile(join(project, 'notes.txt'), 'text')
await writeFile(join(outside, 'outside.png'), 'png')
await mkdir(join(outside, 'doc-folder', 'img'), { recursive: true })
await writeFile(join(outside, 'doc-folder', 'note.md'), '# note\n')
await writeFile(join(outside, 'doc-folder', 'img', 'nested.png'), 'png')
await writeFile(join(outside, 'doc-folder', 'sibling.png'), 'png')
})
it('limit a project document to every project root, whatever the file type', async () => {
const store = makeStore({ repoPaths: [project, otherProject] })
const access = documentResource(join(project, 'docs', 'README.md'))
const outcome = (path: string) => settles(resolveLocalFileRequestPath(path, access, store))
expect(await outcome(join(project, 'logo.png'))).toBe('ok')
expect(await outcome(join(otherProject, 'shared.png'))).toBe('ok')
expect(await outcome(join(project, 'notes.txt'))).toBe('ok')
expect(await outcome(join(outside, 'outside.png'))).toBe('denied')
})
it('limit a document outside every project to its own folder', async () => {
const store = makeStore({ repoPaths: [project] })
const access = documentResource(join(outside, 'doc-folder', 'note.md'))
const outcome = (path: string) => settles(resolveLocalFileRequestPath(path, access, store))
expect(await outcome(join(outside, 'doc-folder', 'sibling.png'))).toBe('ok')
expect(await outcome(join(outside, 'doc-folder', 'img', 'nested.png'))).toBe('ok')
expect(await outcome(join(outside, 'outside.png'))).toBe('denied')
expect(await outcome('/dev/zero')).toBe('denied')
})
it.skipIf(process.platform === 'win32')(
'refuse a symlink in the document folder that leads out of it',
async () => {
const store = makeStore({})
await symlink(join(outside, 'outside.png'), join(outside, 'doc-folder', 'escape.png'))
const access = documentResource(join(outside, 'doc-folder', 'note.md'))
expect(
await settles(
resolveLocalFileRequestPath(join(outside, 'doc-folder', 'escape.png'), access, store)
)
).toBe('denied')
}
)
it.skipIf(process.platform === 'win32')(
'read a link of any name in a project or beside the document when its target stays there',
async () => {
await writeFile(join(outside, 'doc-folder', 'diagram'), 'png')
await symlink(join(outside, 'doc-folder', 'diagram'), join(outside, 'doc-folder', 'a.png'))
await symlink(join(project, 'notes.txt'), join(project, 'notes-link'))
const store = makeStore({ repoPaths: [project] })
const inProject = documentResource(join(project, 'docs', 'README.md'))
const besideDoc = documentResource(join(outside, 'doc-folder', 'note.md'))
expect(
await settles(resolveLocalFileRequestPath(join(project, 'notes-link'), inProject, store))
).toBe('ok')
expect(
await settles(
resolveLocalFileRequestPath(join(outside, 'doc-folder', 'a.png'), besideDoc, store)
)
).toBe('ok')
}
)
})
describe('chat-image requests', () => {
const CHAT_IMAGE = { kind: 'chat-image' } as const
it('read any local image file in place, whatever turn named it', async () => {
const shot = join(outside, 'shot.png')
await writeFile(shot, 'png')
const filePath = await resolveLocalFileRequestPath(shot, CHAT_IMAGE, makeStore({}))
await expect(readLocalFileContent(filePath)).resolves.toMatchObject({
isBinary: true,
mimeType: 'image/png'
})
})
it.each(['shot.avif', 'shot.bmp', 'shot.ico', 'shot.svg', 'shot.webp'])(
'read %s',
async (name) => {
await writeFile(join(outside, name), 'image')
expect(
await settles(resolveLocalFileRequestPath(join(outside, name), CHAT_IMAGE, makeStore({})))
).toBe('ok')
}
)
it.skipIf(process.platform === 'win32')(
'judge a link by its target: an extensionless link to an image loads',
async () => {
await writeFile(join(outside, 'shot.png'), 'png')
await symlink(join(outside, 'shot.png'), join(outside, 'latest-screenshot'))
expect(
await settles(
resolveLocalFileRequestPath(join(outside, 'latest-screenshot'), CHAT_IMAGE, makeStore({}))
)
).toBe('ok')
}
)
it.each(['notes.txt', 'missing.png'])('refuse %s', async (name) => {
expect(
await settles(resolveLocalFileRequestPath(join(outside, name), CHAT_IMAGE, makeStore({})))
).toBe('denied')
})
it('refuse a relative path, a device and a PDF', async () => {
const store = makeStore({})
await writeFile(join(outside, 'doc.pdf'), '%PDF')
expect(await settles(resolveLocalFileRequestPath('shot.png', CHAT_IMAGE, store))).toBe('denied')
expect(await settles(resolveLocalFileRequestPath('/dev/zero', CHAT_IMAGE, store))).toBe(
'denied'
)
expect(
await settles(resolveLocalFileRequestPath(join(outside, 'doc.pdf'), CHAT_IMAGE, store))
).toBe('denied')
})
it.skipIf(process.platform === 'win32')(
'refuse an image-named link to a text file or a device',
async () => {
await symlink(join(outside, 'notes.txt'), join(outside, 'secret.png'))
await symlink('/dev/zero', join(outside, 'zero.png'))
const store = makeStore({})
expect(
await settles(resolveLocalFileRequestPath(join(outside, 'secret.png'), CHAT_IMAGE, store))
).toBe('denied')
expect(
await settles(resolveLocalFileRequestPath(join(outside, 'zero.png'), CHAT_IMAGE, store))
).toBe('denied')
}
)
})
@@ -0,0 +1,317 @@
import { dirname, extname, isAbsolute, resolve } from 'node:path'
import { realpath, stat } from 'node:fs/promises'
import type { Store } from '../persistence'
import type { LocalFileAccess } from '../../shared/local-file-access'
import {
PATH_ACCESS_DENIED_MESSAGE,
resolveAuthorizedPath,
type ResolveAuthorizedPathOptions
} from './filesystem-auth'
import { isDescendantOrEqual } from './filesystem-path-containment'
import { PREVIEWABLE_BINARY_MIME_TYPES } from './filesystem/filesystem-file-content-inspection'
import { getDefaultFloatingWorkspacePath } from './floating-workspace-directory'
import {
isDeviceNamespacePath,
isNetworkSharePath,
isWindowsReservedDeviceName
} from './automatic-load-path-text'
import { NOT_A_REGULAR_FILE_MESSAGE } from './filesystem/local-regular-file-read'
const USER_FILE_NEEDS_ABSOLUTE_PATH_MESSAGE =
'Access denied: a file opened by name needs an absolute path.'
const USER_FILE_ACCESS: LocalFileAccess = { kind: 'user-file' }
const CHAT_IMAGE_TYPE_MESSAGE = 'Access denied: a chat can only show local image files.'
/** Desktop IPC's root check: the project roots plus the app-owned floating-workspace folder. */
export async function resolveDesktopAuthorizedPath(
targetPath: string,
store: Store,
options: ResolveAuthorizedPathOptions = {}
): Promise<string> {
return resolveAuthorizedPath(targetPath, store, {
...options,
extraRoots: [getDefaultFloatingWorkspacePath()]
})
}
/** A file the user named is used where it is; no root applies, and nothing is remembered. */
function resolveUserNamedLocalPath(targetPath: string): string {
// Why isAbsolute on the raw input: resolve() would anchor `notes.txt` or `C:notes` to main's cwd.
if (typeof targetPath !== 'string' || !isAbsolute(targetPath)) {
throw new Error(USER_FILE_NEEDS_ABSOLUTE_PATH_MESSAGE)
}
return resolve(targetPath)
}
/**
* A user-named path that must be an existing regular file, e.g. a notebook or a log being tailed.
* Inside a project it resolves as the default check does, to the real file.
*/
export async function resolveUserNamedRegularFile(
targetPath: string,
store: Store
): Promise<string> {
const filePath = await resolveLocalRequestPath(targetPath, USER_FILE_ACCESS, store, 'read')
if (!(await stat(filePath)).isFile()) {
throw new Error(NOT_A_REGULAR_FILE_MESSAGE)
}
return filePath
}
// Why every previewable type but PDF: chat shows these in an <img>, which renders no PDF.
function isChatImage(filePath: string): boolean {
const extension = extname(filePath).toLowerCase()
return Boolean(PREVIEWABLE_BINARY_MIME_TYPES[extension]) && extension !== '.pdf'
}
// Why the resolved path: `NUL.png\.` and `COM1.png\x\..` resolve to a device name.
function isRefusedAutomaticLoadPath(filePath: string): boolean {
return isDeviceNamespacePath(filePath) || isWindowsReservedDeviceName(filePath)
}
/**
* A file a document references (an image, typically) beyond what the default check allows: one in
* the document's own folder, like the common markdown-preview rule. A target outside the folder,
* or a network share, is refused by its path text before any filesystem call; a symlink inside the
* folder is still resolved by the folder check.
*/
async function resolveDocumentResourcePath(
targetPath: string,
documentPath: string
): Promise<string> {
if (
typeof targetPath !== 'string' ||
!isAbsolute(targetPath) ||
typeof documentPath !== 'string' ||
!isAbsolute(documentPath)
) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
const resolvedTarget = resolve(targetPath)
if (isRefusedAutomaticLoadPath(resolvedTarget)) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
const documentFolder = dirname(resolve(documentPath))
if (isNetworkSharePath(resolvedTarget) || !isDescendantOrEqual(resolvedTarget, documentFolder)) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
const realTarget = resolve(await realpath(resolvedTarget))
if (!isDescendantOrEqual(realTarget, resolve(await realpath(documentFolder)))) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
if (isRefusedAutomaticLoadPath(realTarget)) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
return realTarget
}
/**
* An image shown in a chat transcript, whoever's turn named it: any absolute local image file,
* typed by its real target. Transcripts load as they scroll into view, so a network share is read
* only inside a project the user added from it (the default check); anywhere else its path text,
* like a device path, is refused before any filesystem call.
*/
async function resolveChatImagePath(targetPath: string, store: Store): Promise<string> {
if (typeof targetPath !== 'string' || !isAbsolute(targetPath)) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
const resolvedTarget = resolve(targetPath)
if (isRefusedAutomaticLoadPath(resolvedTarget)) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
if (isNetworkSharePath(resolvedTarget)) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
const realTarget = resolve(await realpath(resolvedTarget))
if (isRefusedAutomaticLoadPath(realTarget)) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
if (isNetworkSharePath(realTarget) && !isNetworkSharePath(resolvedTarget)) {
// Why: a local link may still lead onto a share; that is readable only inside a project.
await resolveDesktopAuthorizedPath(realTarget, store)
}
// Why the real target's type: `shot.png -> ~/.ssh/id_rsa` must not be read as an image.
if (!isChatImage(realTarget)) {
throw new Error(CHAT_IMAGE_TYPE_MESSAGE)
}
return realTarget
}
/**
* Adding a file beside a document the user opened: the target must stay inside the document's own
* folder, symlinks included.
*/
async function resolveDocumentFolderPath(
targetPath: string,
documentPath: string
): Promise<string> {
if (
typeof targetPath !== 'string' ||
!isAbsolute(targetPath) ||
typeof documentPath !== 'string' ||
!isAbsolute(documentPath)
) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
const resolvedTarget = resolve(targetPath)
const documentFolder = dirname(resolve(documentPath))
if (
isRefusedAutomaticLoadPath(resolvedTarget) ||
!isDescendantOrEqual(resolvedTarget, documentFolder)
) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
const realTarget = resolve(await realpath(resolvedTarget))
const realFolder = resolve(await realpath(documentFolder))
if (isRefusedAutomaticLoadPath(realTarget) || !isDescendantOrEqual(realTarget, realFolder)) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
return realTarget
}
function isOpenedDocument(targetPath: unknown, documentPath: string): boolean {
return (
typeof targetPath === 'string' &&
isAbsolute(targetPath) &&
isAbsolute(documentPath) &&
resolve(targetPath) === resolve(documentPath)
)
}
// Why parse: IPC input is untyped, and an unrecognised access kind must fall back to roots only.
function parseLocalFileAccess(access: unknown): LocalFileAccess | undefined {
if (typeof access !== 'object' || access === null || !('kind' in access)) {
return undefined
}
if (access.kind === 'user-file') {
return { kind: 'user-file' }
}
if (access.kind === 'chat-image') {
return { kind: 'chat-image' }
}
if (
access.kind === 'document-folder' &&
'documentPath' in access &&
typeof access.documentPath === 'string'
) {
return { kind: 'document-folder', documentPath: access.documentPath }
}
if (
access.kind === 'document-resource' &&
'documentPath' in access &&
typeof access.documentPath === 'string'
) {
return { kind: 'document-resource', documentPath: access.documentPath }
}
return undefined
}
/** What a desktop request does with its path; each declared kind adds access to only some. */
export type LocalRequestOperation = 'read' | 'write' | 'rename-from' | 'rename-to' | 'import-into'
type KindRule = (targetPath: string) => Promise<string>
function declaredKindRule(
fileAccess: LocalFileAccess,
operation: LocalRequestOperation,
store: Store
): KindRule | undefined {
switch (fileAccess.kind) {
case 'user-file':
// Why renames: resolveLocalRenamePaths declares this only for the opened document itself.
return operation !== 'import-into'
? async (targetPath) => resolveUserNamedLocalPath(targetPath)
: undefined
case 'document-resource':
return operation === 'read'
? (targetPath) => resolveDocumentResourcePath(targetPath, fileAccess.documentPath)
: undefined
case 'chat-image':
return operation === 'read'
? (targetPath) => resolveChatImagePath(targetPath, store)
: undefined
case 'document-folder':
return operation === 'import-into'
? (targetPath) => resolveDocumentFolderPath(targetPath, fileAccess.documentPath)
: undefined
}
}
/**
* The one resolver for desktop local file requests. A declared kind never refuses what the default
* project check allows; its own rule only adds paths outside every project.
*/
export async function resolveLocalRequestPath(
targetPath: string,
access: unknown,
store: Store,
operation: LocalRequestOperation
): Promise<string> {
// Why the leaf is kept: a rename acts on a link itself, never on what it points to.
const options = { preserveSymlink: operation === 'rename-from' || operation === 'rename-to' }
const fileAccess = parseLocalFileAccess(access)
const kindRule = fileAccess && declaredKindRule(fileAccess, operation, store)
if (!fileAccess || !kindRule) {
return resolveDesktopAuthorizedPath(targetPath, store, options)
}
if (typeof targetPath !== 'string') {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
// Why device text first: a device is never a file to load, even inside a project.
const automaticLoad = fileAccess.kind === 'document-resource' || fileAccess.kind === 'chat-image'
if (automaticLoad && isRefusedAutomaticLoadPath(resolve(targetPath))) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
// Why the default check refuses an outside path by its text first: no share is contacted here.
const insideRoots = await resolveDesktopAuthorizedPath(targetPath, store, options).catch(
() => undefined
)
if (insideRoots === undefined) {
return kindRule(targetPath)
}
if (automaticLoad && isRefusedAutomaticLoadPath(insideRoots)) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
return insideRoots
}
/**
* Both paths of a desktop rename. Renaming the opened document (its document-folder access) follows
* the user-file rule: the user typed the new path, so it may go anywhere, and its Undo, declared
* from the moved file, may come back from there. Any other rename gets the default check only.
*/
export async function resolveLocalRenamePaths(
oldPath: string,
newPath: string,
access: unknown,
store: Store
): Promise<{ from: string; to: string }> {
const fileAccess = parseLocalFileAccess(access)
const renameAccess =
fileAccess?.kind === 'document-folder' && isOpenedDocument(oldPath, fileAccess.documentPath)
? USER_FILE_ACCESS
: undefined
return {
from: await resolveLocalRequestPath(oldPath, renameAccess, store, 'rename-from'),
to: await resolveLocalRequestPath(newPath, renameAccess, store, 'rename-to')
}
}
/** A desktop read/stat request; no declared access means roots only. */
export function resolveLocalFileRequestPath(
targetPath: string,
access: unknown,
store: Store
): Promise<string> {
return resolveLocalRequestPath(targetPath, access, store, 'read')
}
/** A desktop save; user-file access adds the open file the user named. */
export function resolveLocalWriteRequestPath(
targetPath: string,
access: unknown,
store: Store
): Promise<string> {
return resolveLocalRequestPath(targetPath, access, store, 'write')
}
+6 -3
View File
@@ -13,12 +13,16 @@ vi.mock('electron', () => ({
handle: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler)
}
}))
vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: authorize }))
vi.mock('./local-file-access-resolution', () => ({ resolveUserNamedRegularFile: authorize }))
import {
closeAllLocalLogTailWatchers,
getActiveLocalLogTailWatcherCount,
registerLocalLogTailHandlers
} from './local-log-tail'
import type { Store } from '../persistence'
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: path resolution is mocked, so the store is never read.
const NO_STORE = {} as Store
class Sender extends EventEmitter {
dead = false
@@ -41,8 +45,7 @@ beforeEach(async () => {
filePath = join(directory, 'fixture.log')
await writeFile(filePath, 'test\n')
authorize.mockReset().mockResolvedValue(filePath)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization is mocked; the handler never reads Store in this isolated fixture.
registerLocalLogTailHandlers({} as never)
registerLocalLogTailHandlers(NO_STORE)
})
afterEach(async () => {
closeAllLocalLogTailWatchers()
+9 -3
View File
@@ -18,7 +18,9 @@ vi.mock('electron', () => ({
vi.mock('node:fs', () => ({ watch: watchMock }))
vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: resolveAuthorizedPathMock }))
vi.mock('./local-file-access-resolution', () => ({
resolveUserNamedRegularFile: resolveAuthorizedPathMock
}))
vi.mock('../ai-vault/local-log-tail-reader', () => ({
readLocalLogTailRange: readRangeMock
@@ -29,6 +31,10 @@ import {
getActiveLocalLogTailWatcherCount,
registerLocalLogTailHandlers
} from './local-log-tail'
import type { Store } from '../persistence'
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: path resolution is mocked, so the store is never read.
const NO_STORE = {} as Store
type FakeWatcher = {
close: ReturnType<typeof vi.fn>
@@ -64,7 +70,7 @@ beforeEach(() => {
watchMock.mockReset()
resolveAuthorizedPathMock.mockReset().mockImplementation(async (path: string) => path)
readRangeMock.mockReset()
registerLocalLogTailHandlers({} as never)
registerLocalLogTailHandlers(NO_STORE)
})
afterEach(() => {
@@ -87,7 +93,7 @@ describe('local log tail IPC', () => {
)
emitChange?.('change')
expect(resolveAuthorizedPathMock).toHaveBeenCalledWith('/logs/session.jsonl', expect.anything())
expect(resolveAuthorizedPathMock).toHaveBeenCalledWith('/logs/session.jsonl', NO_STORE)
expect(watchMock).toHaveBeenCalledWith('/logs/session.jsonl', expect.any(Function))
expect(sender.send).toHaveBeenCalledWith('fs:localLogTailChanged', {
subscriptionId: 'tail-1',
+4 -4
View File
@@ -1,6 +1,5 @@
import { ipcMain, type WebContents } from 'electron'
import { watch, type FSWatcher } from 'node:fs'
import type { Store } from '../persistence'
import type {
LocalLogTailChangedPayload,
LocalLogTailReadArgs,
@@ -8,7 +7,8 @@ import type {
LocalLogTailWatchArgs
} from '../../shared/local-log-tail-types'
import { readLocalLogTailRange } from '../ai-vault/local-log-tail-reader'
import { resolveAuthorizedPath } from './filesystem-auth'
import type { Store } from '../persistence'
import { resolveUserNamedRegularFile } from './local-file-access-resolution'
import { abortWhenRendererGone } from './renderer-lifetime-abort'
type TailSenderOwner = {
@@ -109,7 +109,7 @@ async function startWatch(
const pending = Symbol(subscriptionId)
owner.pending.set(key, pending)
try {
const filePath = await resolveAuthorizedPath(args.filePath, store)
const filePath = await resolveUserNamedRegularFile(args.filePath, store)
if (
sender.isDestroyed() ||
owner.signal.aborted ||
@@ -147,7 +147,7 @@ export function registerLocalLogTailHandlers(store: Store): void {
ipcMain.handle(
'fs:readLocalLogTail',
async (_event, args: LocalLogTailReadArgs): Promise<LocalLogTailReadResult> => {
const filePath = await resolveAuthorizedPath(args.filePath, store)
const filePath = await resolveUserNamedRegularFile(args.filePath, store)
return readLocalLogTailRange(filePath, args.fromByteOffset, args.expectedIdentity)
}
)
+102
View File
@@ -0,0 +1,102 @@
import { EventEmitter } from 'node:events'
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
import type * as RepoWorktrees from '../repo-worktrees'
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
type Handler = (event: unknown, args: unknown) => unknown
const { handlers, startNotebookKernelMock, userData } = vi.hoisted(() => ({
handlers: new Map<string, Handler>(),
startNotebookKernelMock: vi.fn(),
userData: { path: '' }
}))
vi.mock('electron', () => ({
app: { getPath: () => userData.path },
ipcMain: { handle: (channel: string, handler: Handler) => handlers.set(channel, handler) }
}))
vi.mock('../repo-worktrees', async () => {
const actual = await vi.importActual<typeof RepoWorktrees>('../repo-worktrees')
return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) }
})
vi.mock('../notebook/notebook-kernel', () => ({ startNotebookKernel: startNotebookKernelMock }))
import { registerNotebookHandlers } from './notebook'
let base: string
let project: string
function storeWithProject(projectPath: string): Store {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members.
return {
getRepos: () => [
{ id: 'repo', path: projectPath, displayName: 'project', badgeColor: '#000', addedAt: 0 }
],
getProjects: () => [],
getProjectGroups: () => [],
getFolderWorkspaces: () => [],
getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' })
} as unknown as Store
}
beforeEach(async () => {
invalidateAuthorizedRootsCache()
handlers.clear()
startNotebookKernelMock.mockReset().mockReturnValue({
kernel: { execute: vi.fn(), interrupt: vi.fn(), shutdown: vi.fn() },
ready: Promise.resolve({ status: 'ready' }),
exited: new Promise(() => {})
})
base = await mkdtemp(join(await realpath(tmpdir()), 'orca-notebook-link-'))
project = join(base, 'project')
userData.path = join(base, 'user-data')
await mkdir(join(project, 'analysis'), { recursive: true })
await mkdir(join(userData.path, 'floating-workspace'), { recursive: true })
await writeFile(join(project, 'analysis', 'real.ipynb'), '{}')
registerNotebookHandlers(storeWithProject(project))
})
afterEach(async () => {
await rm(base, { recursive: true, force: true })
})
it.skipIf(process.platform === 'win32')(
'runs a notebook opened through a project link in its real folder, as the project check resolves it',
async () => {
await symlink(join(project, 'analysis', 'real.ipynb'), join(project, 'nb.ipynb'))
const owner = Object.assign(new EventEmitter(), { send: vi.fn(), isDestroyed: () => false })
await handlers.get('notebook:startKernel')!(
{ sender: owner },
{ filePath: join(project, 'nb.ipynb'), python: '/py' }
)
expect(startNotebookKernelMock).toHaveBeenCalledWith(
expect.objectContaining({ cwd: join(project, 'analysis') })
)
}
)
it.skipIf(process.platform === 'win32')(
'runs a linked notebook outside every project in its real folder, as before',
async () => {
const notes = join(base, 'notes')
await mkdir(join(notes, 'analysis'), { recursive: true })
await writeFile(join(notes, 'analysis', 'real.ipynb'), '{}')
await symlink(join(notes, 'analysis', 'real.ipynb'), join(notes, 'nb.ipynb'))
const owner = Object.assign(new EventEmitter(), { send: vi.fn(), isDestroyed: () => false })
await handlers.get('notebook:startKernel')!(
{ sender: owner },
{ filePath: join(notes, 'nb.ipynb'), python: '/py' }
)
expect(startNotebookKernelMock).toHaveBeenCalledWith(
expect.objectContaining({ cwd: join(notes, 'analysis') })
)
}
)
+43 -3
View File
@@ -1,5 +1,9 @@
import { EventEmitter } from 'node:events'
import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeFsPromises from 'node:fs/promises'
import type { KernelFrame } from '../../shared/notebook-kernel-types'
const handlers = new Map<string, (event: unknown, args: unknown) => unknown>()
@@ -14,11 +18,23 @@ vi.mock('electron', () => ({
handlers.set(channel, handler)
}
}))
vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: resolveAuthorizedPathMock }))
vi.mock('./local-file-access-resolution', () => ({
resolveUserNamedRegularFile: resolveAuthorizedPathMock,
resolveDesktopAuthorizedPath: resolveAuthorizedPathMock
}))
vi.mock('../notebook/notebook-kernel', () => ({ startNotebookKernel: startNotebookKernelMock }))
// Why: the mocked resolver returns made-up `/real/...` paths, which stand for real files already.
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>()
return {
...actual,
realpath: async (path: string) => (path.startsWith('/real/') ? path : actual.realpath(path))
}
})
import { registerNotebookHandlers } from './notebook'
import type { Store } from '../persistence'
import type * as FileAccessResolution from './local-file-access-resolution'
function fakeKernel() {
let onFrame: (frame: KernelFrame) => void = () => {}
@@ -122,6 +138,30 @@ describe('notebook IPC', () => {
expect(first.kernel.execute).not.toHaveBeenCalled()
})
it('starts a kernel for a notebook outside every project, and refuses a relative path', async () => {
const actual = await vi.importActual<typeof FileAccessResolution>(
'./local-file-access-resolution'
)
resolveAuthorizedPathMock.mockImplementation(actual.resolveUserNamedRegularFile)
const folder = await mkdtemp(join(await realpath(tmpdir()), 'orca-notebook-'))
try {
const notebook = join(folder, 'analysis.ipynb')
await writeFile(notebook, '{}')
fakeKernel()
const start = handlers.get('notebook:startKernel')!
await expect(
start({ sender: fakeOwner() }, { filePath: notebook, python: '/py' })
).resolves.toEqual({ status: 'ready' })
expect(startNotebookKernelMock).toHaveBeenCalledWith(expect.objectContaining({ cwd: folder }))
await expect(
start({ sender: fakeOwner() }, { filePath: 'analysis.ipynb', python: '/py' })
).rejects.toThrow('absolute path')
} finally {
await rm(folder, { recursive: true, force: true })
}
})
it.each(['shutdown', 'destroyed', 'did-navigate', 'render-process-gone'])(
'does not start a kernel after %s while path authorization is pending',
async (boundary) => {
@@ -229,9 +269,9 @@ describe('notebook IPC', () => {
const old = start({ sender: owner }, alias)
const fresh = start({ sender: owner }, canonical)
handlers.get('notebook:shutdownKernel')!({ sender: owner }, alias)
aliasAuthorization.resolve(canonical.filePath)
aliasAuthorization.resolve(`/real${canonical.filePath}`)
await expect(old).resolves.toMatchObject({ status: 'failed' })
canonicalAuthorization.resolve(canonical.filePath)
canonicalAuthorization.resolve(`/real${canonical.filePath}`)
await expect(fresh).resolves.toEqual({ status: 'ready' })
handlers.get('notebook:execute')!({ sender: owner }, { ...canonical, code: 'canonical' })
+13 -6
View File
@@ -1,8 +1,12 @@
import { randomUUID } from 'node:crypto'
import { realpath } from 'node:fs/promises'
import { dirname } from 'node:path'
import { ipcMain, type WebContents } from 'electron'
import type { Store } from '../persistence'
import { resolveAuthorizedPath } from './filesystem-auth'
import {
resolveDesktopAuthorizedPath,
resolveUserNamedRegularFile
} from './local-file-access-resolution'
import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation'
import { startNotebookKernel, type NotebookKernel } from '../notebook/notebook-kernel'
import {
@@ -57,6 +61,8 @@ function kernelsOf(owner: WebContents): Map<string, NotebookKernel> {
return kernels
}
// Why the notebook path is user-named: it is an open tab, and it only picks the kernel's cwd and
// the venv folder. Inside a project it resolves to the real file, so the cwd is its real folder.
export function registerNotebookHandlers(store: Store): void {
ipcMain.handle(
'notebook:listPythonEnvironments',
@@ -64,7 +70,7 @@ export function registerNotebookHandlers(store: Store): void {
_event,
args: { filePath: string; rootPath: string | null; runWorkspaceInterpreters: boolean }
): Promise<PythonEnvironments> => {
await resolveAuthorizedPath(args.filePath, store)
await resolveUserNamedRegularFile(args.filePath, store)
// Why the unresolved path: rootPath is in the same (possibly symlinked) form, e.g. /tmp.
return listPythonEnvironments(args.filePath, args.rootPath, {
runWorkspaceInterpreters: args.runWorkspaceInterpreters === true
@@ -96,8 +102,9 @@ export function registerNotebookHandlers(store: Store): void {
starts.set(args.filePath, pending)
pending.add(controller)
try {
// Why: run from the notebook's folder so relative imports and data paths resolve as on disk.
const cwd = dirname(await resolveAuthorizedPath(args.filePath, store))
// Why the real file's folder: relative imports and data paths resolve as on disk, even when
// the notebook was opened through a link.
const cwd = dirname(await realpath(await resolveUserNamedRegularFile(args.filePath, store)))
if (controller.signal.aborted || owner.isDestroyed()) {
return { status: 'failed', detail: 'The notebook closed before its kernel started.' }
}
@@ -144,9 +151,9 @@ export function registerNotebookHandlers(store: Store): void {
_event,
args: { filePath: string; rootPath: string | null; python: string }
): Promise<CreateVenvResult> => {
await resolveAuthorizedPath(args.filePath, store)
await resolveUserNamedRegularFile(args.filePath, store)
if (args.rootPath) {
await resolveAuthorizedPath(args.rootPath, store)
await resolveDesktopAuthorizedPath(args.rootPath, store)
}
return createNotebookVenv(args.python, notebookVenvParent(args.filePath, args.rootPath))
}
+24
View File
@@ -92,9 +92,33 @@ vi.mock('../gitea/client', () => ({
}))
import { registerPreflightHandlers } from './preflight'
import { detectAgentCommandsOnHost } from '../preflight/agent-detection'
import { resetPreflightMocks, type HandlerMap } from './preflight-test-harness'
describe('preflight', () => {
it('resolves exact Qoder command labels on the SSH host using the existing request', async () => {
const request = vi.fn().mockResolvedValue({ agents: ['qoder', 'unrelated'] })
getActiveMultiplexerMock.mockReturnValue({ isDisposed: () => false, request })
await expect(
detectAgentCommandsOnHost(['qodercli', 'qoder'], { connectionId: 'ssh-qoder' })
).resolves.toEqual(new Set(['qoder']))
expect(request).toHaveBeenCalledWith('preflight.detectAgents', {
commands: [
{ id: 'qodercli', cmd: 'qodercli' },
{ id: 'qoder', cmd: 'qoder' }
]
})
expect(isCommandOnLocalPathMock).not.toHaveBeenCalled()
})
it('refuses command selection after SSH contact loss without probing the local host', async () => {
getActiveMultiplexerMock.mockReturnValue(null)
await expect(
detectAgentCommandsOnHost(['qodercli', 'qoder'], { connectionId: 'ssh-qoder' })
).rejects.toThrow('execution host connection')
expect(isCommandOnLocalPathMock).not.toHaveBeenCalled()
})
const originalPlatform = process.platform
const handlers: HandlerMap = {}
@@ -7,7 +7,8 @@ import {
import {
expectedOmpStatusExtension,
TEST_CODEX_HOME,
TEST_CODEX_AUTH_JSON
TEST_CODEX_AUTH_JSON,
TEST_MANAGED_ROOT
} from './pty-ipc-test-constants'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures'
@@ -15,6 +16,8 @@ import { join } from 'node:path'
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing'
import { registerPtyHandlers, resolveCodexHomeAfterManagedAuthReadiness } from './pty'
const consumerConfigHome = join(TEST_MANAGED_ROOT, 'opencode-consumer-config')
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
@@ -435,11 +438,15 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_OPENCODE_HOOK_PORT).toBe('4567')
})
it('mirrors a user-provided OPENCODE_CONFIG_DIR into a source-scoped overlay on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({ OPENCODE_CONFIG_DIR: '/user/custom/opencode' })
const env = await daemonSpawnAndGetEnv({
OPENCODE_CONFIG_DIR: '/user/custom/opencode',
XDG_CONFIG_HOME: consumerConfigHome
})
// Why: OpenCode loads config from a single dir, so the user's path is mirrored into a source-scoped overlay, not passed through.
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/user/custom/opencode'
'/user/custom/opencode',
join(consumerConfigHome, 'opencode')
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
@@ -448,11 +455,13 @@ describe('registerPtyHandlers', () => {
it('uses source OpenCode config env instead of remirroring a parent overlay', async () => {
const env = await daemonSpawnAndGetEnv({
OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/user/custom/opencode'
ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/user/custom/opencode',
XDG_CONFIG_HOME: consumerConfigHome
})
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/user/custom/opencode'
'/user/custom/opencode',
join(consumerConfigHome, 'opencode')
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
@@ -58,6 +58,7 @@ import { _resetWslCachesForTests } from '../wsl'
/** The mocked webContents each suite asserts sends against. */
export type PtyIpcTestWebContents = {
id: number
on: Mock
send: Mock
removeListener: Mock
@@ -92,6 +93,7 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment {
isVisible: () => true,
isMinimized: () => false,
webContents: {
id: 1,
on: vi.fn(),
send: vi.fn(),
removeListener: vi.fn(),
@@ -101,6 +103,7 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment {
const mainWindowIpcEvent = { sender: mainWindow.webContents }
const foreignWindowIpcEvent = {
sender: {
id: 2,
on: vi.fn(),
send: vi.fn(),
removeListener: vi.fn(),
+374 -20
View File
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { PtyBindingPersistenceOperations } from '../persistence/loading-store/pty-binding-persistence'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { setupPtyIpcSuite, type PtyIpcSuiteFixtures } from './pty-ipc-test-harness'
import { SessionNotFoundError } from '../daemon/daemon-errors'
import { registerSshPtyProvider, unregisterSshPtyProvider } from './pty/provider/registry'
import { toAppSshPtyId } from '../providers/ssh-pty-id'
import { TerminalKilledError } from '../daemon/daemon-pty-lifecycle-errors'
import { makePaneKey } from '../../shared/stable-pane-id'
import { registerPtyHandlers, setLocalPtyProvider } from './pty'
import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops'
@@ -56,6 +59,34 @@ const leafId = '12121212-1212-4212-8212-121212121212'
const paneKey = makePaneKey(tabId, leafId)
type RestartHarness = ReturnType<typeof installRestartHarness>
type FakeSession = {
tabsByWorktree: Record<string, { id: string; worktreeId: string; ptyId: string | null }[]>
terminalLayoutsByTabId: Record<
string,
{
root: { type: 'leaf'; leafId: string }
activeLeafId: string
expandedLeafId: null
ptyIdsByLeafId: Record<string, string>
}
>
terminalPtyIncarnationsByPaneKey: Record<string, string>
}
function seedSession(ptyId: string): FakeSession {
return {
tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId }] },
terminalLayoutsByTabId: {
[tabId]: {
root: { type: 'leaf', leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: ptyId }
}
},
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-old' }
}
}
function registerWithFakes(
mainWindow: PtyIpcSuiteFixtures['mainWindow'],
@@ -78,13 +109,15 @@ function installRestartHarness(
options: { shutdownFails?: boolean; shutdownGate?: Promise<void> } = {}
) {
let oldSessionAlive = true
const control = { shutdownFails: options.shutdownFails ?? false }
const control = { shutdownFails: options.shutdownFails ?? false, failNextWrite: false }
const boundAtFreshLaunch: (string | undefined)[] = []
const providerSpawn = vi.fn(async (spawnOptions: { attachOnly?: boolean }) => {
if (!spawnOptions.attachOnly) {
boundAtFreshLaunch.push(leafBinding())
return { id: 'pty-new', incarnationId: 'inc-new' }
}
if (!oldSessionAlive) {
throw new SessionNotFoundError('pty-old')
throw new TerminalKilledError('pty-old')
}
return { id: 'pty-old', incarnationId: 'inc-old', isReattach: true }
})
@@ -120,31 +153,75 @@ function installRestartHarness(
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restart path calls only the provider members this fake defines.
setLocalPtyProvider(provider as unknown as Parameters<typeof setLocalPtyProvider>[0])
let session = {
tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-old' }] },
terminalLayoutsByTabId: {
[tabId]: {
root: { type: 'leaf' as const, leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: 'pty-old' }
}
},
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-old' }
}
let session = seedSession('pty-old')
const store = {
getWorkspaceSession: vi.fn(() => session),
getWorkspaceSession: vi.fn((_hostId?: string): FakeSession => session),
setWorkspaceSession: vi.fn((next) => {
session = next
}),
flushOrThrow: vi.fn(),
runDurableMutation: vi.fn(async <T>(mutate: () => { value: T }) => mutate().value),
persistPtyBinding: vi.fn(),
runDurableMutation: vi.fn(
async <T>(
mutate: () => { value: T; persist?: boolean | 'if-dirty'; rollback?: () => void }
) => {
const mutation = mutate()
if (mutation.persist !== false) {
if (control.failNextWrite) {
control.failNextWrite = false
mutation.rollback?.()
throw new Error('save failed')
}
durableBindings.push(leafBinding())
}
return mutation.value
}
),
getWorkspaceSessionHostIds: vi.fn(() => [
'local',
...Object.keys(state.workspaceSessionsByHostId)
]),
getFolderWorkspace: vi.fn(() => undefined),
getFolderWorkspaces: vi.fn(() => []),
getProjectGroups: vi.fn(() => []),
getRepos: vi.fn(() => [])
}
const state: {
workspaceSession: FakeSession
workspaceSessionsByHostId: Record<string, FakeSession>
} = { workspaceSession: session, workspaceSessionsByHostId: {} }
const durableBindings: (string | undefined)[] = []
function leafBinding(hostId?: string): string | undefined {
return store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId]
}
// Like the store, a missing host partition reads as an empty session, never the local one.
store.getWorkspaceSession.mockImplementation((hostId?: string) =>
hostId && hostId !== 'local'
? (state.workspaceSessionsByHostId[hostId] ?? {
tabsByWorktree: {},
terminalLayoutsByTabId: {},
terminalPtyIncarnationsByPaneKey: {}
})
: state.workspaceSession
)
const bindingRuntime = {
state,
dirtyProfileStateDomains: new Set(),
runDurableMutation: store.runDurableMutation,
writeTimer: null,
pendingWrite: null,
quitFlushStarted: false,
writeGeneration: 0,
lastDurableWriteGeneration: 0
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: binding writes read only state, write bookkeeping, durable mutation and the session partitions from these fakes.
const bindingArgs = [bindingRuntime, store] as unknown as ConstructorParameters<
typeof PtyBindingPersistenceOperations
>
const bindingOperations = new PtyBindingPersistenceOperations(...bindingArgs)
const storeWithRetirement = Object.assign(store, {
persistPtyBinding: vi.fn(bindingOperations.persistPtyBinding.bind(bindingOperations)),
retirePtyBinding: bindingOperations.retirePtyBinding.bind(bindingOperations)
})
const runtime = {
setPtyController: vi.fn(),
resolveTerminalPane: vi.fn(() => {
@@ -165,7 +242,18 @@ function installRestartHarness(
onPtyData: vi.fn(),
intentionalPtyStops: new TerminalIntentionalStops()
}
return { providerSpawn, shutdown, store, runtime, control }
return {
providerSpawn,
shutdown,
store: storeWithRetirement,
runtime,
control,
provider,
leafBinding,
durableBindings,
boundAtFreshLaunch,
partitions: state.workspaceSessionsByHostId
}
}
function restartSpawnArgs(extra: { replacesPtyId?: string } = {}) {
@@ -283,4 +371,270 @@ describe('pty:spawn replacing a pane owner', () => {
expect(exitPayloads('pty-old')).toHaveLength(1)
expect(exitPayloads('pty-old')[0]).not.toHaveProperty('replacedByRestart')
})
it('swaps the stopped binding for the replacement in one write, never unbinding the pane', async () => {
const { providerSpawn, store, runtime, durableBindings, boundAtFreshLaunch } =
installRestartHarness()
const session = store.getWorkspaceSession()
const layout = structuredClone(session.terminalLayoutsByTabId[tabId])
registerWithFakes(mainWindow, runtime, store)
await handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' }))
const swapped = store.getWorkspaceSession()
expect(boundAtFreshLaunch).toEqual(['pty-old'])
expect(durableBindings).toEqual(['pty-new'])
expect(swapped.tabsByWorktree[worktreeId]).toEqual([
{ ...session.tabsByWorktree[worktreeId][0], ptyId: 'pty-new' }
])
expect(swapped.terminalLayoutsByTabId[tabId]).toEqual({
...layout,
ptyIdsByLeafId: { [leafId]: 'pty-new' }
})
expect(swapped.terminalPtyIncarnationsByPaneKey).toEqual({ [paneKey]: 'inc-new' })
expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true)
})
it('launches fresh when a stale snapshot re-publishes the stopped binding mid-spawn', async () => {
const { providerSpawn, store, runtime, leafBinding } = installRestartHarness()
runtime.createPreAllocatedTerminalHandle.mockImplementationOnce(() => {
// A debounced renderer layout patch still carrying the old id lands after the stop.
const current = store.getWorkspaceSession()
current.terminalLayoutsByTabId[tabId] = {
...current.terminalLayoutsByTabId[tabId],
ptyIdsByLeafId: { [leafId]: 'pty-old' }
}
return 'term-restart'
})
registerWithFakes(mainWindow, runtime, store)
await expect(
handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' }))
).resolves.toMatchObject({ id: 'pty-new' })
expect(runtime.createPreAllocatedTerminalHandle).toHaveBeenCalled()
expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true)
expect(leafBinding()).toBe('pty-new')
})
it('swaps a binding the renderer withdrew before connecting the replacement', async () => {
const { providerSpawn, store, runtime, leafBinding } = installRestartHarness()
runtime.createPreAllocatedTerminalHandle.mockImplementationOnce(() => {
// A split tab's partial layout map, or an SSH terminated lease, lets the renderer clear land.
delete store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId]
return 'term-restart'
})
registerWithFakes(mainWindow, runtime, store)
await expect(
handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' }))
).resolves.toMatchObject({ id: 'pty-new' })
expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true)
expect(leafBinding()).toBe('pty-new')
})
it('clears the stopped binding when the replacement fails, so the remount starts fresh', async () => {
const { providerSpawn, store, runtime, leafBinding } = installRestartHarness()
providerSpawn.mockRejectedValueOnce(new Error('spawn failed'))
registerWithFakes(mainWindow, runtime, store)
await expect(
handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' }))
).rejects.toThrow('spawn failed')
const boundAfterFailure = leafBinding()
// The renderer's recovery remount sends no replacesPtyId.
await expect(handlers.get('pty:spawn')!(null, restartSpawnArgs())).resolves.toMatchObject({
id: 'pty-new'
})
expect(boundAfterFailure).toBeUndefined()
expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true)
})
it('reaps the replacement when its binding save fails, and the remount starts fresh', async () => {
const { provider, providerSpawn, store, runtime, control, leafBinding } =
installRestartHarness()
control.failNextWrite = true
vi.spyOn(console, 'error').mockImplementation(() => {})
registerWithFakes(mainWindow, runtime, store)
await expect(
handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' }))
).rejects.toThrow('ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED')
expect(provider.shutdown).toHaveBeenCalledWith(
'pty-new',
expect.objectContaining({ immediate: true })
)
const boundAfterFailure = leafBinding()
await expect(handlers.get('pty:spawn')!(null, restartSpawnArgs())).resolves.toMatchObject({
id: 'pty-new'
})
expect(boundAfterFailure).toBeUndefined()
expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true)
})
it('refuses an old restart after another owner has already claimed the pane', async () => {
const { shutdown, providerSpawn, store, runtime } = installRestartHarness()
store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] = 'successor'
registerWithFakes(mainWindow, runtime, store)
await expect(
handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' }))
).rejects.toThrow('terminal_pane_owner_changed')
expect(shutdown).not.toHaveBeenCalled()
expect(providerSpawn).not.toHaveBeenCalled()
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
})
it('reaps the replacement when a successor binds the pane during its spawn', async () => {
const { provider, providerSpawn, store, runtime, leafBinding } = installRestartHarness()
providerSpawn.mockImplementationOnce(async () => {
store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] = 'successor'
return { id: 'pty-new', incarnationId: 'inc-new' }
})
registerWithFakes(mainWindow, runtime, store)
await expect(
handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' }))
).rejects.toThrow('terminal_pane_owner_changed')
expect(provider.shutdown).toHaveBeenCalledWith(
'pty-new',
expect.objectContaining({ immediate: true })
)
expect(leafBinding()).toBe('successor')
})
/** The SSH pane lives only in its host partition; the local leaf keeps an unrelated id. */
async function withSshRestart(
run: (
host: RestartHarness & { oldId: string; newId: string; hostId: string },
restart: (extra?: { replacesPtyId?: string }) => Promise<unknown>
) => Promise<void>
): Promise<void> {
const harness = installRestartHarness()
const connectionId = 'restart-ssh'
const hostId = 'ssh:restart-ssh'
const oldId = toAppSshPtyId(connectionId, 'pty-old')
const newId = toAppSshPtyId(connectionId, 'pty-new')
harness.partitions[hostId] = seedSession(oldId)
Object.assign(harness.store, {
markSshRemotePtyLease: vi.fn(),
upsertSshRemotePtyLease: vi.fn(),
removeSshRemotePtyLease: vi.fn(),
supersedeSshRemotePtyLeasesForBoundPane: vi.fn()
})
let oldAlive = true
harness.shutdown.mockImplementation(async () => {
oldAlive = false
})
harness.providerSpawn.mockImplementation(async (options) => {
if (!options.attachOnly) {
return { id: newId, incarnationId: 'inc-new' }
}
if (!oldAlive) {
throw new TerminalKilledError(oldId)
}
return { id: oldId, incarnationId: 'inc-old', isReattach: true }
})
registerSshPtyProvider(
connectionId,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: same fake provider surface used by the local restart fixture.
harness.provider as unknown as Parameters<typeof registerSshPtyProvider>[1]
)
const localSpawn = vi.fn(() => {
throw new Error('wrong execution host')
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the test must never reach the local provider.
setLocalPtyProvider({
...harness.provider,
spawn: localSpawn,
shutdown: localSpawn
} as unknown as Parameters<typeof setLocalPtyProvider>[0])
registerWithFakes(mainWindow, harness.runtime, harness.store)
try {
await run({ ...harness, oldId, newId, hostId }, async (extra = {}) =>
handlers.get('pty:spawn')!(null, { ...restartSpawnArgs(extra), connectionId })
)
expect(localSpawn).not.toHaveBeenCalled()
expect(harness.leafBinding()).toBe('pty-old')
} finally {
unregisterSshPtyProvider(connectionId)
}
}
it('swaps the direct-SSH host binding without touching the local provider', async () => {
await withSshRestart(async (host, restart) => {
await expect(restart({ replacesPtyId: host.oldId })).resolves.toMatchObject({
id: host.newId
})
expect(host.shutdown).toHaveBeenCalledWith(
host.oldId,
expect.objectContaining({ immediate: true })
)
expect(host.store.persistPtyBinding).toHaveBeenCalledWith(expect.any(Function), host.hostId)
expect(host.leafBinding(host.hostId)).toBe(host.newId)
expect(host.providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true)
})
})
it('reaps a direct-SSH replacement when a successor binds the host pane during its spawn', async () => {
await withSshRestart(async (host, restart) => {
host.providerSpawn.mockImplementationOnce(async () => {
host.partitions[host.hostId].terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] =
'successor'
return { id: host.newId, incarnationId: 'inc-new' }
})
await expect(restart({ replacesPtyId: host.oldId })).rejects.toThrow(
'terminal_pane_owner_changed'
)
expect(host.shutdown).toHaveBeenCalledWith(
host.newId,
expect.objectContaining({ immediate: true })
)
expect(host.leafBinding(host.hostId)).toBe('successor')
})
})
it('clears the direct-SSH stopped binding when the replacement fails, so the remount starts fresh', async () => {
await withSshRestart(async (host, restart) => {
host.providerSpawn.mockRejectedValueOnce(new Error('spawn failed'))
await expect(restart({ replacesPtyId: host.oldId })).rejects.toThrow('spawn failed')
const boundAfterFailure = host.leafBinding(host.hostId)
await expect(restart()).resolves.toMatchObject({ id: host.newId })
expect(boundAfterFailure).toBeUndefined()
expect(host.providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true)
})
})
it.each(['darwin', 'linux', 'win32'])(
'restarts a folder workspace on %s without deleting its pane',
async (platform) => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
onTestFinished(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
})
const { store, runtime, leafBinding } = installRestartHarness()
const folderId = 'folder:restart-folder'
const session = store.getWorkspaceSession()
session.tabsByWorktree[folderId] = session.tabsByWorktree[worktreeId].map((tab) => ({
...tab,
worktreeId: folderId
}))
session.tabsByWorktree[worktreeId] = []
const folder = {
id: 'restart-folder',
folderPath: process.cwd(),
name: 'Restart',
projectGroupId: null
}
Object.assign(store, {
getFolderWorkspaces: vi.fn(() => [folder]),
getFolderWorkspace: vi.fn(() => folder)
})
registerWithFakes(mainWindow, runtime, store)
await expect(
handlers.get('pty:spawn')!(null, {
...restartSpawnArgs({ replacesPtyId: 'pty-old' }),
worktreeId: folderId,
cwd: process.cwd()
})
).resolves.toMatchObject({ id: 'pty-new' })
expect(store.getWorkspaceSession().tabsByWorktree[folderId]).toHaveLength(1)
expect(leafBinding()).toBe('pty-new')
expect(store.getWorkspaceSession().terminalLayoutsByTabId[tabId].root).toEqual({
type: 'leaf',
leafId
})
}
)
})
@@ -1,3 +1,4 @@
import { join } from 'node:path'
import { withFreshOmpLaunch } from '../../shared/omp-fresh-launch'
import { describe, expect, it, vi } from 'vitest'
import {
@@ -7,10 +8,12 @@ import {
mimoCodeBuildPtyEnvMock,
piBuildPtyEnvMock
} from './pty-ipc-mock-registry'
import { posixOnlyIt } from './pty-ipc-test-constants'
import { posixOnlyIt, TEST_MANAGED_ROOT } from './pty-ipc-test-constants'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing'
const consumerConfigHome = join(TEST_MANAGED_ROOT, 'opencode-consumer-config')
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
@@ -99,11 +102,13 @@ describe('registerPtyHandlers', () => {
it('mirrors the original OpenCode source dir when launched from an Orca overlay shell', async () => {
const env = await spawnAndGetEnv({
OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/tmp/user-opencode-config'
ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/tmp/user-opencode-config',
XDG_CONFIG_HOME: consumerConfigHome
})
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/tmp/user-opencode-config'
'/tmp/user-opencode-config',
join(consumerConfigHome, 'opencode')
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
@@ -112,10 +117,15 @@ describe('registerPtyHandlers', () => {
it('does not treat inherited Orca OpenCode config as user config without a source dir', async () => {
const env = await spawnAndGetEnv({
OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay'
ORCA_OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
XDG_CONFIG_HOME: consumerConfigHome
})
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined)
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
undefined,
join(consumerConfigHome, 'opencode')
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
@@ -222,16 +232,20 @@ describe('registerPtyHandlers', () => {
return ''
})
const env = await spawnAndGetEnv(undefined, {
HOME: '/home/pim',
SHELL: '/bin/zsh',
OPENCODE_CONFIG_DIR: undefined,
ORCA_OPENCODE_SOURCE_CONFIG_DIR: undefined
})
const env = await spawnAndGetEnv(
{ XDG_CONFIG_HOME: consumerConfigHome },
{
HOME: '/home/pim',
SHELL: '/bin/zsh',
OPENCODE_CONFIG_DIR: undefined,
ORCA_OPENCODE_SOURCE_CONFIG_DIR: undefined
}
)
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/home/pim/company/opencode-config'
'/home/pim/company/opencode-config',
join(consumerConfigHome, 'opencode')
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
+3 -1
View File
@@ -30,6 +30,7 @@ export type PrepareCodexResumeHomeArgs = {
providerSession?: AgentProviderSessionMetadata
target: CodexAccountSelectionTarget
launchEnv?: NodeJS.ProcessEnv
useSelectedAccount?: boolean
}
export function prepareCodexResumeHome(
@@ -48,7 +49,8 @@ export function prepareCodexResumeHome(
preparation: prepareCodexSessionResume({
providerSession,
target: args.target,
launchEnv: args.launchEnv
launchEnv: args.launchEnv,
...(args.useSelectedAccount ? { useSelectedAccount: true } : {})
})
}
}
+10 -2
View File
@@ -6,6 +6,8 @@ import { openCode2HookService, openCodeHookService } from '../../../opencode/hoo
import { resolveOpenCodeSourceConfigDir, restoreOrStripOverlayEnv } from './pi-agent'
import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command'
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
import { resolveOpenCodeConfigDirectory } from '../../../../shared/opencode-config-directory'
import { readSessionShellStartupEnvVar } from '../../../pty/shell-startup-env'
import type { BuildPtyHostEnvOptions } from './types'
type OpenCodeSourceConfig = {
@@ -41,7 +43,7 @@ export function applyOpenCodeStatusPluginEnv(
config: OpenCodeSourceConfig,
options: Pick<
BuildPtyHostEnvOptions,
'launchAgent' | 'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'isWsl'
'launchAgent' | 'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'isWsl' | 'shellPath'
>,
command: string | undefined
): 'opencode' | 'opencode2' | null {
@@ -69,7 +71,13 @@ export function applyOpenCodeStatusPluginEnv(
env.ORCA_OPENCODE_AGENT = agent
// WSL owns its config writes; only the guest overlay may enter a WSL pane.
if (!options.isWsl) {
Object.assign(env, service.buildPtyEnv(id, config.directory))
const executionEnv = { ...process.env, ...env }
const shellConfigHome = readSessionShellStartupEnvVar('XDG_CONFIG_HOME', env, options.shellPath)
const defaultConfigDir = resolveOpenCodeConfigDirectory({
...executionEnv,
XDG_CONFIG_HOME: shellConfigHome ?? executionEnv.XDG_CONFIG_HOME
})
Object.assign(env, service.buildPtyEnv(id, config.directory, defaultConfigDir))
}
if (env.OPENCODE_CONFIG_DIR) {
// Shell startup can re-export the default; preserve this pane's overlay and original source.
@@ -77,6 +77,85 @@ afterEach(() => {
})
describe('OpenCode installation uses the current enabled agents', () => {
it.each(
process.platform === 'win32' ? (['home', 'xdg'] as const) : (['home', 'xdg', 'shell'] as const)
)(
'consumer config root follows the %s execution environment without redirecting JSON',
(kind) => {
const home = join(root, 'consumer-home')
const xdg = join(root, 'consumer-xdg')
const consumer = kind === 'home' ? join(home, '.config', 'opencode') : join(xdg, 'opencode')
mkdirSync(home, { recursive: true })
mkdirSync(consumer, { recursive: true })
const settings = '{"model":"consumer-model","description":"external-settings"}'
writeFileSync(join(consumer, 'opencode.json'), settings)
if (kind === 'shell') {
writeFileSync(join(home, '.zshrc'), `export XDG_CONFIG_HOME='${xdg}'\n`)
}
const input = {
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: kind === 'xdg' ? xdg : '',
SHELL: kind === 'shell' ? '/bin/zsh' : '/bin/sh'
}
const env = buildPtyHostEnv(
'folder-pane',
{ ...input },
{ ...options, launchAgent: 'opencode' }
)
expect(existsSync(plugin(consumer, 'opencode'))).toBe(true)
expect(existsSync(plugin(config, 'opencode'))).toBe(false)
expect(readFileSync(join(consumer, 'opencode.json'), 'utf8')).toBe(settings)
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
for (const key of ['HOME', 'USERPROFILE', 'XDG_CONFIG_HOME', 'SHELL'] as const) {
expect(env[key]).toBe(input[key])
}
}
)
it.skipIf(process.platform === 'win32').each([
{ selected: '/bin/bash', inherited: '/bin/zsh', file: '.bash_profile' },
{ selected: '/bin/zsh', inherited: '/bin/bash', file: '.zshrc' }
])('consumer config follows selected $selected over inherited $inherited', (selection) => {
const home = join(root, 'selected-shell-home')
const selectedXdg = join(root, 'selected-shell-xdg')
const inheritedXdg = join(root, 'inherited-shell-xdg')
mkdirSync(home)
writeFileSync(
join(home, '.bash_profile'),
`export XDG_CONFIG_HOME='${selection.file === '.bash_profile' ? selectedXdg : inheritedXdg}'\n`
)
writeFileSync(
join(home, '.zshrc'),
`export XDG_CONFIG_HOME='${selection.file === '.zshrc' ? selectedXdg : inheritedXdg}'\n`
)
const input = { HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: '', SHELL: selection.inherited }
const launchOptions = {
...options,
launchAgent: 'opencode' as const,
shellPath: selection.selected
}
const env = buildPtyHostEnv('selected-shell-pane', input, launchOptions)
expect(existsSync(plugin(join(selectedXdg, 'opencode'), 'opencode'))).toBe(true)
expect(existsSync(plugin(join(inheritedXdg, 'opencode'), 'opencode'))).toBe(false)
expect(env.SHELL).toBe(selection.inherited)
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
})
it.each([false, true])('consumer config root is untouched with hooks disabled %s', (disabled) => {
const consumer = join(root, 'disabled-consumer')
buildPtyHostEnv(
'pane',
{ XDG_CONFIG_HOME: consumer },
{
...options,
agentStatusHooksEnabled: disabled,
disabledTuiAgents: disabled ? ['opencode', 'opencode2'] : []
}
)
expect(existsSync(join(consumer, 'opencode'))).toBe(false)
})
it('refuses spawn if a prepared startup intent loses its owned installer', () => {
mkdirSync(fixture.userData, { recursive: true })
writeFileSync(
+3
View File
@@ -23,6 +23,8 @@ export type BuildPtyHostEnvOptions = {
launchCommand?: string
/** Trusted agent identity for wrapped commands that cannot be recognized from text. */
launchAgent?: TuiAgent
/** Selected execution shell, which may differ from inherited SHELL. */
shellPath?: string
isWsl?: boolean
/** Distro for WSL spawns (null = Windows default distro); drives the WSL hook relay + endpoint repoint. Only read when isWsl. */
wslDistro?: string | null
@@ -53,6 +55,7 @@ export type PrepareCodexSessionResume = (args: {
providerSession: AgentProviderSessionMetadata
target: CodexAccountSelectionTarget
launchEnv?: NodeJS.ProcessEnv
useSelectedAccount?: boolean
}) => Promise<CodexSessionResumePreparation | null>
export type CodexHomePtySpawnedLifecycleArgs = {
+20 -13
View File
@@ -13,11 +13,12 @@ import {
pendingRuntimePaneCreatesByOwnerKey
} from '../pane/spawn-reservation'
import { resolveStablePaneOwner } from '../pane/stable-owner'
import { excludeReplacedPaneOwner } from '../pane/pane-owner-replacement'
import type { PtyIpcSpawnState } from './spawn-state'
import type { PtySpawnIpcArgs } from './spawn-types'
/** The pane key a spawn names before preflight; null when it names no stable pane. */
function resolveEarlyPaneKey(args: PtySpawnIpcArgs): string | null {
export function resolveEarlyPaneKey(args: PtySpawnIpcArgs): string | null {
const leafId =
typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null
return typeof args.worktreeId === 'string' &&
@@ -45,12 +46,15 @@ export async function beginPtyIpcSpawn(
const initialStablePanePtyId = (() => {
try {
return !args.connectionId && initialPaneKey
? resolveStablePaneOwner(
ctx.deps.runtime,
ctx.deps.store,
initialPaneKey,
args.worktreeId,
args.connectionId
? excludeReplacedPaneOwner(
resolveStablePaneOwner(
ctx.deps.runtime,
ctx.deps.store,
initialPaneKey,
args.worktreeId,
args.connectionId
),
ctx.replacedPaneOwner
)?.ptyId
: undefined
} catch {
@@ -87,12 +91,15 @@ export async function beginPtyIpcSpawn(
}
ctx.earlyStablePaneOwner =
initialPaneKey && args.worktreeId
? resolveStablePaneOwner(
ctx.deps.runtime,
ctx.deps.store,
initialPaneKey,
args.worktreeId,
args.connectionId
? excludeReplacedPaneOwner(
resolveStablePaneOwner(
ctx.deps.runtime,
ctx.deps.store,
initialPaneKey,
args.worktreeId,
args.connectionId
),
ctx.replacedPaneOwner
)
: null
ctx.earlyWorktreeId = args.worktreeId
+8 -3
View File
@@ -3,6 +3,7 @@ import { closeStartupQueryAuthorityForPty, getRelayPtyId } from '../provider/reg
import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure'
import { recordCodexPaneAccountForSpawn } from '../host-env/codex-home'
import { persistAdmittedStablePaneBinding } from '../pane/stable-owner'
import { swapReplacedPaneBinding } from '../pane/pane-owner-replacement'
import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim'
import {
pendingByPaneKey,
@@ -60,9 +61,13 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<P
...(ctx.cwd ? { startupCwd: ctx.cwd } : {}),
origin: spawnCommitBindingOrigin(ctx.result)
}
const persisted = args.connectionId
? await ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId))
: await ctx.deps.store.persistPtyBinding(binding)
const hostId = args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined
const input = ctx.replacedPaneOwner
? swapReplacedPaneBinding(ctx.deps.store, binding, ctx.replacedPaneOwner, hostId)
: binding
const persisted = hostId
? await ctx.deps.store.persistPtyBinding(input, hostId)
: await ctx.deps.store.persistPtyBinding(input)
if (persisted === false) {
throw new Error('terminal_pane_owner_changed')
}
+3 -1
View File
@@ -39,7 +39,8 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis
launchAgent: args.launchAgent,
providerSession: args.resumeProviderSession,
target: ctx.codexSelectionTarget,
launchEnv: ctx.baseEnv
launchEnv: ctx.baseEnv,
...(args.replacesPtyId ? { useSelectedAccount: true } : {})
})
ctx.codexResumeLaunch = codexResumePreparation
? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation)
@@ -138,6 +139,7 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis
stripInheritedOrcaCodexHome: ctx.stripInheritedOrcaCodexHome,
launchCommand: ctx.launchCommand,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
shellPath: ctx.effectiveShellOverride,
isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd),
wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings),
+15 -15
View File
@@ -10,6 +10,7 @@ import { getCohortAtEmit } from '../../../telemetry/cohort-classifier'
import { agentKindSchema } from '../../../../shared/telemetry-events'
import { normalizeNodePtySpawnError } from '../provider/liveness'
import { resolveStablePaneOwner, spawnForStablePane } from '../pane/stable-owner'
import { excludeReplacedPaneOwner } from '../pane/pane-owner-replacement'
import { assertSpawnReplyWasLive } from '../pane/agent-session-owners'
import { deletePtyOwnership } from '../provider/ownership-state'
import { ptySizes } from '../delivery/visibility-state'
@@ -23,13 +24,19 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
ctx.deps.trustedTerminalHandleEnv.add(ctx.preAllocatedHandle)
}
ctx.spawnTiming.mark('options')
const stablePaneOwnerCandidate = resolveStablePaneOwner(
ctx.deps.runtime,
ctx.deps.store,
ctx.reservationPaneKey,
args.worktreeId,
args.connectionId
)
// Why exclude: the restart's own stop leaves its binding for the bind to swap, not to reattach.
const resolveOwner = () =>
excludeReplacedPaneOwner(
resolveStablePaneOwner(
ctx.deps.runtime,
ctx.deps.store,
ctx.reservationPaneKey,
args.worktreeId,
args.connectionId
),
ctx.replacedPaneOwner
)
const stablePaneOwnerCandidate = resolveOwner()
const expectedPtyId =
stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.effectiveSessionId
if (expectedPtyId) {
@@ -56,14 +63,7 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
owner: stablePaneOwnerCandidate,
worktreeId: args.worktreeId,
connectionId: args.connectionId,
resolveOwner: () =>
resolveStablePaneOwner(
ctx.deps.runtime,
ctx.deps.store,
ctx.reservationPaneKey,
args.worktreeId,
args.connectionId
)
resolveOwner
})
ctx.result = stablePaneSpawn.result
ctx.stablePaneOwner = stablePaneSpawn.owner
@@ -0,0 +1,34 @@
import { resolveQoderTerminalCommand } from '../../../runtime/qoder-terminal-command-resolution'
import { resolveStartupShell } from '../../../../shared/tui-agent-startup-shell'
import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows-terminal-shell'
import type { PtyIpcSpawnState } from './spawn-state'
export async function preparePtyIpcQoderCommand(ctx: PtyIpcSpawnState): Promise<void> {
if (ctx.preAdoptedStablePane || ctx.args.launchAgent !== 'qoder') {
return
}
const connectionId = ctx.args.connectionId
const isWsl = ctx.codexSelectionTarget.runtime === 'wsl'
const platform = connectionId || isWsl ? 'linux' : process.platform
const launch = await resolveQoderTerminalCommand(
{
launchAgent: ctx.args.launchAgent,
command: ctx.launchCommand,
launchConfig: ctx.effectiveLaunchConfig
},
{
connectionId,
context: connectionId ? undefined : { wslDistro: ctx.expectedWslDistro, wslDefault: isWsl },
shell: resolveStartupShell(
platform,
resolveLocalWindowsAgentStartupShell({
platform,
isRemote: Boolean(connectionId),
terminalWindowsShell: ctx.effectiveShellOverride
})
)
}
)
ctx.launchCommand = launch.command
ctx.effectiveLaunchConfig = launch.launchConfig
}
+17 -4
View File
@@ -1,8 +1,14 @@
import { rejectPaneSpawnReservation, reserveIdlePaneSpawn } from '../pane/spawn-reservation'
import { ptySizes } from '../delivery/visibility-state'
import { beginPtyIpcSpawn, resolveEarlyPaneSpawnReservationKey } from './spawn-begin'
import {
beginPtyIpcSpawn,
resolveEarlyPaneKey,
resolveEarlyPaneSpawnReservationKey
} from './spawn-begin'
import { releaseStoppedPaneBinding, stopReplacedPaneOwner } from '../pane/pane-owner-replacement'
import { preparePtyIpcSpawnPreflight } from './spawn-preflight'
import { assemblePtyIpcSpawnEnv } from './spawn-env'
import { preparePtyIpcQoderCommand } from './spawn-qoder-command'
import { buildPtyIpcSpawnOptions } from './spawn-options'
import { executePtyIpcSpawn } from './spawn-execute'
import { commitPtyIpcSpawn } from './spawn-commit'
@@ -42,9 +48,12 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg
}
try {
if (args.replacesPtyId !== undefined) {
// Why: stop before resolving the pane owner, so the spawn below finds a dead owner and
// launches fresh instead of reattaching the process this restart exists to replace.
await deps.stopReplacedPty(args.replacesPtyId)
ctx.replacedPaneOwner = await stopReplacedPaneOwner(deps, {
replacesPtyId: args.replacesPtyId,
paneKey: resolveEarlyPaneKey(args),
worktreeId: args.worktreeId,
connectionId: args.connectionId
})
}
triggerPtySpawnPushTargetMaterialization(deps, args)
const early = await beginPtyIpcSpawn(ctx)
@@ -53,6 +62,7 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg
}
await preparePtyIpcSpawnPreflight(ctx)
await assemblePtyIpcSpawnEnv(ctx)
await preparePtyIpcQoderCommand(ctx)
const earlyReserved = await buildPtyIpcSpawnOptions(ctx).catch((error: unknown) => {
restoreProvisionalPtySize(ctx)
throw error
@@ -79,6 +89,9 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg
)
ctx.pendingRegistrationPtyId = null
}
if (ctx.replacedPaneOwner) {
await releaseStoppedPaneBinding(deps.store, ctx.replacedPaneOwner)
}
// Why: once the reservation is created, any later throw —
// spawn failure, persist failure, or a post-spawn helper such as
// seedHeadlessTerminal/registerPty/track — must settle it. Otherwise
+3
View File
@@ -8,6 +8,7 @@ import { createPtySpawnTiming } from '../../pty-spawn-timing'
import { noCodexResumeLaunch, type CodexResumeLaunch } from '../host-env/codex-resume'
import type { StablePaneOwner } from '../pane/stable-owner'
import type { PaneSpawnReservation } from '../pane/spawn-reservation'
import type { ReplacedPaneOwner } from '../pane/pane-owner-replacement'
import { localProvider } from '../provider/registry'
import type { AdoptStablePaneResult, PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types'
@@ -25,6 +26,7 @@ export type PtyIpcSpawnState = {
earlyWorktreeId: string | undefined
paneSpawnReservationKey: string | null
paneSpawnReservation: PaneSpawnReservation | null
replacedPaneOwner: ReplacedPaneOwner | null
finishTerminalInstall: () => void
result: PtySpawnResult
stablePaneOwner: StablePaneOwner | null
@@ -103,6 +105,7 @@ export function createPtyIpcSpawnState(
earlyWorktreeId: undefined,
paneSpawnReservationKey: null,
paneSpawnReservation: null,
replacedPaneOwner: null,
finishTerminalInstall: () => {},
result: { id: '' },
stablePaneOwner: null,
+1
View File
@@ -104,6 +104,7 @@ export type PtySpawnIpcDeps = {
providerSession?: AgentProviderSessionMetadata
target: CodexAccountSelectionTarget
launchEnv?: NodeJS.ProcessEnv
useSelectedAccount?: boolean
}) => PreparedCodexResumeHome | null
noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch
resolveCodexResumeLaunch: (
@@ -0,0 +1,108 @@
import { expect, it, vi } from 'vitest'
import { fixture } from '../../../persistence/loading-store/profile-state-delayed-authority-fixture'
import { TEST_LEAF_1, TEST_LEAF_2 } from '../../../persistence-session-fixtures'
import { swapReplacedPaneBinding, type ReplacedPaneOwner } from './pane-owner-replacement'
vi.mock('../../../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../../../telemetry/cohort-classifier', () => ({
getCohortAtEmit: () => ({ nth_repo_added: 2 })
}))
vi.mock('../../../ssh/ssh-config-parser', () => ({
loadUserSshConfig: () => ({ hosts: [] }),
sshConfigHostsToTargets: () => []
}))
const worktreeId = 'repo-local::/fixture/local'
const tabId = 'restart-tab'
function replaced(ptyId: string, hostId?: string): ReplacedPaneOwner {
return { ptyId, pane: { worktreeId, tabId, leafId: TEST_LEAF_1, hostId } }
}
type FixtureStore = Awaited<ReturnType<typeof fixture>>['store']
/** The binding swap must survive the renderer's own pre-connect clear, which strict fences refuse. */
async function expectSwapAfterRendererClear(
store: FixtureStore,
oldId: string,
newId: string,
hostId?: string
): Promise<void> {
expect(
store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_1]
).toBeUndefined()
const binding = { worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: newId, incarnationId: 'inc-new' }
expect(
await store.persistPtyBinding(
{ ...binding, expectedBinding: { ptyId: oldId, incarnationId: 'inc-old' } },
hostId
)
).toBe(false)
expect(
await store.persistPtyBinding(
swapReplacedPaneBinding(store, binding, replaced(oldId, hostId), hostId),
hostId
)
).toBe(true)
expect(
store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_1]
).toBe(newId)
}
it('swaps a local split pane whose renderer layout patch already dropped the stopped binding', async () => {
const { store } = await fixture()
const pane = { worktreeId, tabId }
await store.persistPtyBinding({
...pane,
leafId: TEST_LEAF_1,
ptyId: 'old',
incarnationId: 'inc-old'
})
await store.persistPtyBinding({ ...pane, leafId: TEST_LEAF_2, ptyId: 'sibling' })
const layouts = structuredClone(store.getWorkspaceSession().terminalLayoutsByTabId)
// The mounted restart clears only its own leaf before connecting; a partial map is honored.
delete layouts[tabId].ptyIdsByLeafId![TEST_LEAF_1]
store.patchWorkspaceSession({ terminalLayoutsByTabId: layouts })
await expectSwapAfterRendererClear(store, 'old', 'new')
expect(
store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_2]
).toBe('sibling')
})
it('swaps an SSH pane whose terminated lease let the renderer clear withdraw the binding', async () => {
const { store } = await fixture()
const hostId = 'ssh:restart'
const oldId = 'ssh:restart@@remote-old'
await store.persistPtyBinding(
{ worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: oldId, incarnationId: 'inc-old' },
hostId
)
store.upsertSshRemotePtyLease({
targetId: 'restart',
ptyId: 'remote-old',
worktreeId,
tabId,
leafId: TEST_LEAF_1,
state: 'attached'
})
// What the replacement stop records (finishPtyShutdown).
store.markSshRemotePtyLease('restart', 'remote-old', 'terminated')
const layouts = structuredClone(store.getWorkspaceSession(hostId).terminalLayoutsByTabId)
layouts[tabId].ptyIdsByLeafId = {}
store.patchWorkspaceSession({ terminalLayoutsByTabId: layouts }, hostId)
await expectSwapAfterRendererClear(store, oldId, 'ssh:restart@@remote-new', hostId)
})
it('refuses the swap when another owner holds the leaf', async () => {
const { store } = await fixture()
await store.persistPtyBinding({ worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: 'successor' })
const binding = { worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: 'new' }
expect(
await store.persistPtyBinding(
swapReplacedPaneBinding(store, binding, replaced('old'), undefined)
)
).toBe(false)
expect(
store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_1]
).toBe('successor')
})
@@ -0,0 +1,96 @@
import { toSshExecutionHostId } from '../../../../shared/execution-host'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import type { Store } from '../../../persistence'
import type { PersistPtyBindingArgs } from '../../../persistence/loading-store/pty-binding-persistence'
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
import { resolveStablePaneOwner, type StablePaneOwner } from './stable-owner'
/** The owner a replacing spawn stopped. Its binding stays until the replacement's bind swaps it. */
export type ReplacedPaneOwner = {
ptyId: string
pane: { worktreeId: string; tabId: string; leafId: string; hostId: string | undefined } | null
}
/** Caller holds the pane's spawn reservation, so no other spawn can claim the pane meanwhile. */
export async function stopReplacedPaneOwner(
deps: {
runtime?: OrcaRuntimeService
store?: Store
stopReplacedPty: (id: string) => Promise<void>
},
args: {
replacesPtyId: string
paneKey: string | null
worktreeId: string | undefined
connectionId: string | null | undefined
}
): Promise<ReplacedPaneOwner> {
const owner = resolveStablePaneOwner(
deps.runtime,
deps.store,
args.paneKey,
args.worktreeId,
args.connectionId
)
if (owner && owner.ptyId !== args.replacesPtyId) {
throw new Error('terminal_pane_owner_changed')
}
await deps.stopReplacedPty(args.replacesPtyId)
const pane = args.paneKey ? parsePaneKey(args.paneKey) : null
return {
ptyId: args.replacesPtyId,
pane:
pane && args.worktreeId
? {
worktreeId: args.worktreeId,
tabId: pane.tabId,
leafId: pane.leafId,
hostId: args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined
}
: null
}
}
/** The stopped id is dead whatever its incarnation, so a binding naming it must not be reattached. */
export function excludeReplacedPaneOwner(
owner: StablePaneOwner | null,
replaced: ReplacedPaneOwner | null
): StablePaneOwner | null {
return owner && owner.ptyId === replaced?.ptyId ? null : owner
}
/**
* The replacement's bind is the swap: decided inside the bind's durable mutation, it takes a leaf
* still naming the stopped id, or one the renderer already cleared; any other owner wins.
*/
export function swapReplacedPaneBinding(
store: Store,
binding: PersistPtyBindingArgs,
replaced: ReplacedPaneOwner,
hostId: string | undefined
): () => PersistPtyBindingArgs | null {
return () => {
const bound =
store.getWorkspaceSession(hostId).terminalLayoutsByTabId?.[binding.tabId]?.ptyIdsByLeafId?.[
binding.leafId
]
// Unbound is ok: the renderer's pre-connect clear can withdraw it (split tab, SSH lease ended).
return bound === undefined || bound === replaced.ptyId ? binding : null
}
}
/** Best effort: a failed replacement must not leave the stopped id bound for the remount to reattach. */
export async function releaseStoppedPaneBinding(
store: Store | undefined,
replaced: ReplacedPaneOwner
): Promise<void> {
if (!store || !replaced.pane) {
return
}
const { hostId, ...pane } = replaced.pane
try {
await store.retirePtyBinding({ ...pane, ptyId: replaced.ptyId }, hostId)
} catch (error) {
console.warn('[pty] could not clear the stopped pane binding after a failed restart:', error)
}
}
@@ -89,6 +89,7 @@ export function configureLocalPtyProvider(args: {
}),
launchCommand: ctx?.command,
launchAgent: ctx?.launchAgent,
shellPath: ctx?.shellPath,
isWsl: ctx?.isWsl,
wslDistro: ctx?.wslDistro ?? null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings),
@@ -272,6 +272,7 @@ export async function prepareRuntimePtySpawn(
stripInheritedOrcaCodexHome: ctx.stripInheritedOrcaCodexHome,
launchCommand: ctx.launchCommand,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
shellPath: ctx.daemonShellOverride,
isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd),
wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings),
@@ -0,0 +1,299 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures'
import {
registerPtyHandlers,
registerSshPtyProvider,
unregisterSshPtyProvider,
getLocalPtyProvider
} from './pty'
import { isCommandOnPath } from './preflight-command-exec'
import { detectWslCommandsOnPath } from './preflight-wsl-agent-detection'
import type * as FsPromises from 'node:fs/promises'
import type { Store } from '../persistence'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup'
import type { PtySpawnIpcArgs } from './pty/ipc/spawn-types'
vi.mock('./preflight-command-exec', () => ({ isCommandOnPath: vi.fn() }))
vi.mock('./local-agent-install-dir-detection', () => ({
detectCommandsInInstallDirs: () => new Set()
}))
vi.mock('./preflight-wsl-agent-detection', () => ({ detectWslCommandsOnPath: vi.fn() }))
const { mux } = vi.hoisted(() => ({ mux: vi.fn() }))
vi.mock('../ssh/ssh-target-registry', () => ({ getActiveMultiplexer: mux }))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof FsPromises>()),
stat: vi.fn(async () => ({ isDirectory: () => true }))
}))
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
vi.mock('node:child_process', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
)
vi.mock('../opencode/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
)
vi.mock('../mimo/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
)
vi.mock('../agent-hooks/server', () =>
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
)
vi.mock('../pi/titlebar-extension-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
)
vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock()))
vi.mock('../wsl', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
)
vi.mock('../telemetry/client', () =>
import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
)
vi.mock('../telemetry/classify-error', () =>
import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
)
vi.mock('../cli/linux-terminal-orca-cli-shim', () =>
import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
)
vi.mock('../memory/pty-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
)
vi.mock('../agent-hooks/migration-unsupported-pty-state', () =>
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
)
vi.mock('../codex/codex-pane-account-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
)
vi.mock('../codex/codex-state-db-backfill-recovery', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
)
describe('desktop Qoder execution-host selection', () => {
const { handlers, mainWindow } = setupPtyIpcSuite()
const { setupDaemonAdapter, withWin32Platform } = createDaemonActiveProviderFixtures({
handlers,
mainWindow
})
const nativeProbe = vi.mocked(isCommandOnPath)
const wslProbe = vi.mocked(detectWslCommandsOnPath)
beforeEach(() => {
nativeProbe.mockReset().mockImplementation(async (cmd) => cmd === 'qodercli')
wslProbe.mockReset().mockResolvedValue(new Set(['qoder']))
mux.mockReset()
})
async function spawn(args: Partial<PtySpawnIpcArgs> = {}, store?: Store) {
const providerSpawn = setupDaemonAdapter()
registerPtyHandlers(mainWindow, undefined, undefined, undefined, undefined, store)
const result = await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: process.cwd(),
worktreeId: `repo-review::${process.cwd()}`,
launchAgent: 'qoder',
command: 'qodercli --resume original-id',
...args
})
return { providerSpawn, result }
}
function folderStore(folderPath: string): Store {
const folder: FolderWorkspace = {
id: 'review-folder',
projectGroupId: 'review-group',
name: 'review',
folderPath,
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
createdAt: 0,
updatedAt: 0
}
const store: Pick<
Store,
'getFolderWorkspace' | 'getFolderWorkspaces' | 'getProjectGroups' | 'getRepos'
> = {
getFolderWorkspace: () => folder,
getFolderWorkspaces: () => [folder],
getProjectGroups: () => [],
getRepos: () => []
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These launches omit pane metadata; only folder path/status methods are reached, trust is disabled by the suite.
return store as Store
}
it.each([
['legacy-only', ['qodercli']],
['both', ['qoder', 'qodercli']]
] as const)('keeps legacy preference on %s native hosts', async (_, commands) => {
nativeProbe.mockImplementation(async (cmd) => commands.some((found) => found === cmd))
const { providerSpawn } = await spawn()
expect(providerSpawn).toHaveBeenCalledWith(
expect.objectContaining({ command: 'qodercli --resume original-id' })
)
expect(nativeProbe).toHaveBeenCalledWith('qodercli')
expect(nativeProbe).toHaveBeenCalledWith('qoder')
expect(wslProbe).not.toHaveBeenCalled()
})
it('preserves exact resume tokens and captured configuration on a native folder', async () => {
nativeProbe.mockImplementation(async (cmd) => cmd === 'qoder')
const plan = buildAgentResumeStartupPlan({
agent: 'qoder',
providerSession: { key: 'session_id', id: 'session with spaces' },
platform: 'darwin',
cmdOverrides: {}
})
if (!plan) {
throw new Error('Missing Qoder resume plan')
}
const { providerSpawn, result } = await spawn(
{
worktreeId: 'folder:review-folder',
command: plan.launchCommand,
launchConfig: plan.launchConfig
},
folderStore(process.cwd())
)
expect(providerSpawn).toHaveBeenCalledWith(
expect.objectContaining({
command: "qoder '--resume' 'session with spaces'",
cwd: process.cwd(),
worktreeId: 'folder:review-folder'
})
)
expect(result).toMatchObject({ launchConfig: { ...plan.launchConfig, agentCommand: 'qoder' } })
})
it.each([
{ command: "echo 'qodercli --resume text'", launchAgent: 'qoder' },
{ command: 'qodercli --resume original-id', launchAgent: undefined },
{ command: '/caller/qodercli --resume original-id', launchAgent: 'qoder' },
{ command: 'qoder --resume original-id', launchAgent: 'qoder' }
] as const)('leaves caller and shell commands unchanged: $command', async (args) => {
const { providerSpawn } = await spawn(args)
expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ command: args.command }))
expect(nativeProbe).not.toHaveBeenCalled()
expect(wslProbe).not.toHaveBeenCalled()
expect(mux).not.toHaveBeenCalled()
})
it('uses the selected project WSL distro rather than installed native legacy', async () => {
await withWin32Platform(async () => {
const { providerSpawn } = await spawn({
cwd: '/work/project',
worktreeId: 'repo-review::/work/project',
projectRuntime: {
status: 'resolved',
runtime: {
kind: 'wsl',
hostPlatform: 'wsl',
projectId: 'project-review',
distro: 'Debian',
reason: 'project-override',
cacheKey: 'review'
}
}
})
expect(providerSpawn).toHaveBeenCalledWith(
expect.objectContaining({
command: 'qoder --resume original-id',
shellOverride: 'wsl.exe',
terminalWindowsWslDistro: 'Debian'
})
)
expect(wslProbe).toHaveBeenCalledWith({ distro: 'Debian' }, ['qodercli', 'qoder'])
expect(nativeProbe).not.toHaveBeenCalled()
})
})
it('retains a repo-less UNC folder execution distro', async () => {
await withWin32Platform(async () => {
const { providerSpawn } = await spawn(
{
cwd: String.raw`\\wsl.localhost\Ubuntu\home\review\folder`,
worktreeId: 'folder:review-folder',
shellOverride: 'wsl.exe'
},
folderStore(String.raw`\\wsl.localhost\Ubuntu\home\review\folder`)
)
expect(providerSpawn).toHaveBeenCalledWith(
expect.objectContaining({
command: 'qoder --resume original-id',
terminalWindowsWslDistro: 'Ubuntu',
cwd: '//wsl.localhost/Ubuntu/home/review/folder'
})
)
expect(wslProbe).toHaveBeenCalledWith({ distro: 'Ubuntu' }, ['qodercli', 'qoder'])
expect(nativeProbe).not.toHaveBeenCalled()
})
})
it('keeps native Windows launches on the host shell', async () => {
await withWin32Platform(async () => {
nativeProbe.mockImplementation(async (cmd) => cmd === 'qoder')
const { providerSpawn } = await spawn({
cwd: 'C:\\review',
worktreeId: 'repo-review::C:\\review',
shellOverride: 'cmd.exe'
})
expect(providerSpawn).toHaveBeenCalledWith(
expect.objectContaining({
command: 'qoder --resume original-id',
shellOverride: 'cmd.exe',
terminalWindowsWslDistro: null
})
)
expect(wslProbe).not.toHaveBeenCalled()
})
})
it.each(['modern-only', 'legacy-only', 'missing', 'disposed'] as const)(
'lets SSH own detection and refuse absent contact: %s',
async (host) => {
const remoteSpawn = setupDaemonAdapter()
registerSshPtyProvider('ssh-qoder-review', getLocalPtyProvider())
const localSpawn = setupDaemonAdapter()
const request = vi
.fn()
.mockResolvedValue({ agents: [host === 'modern-only' ? 'qoder' : 'qodercli'] })
mux.mockReturnValue(
host === 'missing' ? null : { isDisposed: () => host === 'disposed', request }
)
registerPtyHandlers(mainWindow)
try {
const pending = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/remote/folder',
worktreeId: 'repo-ssh::/remote/folder',
connectionId: 'ssh-qoder-review',
launchAgent: 'qoder',
command: "qodercli '--resume' 'same remote id'"
})
if (host === 'missing' || host === 'disposed') {
await expect(pending).rejects.toThrow('execution host connection')
expect(remoteSpawn).not.toHaveBeenCalled()
expect(request).not.toHaveBeenCalled()
} else {
await pending
expect(remoteSpawn).toHaveBeenCalledWith(
expect.objectContaining({
cwd: '/remote/folder',
command: `${host === 'modern-only' ? 'qoder' : 'qodercli'} '--resume' 'same remote id'`
})
)
expect(request).toHaveBeenCalledWith('preflight.detectAgents', {
commands: [
{ id: 'qodercli', cmd: 'qodercli' },
{ id: 'qoder', cmd: 'qoder' }
]
})
}
expect(localSpawn).not.toHaveBeenCalled()
expect(nativeProbe).not.toHaveBeenCalled()
expect(wslProbe).not.toHaveBeenCalled()
expect(mux).toHaveBeenCalledWith('ssh-qoder-review')
} finally {
unregisterSshPtyProvider('ssh-qoder-review')
}
}
)
})
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from 'vitest'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures'
import { registerPtyHandlers } from './pty'
import { detectAgentCommandsOnHost } from '../preflight/agent-detection'
import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup'
import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry'
import { trackMock } from './pty-ipc-mock-registry'
vi.mock('../preflight/agent-detection', () => ({
detectAgentCommandsOnHost: vi.fn(async () => new Set(['qoder']))
}))
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
vi.mock('node:child_process', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
)
vi.mock('../opencode/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
)
vi.mock('../mimo/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
)
vi.mock('../agent-hooks/server', () =>
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
)
vi.mock('../pi/titlebar-extension-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
)
vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock()))
vi.mock('../wsl', async (importOriginal) =>
(await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
)
vi.mock('../telemetry/client', () =>
import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
)
vi.mock('../telemetry/classify-error', () =>
import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
)
vi.mock('../cli/linux-terminal-orca-cli-shim', () =>
import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
)
vi.mock('../memory/pty-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
)
vi.mock('../agent-hooks/migration-unsupported-pty-state', () =>
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
)
vi.mock('../codex/codex-pane-account-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
)
vi.mock('../codex/codex-state-db-backfill-recovery', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
)
describe('independent renderer Qoder provider spawn boundary', () => {
const { handlers, mainWindow } = setupPtyIpcSuite()
const { setupDaemonAdapter } = createDaemonActiveProviderFixtures({ handlers, mainWindow })
it.each(['start', 'resume'] as const)(
'selects modern-only executable for desktop %s',
async (mode) => {
const plan =
mode === 'start'
? buildAgentStartupPlan({
agent: 'qoder',
prompt: 'review prompt',
agentArgs: null,
cmdOverrides: {},
platform: 'darwin'
})
: buildAgentResumeStartupPlan({
agent: 'qoder',
providerSession: { key: 'session_id', id: 'original-review-session' },
cmdOverrides: {},
platform: 'darwin'
})
if (!plan) {
throw new Error('Missing existing Qoder startup plan')
}
const physicalSpawn = setupDaemonAdapter()
registerPtyHandlers(mainWindow)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: process.cwd(),
command: plan.launchCommand,
launchAgent: 'qoder',
launchConfig: plan.launchConfig,
...(mode === 'start' ? { telemetry: agentStartedTelemetry('qoder', 'orchestration') } : {})
})
expect(physicalSpawn.mock.calls.at(-1)?.[0].command).toBe(
plan.launchCommand.replace(/^qodercli/, 'qoder')
)
expect(detectAgentCommandsOnHost).toHaveBeenCalled()
const events = trackMock.mock.calls.filter(([event]) => event === 'agent_started')
if (mode === 'start') {
expect(events).toEqual([
[
'agent_started',
{ agent_kind: 'qoder', launch_source: 'orchestration', request_kind: 'new' }
]
])
} else {
expect(events).toEqual([])
}
}
)
it.each([
'qoder --resume original-review-session',
'/caller/qodercli --resume original-review-session'
])('preserves an explicitly chosen command: %s', async (command) => {
const physicalSpawn = setupDaemonAdapter()
registerPtyHandlers(mainWindow)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: process.cwd(),
command,
launchAgent: 'qoder'
})
expect(physicalSpawn.mock.calls.at(-1)?.[0].command).toBe(command)
expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything())
})
})
@@ -35,7 +35,6 @@ vi.mock('./runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: (...args: Parameters<typeof callRuntimeEnvironment>) =>
callRuntimeEnvironment(...args)
}))
vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} }))
// Why: see filesystem-runtime-upload-staging.test.ts — a real over-limit fixture
// would allocate gigabytes on Windows.
vi.mock('./runtime-import-limits', async (importOriginal) => ({
+1 -5
View File
@@ -5,7 +5,6 @@ import type {
RuntimeUploadFileStreamRequest,
StagedRuntimeUploadFileIdentity
} from '../../shared/runtime-upload-staging-contract'
import { authorizeExternalPath } from './filesystem-auth'
import { formatByteCeiling, REMOTE_IMPORT_MAX_FILE_BYTES } from './runtime-import-limits'
import {
isRuntimeEnvironmentManuallyDisconnected,
@@ -39,9 +38,6 @@ export async function streamExternalFileToRuntime(
): Promise<{ byteLength: number }> {
const sourcePath = resolveEntrySourcePath(args.sourceRootPath, args.entryRelativePath)
// Why: parity with staging — an OS drop authorizes the paths it hands over.
authorizeExternalPath(sourcePath)
// Why: relativePath is the hidden .orca-upload-<nonce> temp destination, so a
// dropped file names its source instead of a path the user never chose.
const displayPath = args.entryRelativePath || basename(args.sourceRootPath)
@@ -189,7 +185,7 @@ async function sendChunk(
}
function resolveEntrySourcePath(sourceRootPath: string, entryRelativePath: string): string {
// Why: staging resolves before authorizing, so the streamer has to agree on
// Why: staging resolves the source first, so the streamer has to agree on
// the same absolute path or the two checks can disagree.
const root = resolve(sourceRootPath)
return entryRelativePath ? join(root, entryRelativePath) : root
@@ -16,7 +16,6 @@ import type { StagedRuntimeUploadFileIdentity } from '../../shared/runtime-uploa
// Why: real limits, real host write flags ('wx' then 'a') and the real chunk
// schema — the slice loop is exercised exactly at the boundaries it must respect.
vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} }))
type ChunkParams = { relativePath: string; contentBase64: string; append: boolean }
type CallOptions = { expectedEnvironmentRuntimeId?: string; signal?: AbortSignal }
@@ -6,6 +6,24 @@ import {
} from './tui-agent-detection-commands'
describe('tui agent detection commands', () => {
it.each(['darwin', 'linux', 'win32', 'wsl'] as const)(
'detects modern and legacy Qoder once on %s',
(runtime) => {
const commands = KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter(
(command) => command.id === 'qoder'
)
for (const found of [
new Set(['qoder']),
new Set(['qodercli']),
new Set(['qoder', 'qodercli'])
]) {
expect(resolveDetectedTuiAgentIds(commands, found, runtime)).toEqual(['qoder'])
}
expect(resolveDetectedTuiAgentIds(commands, new Set(['qoder-unrelated']), runtime)).toEqual(
[]
)
}
)
it('requires Claude before reporting Claude Agent Teams', () => {
const commands = KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter(
(command) => command.id === 'claude-agent-teams'
+23
View File
@@ -119,6 +119,29 @@ describe('Jira issue operations', () => {
)
})
it('scopes create-time assignable search by project, with the Server username param', async () => {
jiraRequestMock.mockResolvedValueOnce([{ accountId: 'acc-1', displayName: 'Ada' }])
const { listAssignableUsersForProject } = await import('./issues')
const cloudUsers = await listAssignableUsersForProject('ALP', 'ada', 'site-1')
expect(jiraRequestMock).toHaveBeenCalledWith(
expect.anything(),
'/rest/api/3/user/assignable/search?project=ALP&maxResults=50&query=ada'
)
expect(cloudUsers).toEqual([expect.objectContaining({ accountId: 'acc-1' })])
getClientsMock.mockReturnValue([makeServerEntry()])
jiraRequestMock.mockResolvedValueOnce([])
await listAssignableUsersForProject('ALP', 'ada', 'server-1')
expect(jiraRequestMock).toHaveBeenLastCalledWith(
expect.anything(),
'/rest/api/2/user/assignable/search?project=ALP&maxResults=50&username=ada'
)
})
it('loads Jira summaries without descriptions, rendered fields, or attachment media', async () => {
jiraRequestMock.mockResolvedValueOnce({
id: 'issue-1',
+1
View File
@@ -6,6 +6,7 @@ export { getIssueComments } from './jira-issue-comments'
export { listProjects } from './jira-project-queries'
export {
listAssignableUsers,
listAssignableUsersForProject,
listCreateFields,
listIssueTypes,
listPriorities,
+29 -6
View File
@@ -162,17 +162,18 @@ export async function searchUsers(query?: string, siteId?: string | null): Promi
}
}
export async function listAssignableUsers(
key: string,
query?: string,
siteId?: string | null
async function searchAssignableUsers(
scope: { issueKey: string } | { project: string },
query: string | undefined,
siteId: string | null | undefined,
label: string
): Promise<JiraUser[]> {
const entry = getClients(siteId)[0]
if (!entry) {
return []
}
const isServer = entry.site.authType === 'server'
const params = new URLSearchParams({ issueKey: key, maxResults: '50' })
const params = new URLSearchParams({ ...scope, maxResults: '50' })
if (query?.trim()) {
// Server/DC filters assignable users by `username`; `query` is Cloud-only.
params.set(isServer ? 'username' : 'query', query.trim())
@@ -189,9 +190,31 @@ export async function listAssignableUsers(
clearToken(entry.site.id)
throw error
}
console.warn('[jira] listAssignableUsers failed:', error)
console.warn(`[jira] ${label} failed:`, error)
return []
} finally {
release()
}
}
export async function listAssignableUsers(
key: string,
query?: string,
siteId?: string | null
): Promise<JiraUser[]> {
return searchAssignableUsers({ issueKey: key }, query, siteId, 'listAssignableUsers')
}
// Creation has no issue key yet, so assignability is scoped by project.
export async function listAssignableUsersForProject(
projectIdOrKey: string,
query?: string,
siteId?: string | null
): Promise<JiraUser[]> {
return searchAssignableUsers(
{ project: projectIdOrKey },
query,
siteId,
'listAssignableUsersForProject'
)
}
@@ -5,7 +5,11 @@ import {
agentModelCatalogFingerprintForRecord
} from './agent-model-catalog-fingerprint'
import { createAgentModelCatalogService } from './agent-model-catalog-service'
import { AgentModelCatalogStore, type AgentModelCatalogSuccess } from './agent-model-catalog-store'
import {
AGENT_MODEL_CATALOG_FRESH_MS,
AgentModelCatalogStore,
type AgentModelCatalogSuccess
} from './agent-model-catalog-store'
function record(accountHomePath: string): AgentSessionRecord {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service reads only provider, accountHome and location; the rest of the record is irrelevant here.
@@ -55,7 +59,8 @@ describe('agent model catalog service', () => {
probes: { codex: probe }
})
expect(await service.read({ agent: 'codex', sessionId: 'session-1' })).toEqual({
origin: 'unknown'
origin: 'unknown',
listingInProgress: true
})
// A second read while the probe is in flight must not start another, and a
// record-scoped read probes the RECORD's pinned home, not the selection.
@@ -81,7 +86,10 @@ describe('agent model catalog service', () => {
probes: { codex: probe }
})
// The record-less read follows the CURRENT selection: unknown, never gpt-old.
expect(await service.read({ agent: 'codex' })).toEqual({ origin: 'unknown' })
expect(await service.read({ agent: 'codex' })).toEqual({
origin: 'unknown',
listingInProgress: true
})
expect(probe).toHaveBeenCalledWith('/homes/new')
await vi.waitFor(async () => {
const result = await service.read({ agent: 'codex' })
@@ -139,7 +147,8 @@ describe('agent model catalog service', () => {
probes: { codex: probe }
})
expect(await service.read({ agent: 'codex', sessionId: 'session-1' })).toEqual({
origin: 'unknown'
origin: 'unknown',
listingInProgress: true
})
await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1))
// Still a clean unknown — and the failure TTL suppresses a probe storm.
@@ -164,6 +173,94 @@ describe('agent model catalog service', () => {
expect(probe).not.toHaveBeenCalled()
})
describe('a read that waits for the first listing', () => {
function deferredListing() {
let resolve!: (success: AgentModelCatalogSuccess) => void
let reject!: (error: Error) => void
const promise = new Promise<AgentModelCatalogSuccess>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function coldService(probe: (home: string) => Promise<AgentModelCatalogSuccess>) {
const store = new AgentModelCatalogStore()
const service = createAgentModelCatalogService({
store,
getRecord: () => undefined,
resolveAccountHome: async () => CODEX_HOME('/homes/selected'),
probes: { codex: probe }
})
return { store, service }
}
it('joins the listing the first read started and answers with it', async () => {
const pending = deferredListing()
const probe = vi.fn(() => pending.promise)
const { service } = coldService(probe)
expect(await service.read({ agent: 'codex' })).toEqual({
origin: 'unknown',
listingInProgress: true
})
const waited = service.read({ agent: 'codex', waitForListing: true })
pending.resolve(listing('gpt-listed'))
const result = await waited
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-listed')
expect(probe).toHaveBeenCalledTimes(1)
})
it('answers a plain unknown when the listing fails', async () => {
const pending = deferredListing()
const { service } = coldService(() => pending.promise)
const waited = service.read({ agent: 'codex', waitForListing: true })
pending.reject(new Error('spawn failed'))
expect(await waited).toEqual({ origin: 'unknown' })
})
it('does not wait or report a listing while a failure is inside its TTL', async () => {
const probe = vi.fn(async (): Promise<AgentModelCatalogSuccess> => {
throw new Error('spawn failed')
})
const { store, service } = coldService(probe)
store.recordFailure(selectedHomeFingerprint('/homes/selected'), 'spawn failed')
expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({
origin: 'unknown'
})
expect(await service.read({ agent: 'codex' })).toEqual({ origin: 'unknown' })
expect(probe).not.toHaveBeenCalled()
})
it('reports no listing where the host has no lister for the account', async () => {
const store = new AgentModelCatalogStore()
const service = createAgentModelCatalogService({
store,
getRecord: () => undefined,
resolveAccountHome: async () => CODEX_HOME('/homes/selected')
})
expect(await service.read({ agent: 'codex', waitForListing: true })).toEqual({
origin: 'unknown'
})
})
it('serves an aged entry at once and refreshes it behind the answer', async () => {
let now = 0
const store = new AgentModelCatalogStore({ now: () => now })
store.recordSuccess(selectedHomeFingerprint('/homes/selected'), 'codex', listing('gpt-old'))
now = AGENT_MODEL_CATALOG_FRESH_MS
const probe = vi.fn(() => new Promise<AgentModelCatalogSuccess>(() => {}))
const service = createAgentModelCatalogService({
store,
getRecord: () => undefined,
resolveAccountHome: async () => CODEX_HOME('/homes/selected'),
probes: { codex: probe }
})
const result = await service.read({ agent: 'codex', waitForListing: true })
expect(result.origin === 'unknown' ? null : result.models[0]!.id).toBe('gpt-old')
expect(probe).toHaveBeenCalledTimes(1)
})
})
describe('a read for the workspace a new chat runs in', () => {
function serviceWith(mayOverride: boolean) {
const store = new AgentModelCatalogStore()

Some files were not shown because too many files have changed in this diff Show More