mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 00:03:37 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/publish-submission-positions
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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'
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}))
|
||||
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
|
||||
@@ -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' }))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
@@ -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()) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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') })
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -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' })
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -6,6 +6,7 @@ export { getIssueComments } from './jira-issue-comments'
|
||||
export { listProjects } from './jira-project-queries'
|
||||
export {
|
||||
listAssignableUsers,
|
||||
listAssignableUsersForProject,
|
||||
listCreateFields,
|
||||
listIssueTypes,
|
||||
listPriorities,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user