diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 0b121c5bb2e..90c3fc68473 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -164,7 +164,7 @@ jobs: cache-pnpm-store-lookup-only: 'true' # Design D7 upgrade and rollback: the last Bun orcad, built from a main commit that shipped # it, beside this checkout's Node slot; the live-terminal hand-over skips once PROTOCOL_VERSION - # moves past the Bun daemon's. Its build uses this checkout's installed dependencies. + # moves past the Bun daemon's. Install its pinned dependencies independently of this checkout. - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 if: runner.os == 'Linux' with: @@ -179,7 +179,7 @@ jobs: if [ "$RUNNER_OS" != Linux ]; then exit 0; fi git fetch --no-tags --depth=1 origin "$BUN_ORCAD_COMMIT" git worktree add --detach "$RUNNER_TEMP/bun-orcad-source" "$BUN_ORCAD_COMMIT" - ln -s "$GITHUB_WORKSPACE/node_modules" "$RUNNER_TEMP/bun-orcad-source/node_modules" + pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts node "$RUNNER_TEMP/bun-orcad-source/config/scripts/build-orcad-bun.mjs" --out-dir "$RUNNER_TEMP/bun-orcad" echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT" echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9558255e482..b370dff9929 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -154,15 +154,17 @@ jobs: echo "No specs requiring the reusable E2E workflow" fi - static_analysis: - name: static analysis + preflight: + name: static analysis and typecheck needs: [code_paths] - if: needs.code_paths.outputs.static_analysis == 'true' + if: needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' # Why ARM: measured 128s against 172s on ubuntu-latest, with every compute step faster -- # type-aware 24s->15s, anti-slop 28->19s, localization extraction 67->46s, the orcad smoke # 39->14s. Both lint engines ship linux-arm64 and the Bun target follows process.arch, so - # the whole toolchain resolves. Free for public repositories, same as the typecheck job. + # the whole toolchain resolves. Free for public repositories. runs-on: ubuntu-24.04-arm + outputs: + shards: ${{ steps.unit-plan.outputs.shards }} steps: - name: Checkout @@ -197,21 +199,35 @@ jobs: # Keep each check in its own log while sharing this runner. - name: Lint + if: '!cancelled()' id: root-lint background: true - run: pnpm exec oxlint --format github + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm exec oxlint --format github - name: Reject low-evidence patterns + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run audit:anti-slop - wait: root-lint - name: Enforce focused code-quality plugins + if: '!cancelled()' id: native-code-quality background: true - run: pnpm run audit:code-quality:native + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm run audit:code-quality:native - name: Enforce type-aware code-quality baseline + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run audit:code-quality:type-aware # Mobile installation changes import resolution for the native cycle check. @@ -221,28 +237,39 @@ jobs: # resolves types from mobile/node_modules. Without the install every mobile type # degrades to an `error` type — reported as phantom findings against the changed lines. - uses: ./.github/actions/install-mobile-dependencies - if: needs.code_paths.outputs.mobile_dependencies == 'true' + if: needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true' - name: Enforce changed-code quality + if: '!cancelled()' id: changed-code-quality background: true - run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" - name: Enforce React Doctor on changed lines + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}" - wait: changed-code-quality - name: Check Zustand selector fan-out budget + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:zustand-selector-fanout - name: Check reliability gate manifest + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:reliability-gates - name: Enforce dead design-system classes + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:dead-classes - name: Check VM runtime rollback compatibility + if: needs.code_paths.outputs.static_analysis == 'true' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | @@ -264,25 +291,33 @@ jobs: config/scripts/ephemeral-vm-runtime-store-cross-version.test.ts - name: Enforce max-lines ratchet + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:max-lines-ratchet - name: Enforce ts-nocheck ratchet + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:ts-nocheck-ratchet - name: Enforce runtime Electron-import ratchet + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:runtime-electron-ratchet - name: Check Node runtime pin + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run check:node-runtime-pin # Why: extraction writes sorted evidence to an isolated temporary path, # so feature PRs need one normalized AST pass rather than a three-OS matrix. - name: Verify localization extraction + if: '!cancelled()' id: localization-extraction background: true env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi # Detection failures run the full check; renames retain the removed input path. DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/localization-changes" && @@ -296,6 +331,7 @@ jobs: # which is a property of the import graph. This proves the Node artifact it enables # actually boots, pairs, creates a worktree and round-trips a real PTY. - name: Boot orcad and round-trip a terminal + if: needs.code_paths.outputs.static_analysis == 'true' env: BASE_SHA: ${{ github.event.pull_request.base.sha }} ORCA_BACKGROUND_LAUNCH: '1' @@ -310,20 +346,30 @@ jobs: fi - name: Verify the generated RPC params catalog + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:rpc-params-catalog - name: Verify bundled skill guides + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:bundled-skill-guides - name: Verify skill freshness manifest + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:skill-bundle-manifest - name: Verify localization catalogs + if: '!cancelled()' id: localization-catalogs background: true - run: pnpm run verify:localization-catalogs + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + pnpm run verify:localization-catalogs - name: Verify localization coverage + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm run verify:localization-coverage - wait: [localization-catalogs, localization-extraction] @@ -334,6 +380,7 @@ jobs: # in .d.ts to `any`, which is how #1186 shipped a broken IPC signature # past typecheck. See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts. - name: Guard against project-owned .d.ts in preload/shared + if: needs.code_paths.outputs.static_analysis == 'true' run: | matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true) if [ -n "$matches" ]; then @@ -346,33 +393,19 @@ jobs: fi - name: Check feature wall asset budget + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm check:feature-wall-assets - name: Verify macOS entitlements + if: needs.code_paths.outputs.static_analysis == 'true' run: pnpm verify:macos-entitlements - typecheck: - needs: [code_paths] - if: needs.code_paths.outputs.typecheck == 'true' - # Typechecking uses no native runtime, so it can use the free public ARM runner. - runs-on: ubuntu-24.04-arm - outputs: - shards: ${{ steps.unit-plan.outputs.shards }} - - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 2 - persist-credentials: false - - - uses: ./.github/actions/install-node-dependencies - # Why: every project is `composite`, so tsc already writes a .tsbuildinfo that lets # the next run skip unchanged files. Share one cache entry across commits while the # PR base stays stable; actions/cache keeps the first successful graph and the # compiler still invalidates stale files from its content hashes. - name: Cache TypeScript incremental state + if: needs.code_paths.outputs.typecheck == 'true' uses: actions/cache@v5 with: path: config/*.tsbuildinfo @@ -382,17 +415,24 @@ jobs: # Planning shares setup and stays off the compiler's critical path. - name: Plan unit selection + if: '!cancelled()' id: unit-plan background: true env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.typecheck == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} ORCA_UNIT_SELECTION_MODE: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }} - run: node config/scripts/ci-unit-plan.mjs + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + node config/scripts/ci-unit-plan.mjs - run: pnpm run typecheck + if: needs.code_paths.outputs.typecheck == 'true' - wait: unit-plan - uses: actions/upload-artifact@v7 + if: needs.code_paths.outputs.typecheck == 'true' with: name: unit-selection-attempt-${{ github.run_attempt }} path: ci-shards/unit-selection.json @@ -680,6 +720,7 @@ jobs: src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/main/fish-xdg-data-dirs-handoff.test.ts \ src/main/shell-startup-feature-channel.test.ts \ + src/main/zsh-deferred-startup-line-init.live-shell.test.ts \ src/main/terminal-history-fish-session.node-pty.test.ts \ src/main/zsh-scoped-histfile.live-shell.test.ts \ src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ @@ -691,20 +732,19 @@ jobs: src/shared/startup-shell-portability.live-shell.test.ts \ src/shared/posix-command-path-lookup.test.ts - # Static analysis saves the Node cache; typecheck publishes the plan before tests fan out. + # Preflight saves the Node cache and publishes the plan before tests fan out. test: - needs: [code_paths, static_analysis, typecheck] + needs: [code_paths, preflight] # Cancellation and failed prerequisites stop the expensive matrix. if: >- !cancelled() && needs.code_paths.outputs.test == 'true' && - needs.static_analysis.result == 'success' && - needs.typecheck.result == 'success' + needs.preflight.result == 'success' uses: ./.github/workflows/unit-tests.yml with: node_versions: '["24"]' runner: ubuntu-24.04-arm - shards: ${{ needs.typecheck.outputs.shards }} + shards: ${{ needs.preflight.outputs.shards }} # Why a sibling and not part of the test workflow: it is advisory, so it must not delay the # gate. Inside unit-tests.yml a caller's `needs: test` waited for it, holding verify ~36s @@ -902,7 +942,7 @@ jobs: package: name: package - needs: [code_paths, static_analysis, typecheck] + needs: [code_paths, preflight] if: needs.code_paths.outputs.package == 'true' runs-on: ubuntu-latest # Let the serial Docker gates reach their own deadlines and report cleanup failures. @@ -1059,7 +1099,7 @@ jobs: package_windows: name: package (windows) - needs: [code_paths, static_analysis, typecheck] + needs: [code_paths, preflight] if: needs.code_paths.outputs.package_windows == 'true' runs-on: windows-2022 timeout-minutes: 30 @@ -1267,8 +1307,7 @@ jobs: if: ${{ !cancelled() }} needs: - code_paths - - static_analysis - - typecheck + - preflight - git_compatibility - codex_index_heal_contract - xterm_patch_sync @@ -1297,10 +1336,8 @@ jobs: env: CODE_PATHS: ${{ needs.code_paths.result }} SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }} - STATIC_ANALYSIS: ${{ needs.static_analysis.result }} - STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }} - TYPECHECK: ${{ needs.typecheck.result }} - TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }} + PREFLIGHT: ${{ needs.preflight.result }} + PREFLIGHT_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }} GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }} CODEX_INDEX_HEAL_CONTRACT: ${{ needs.codex_index_heal_contract.result }} @@ -1346,8 +1383,7 @@ jobs: fi } # Require success when the PR has code-relevant changes - check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN" - check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN" + check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN" check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN" check_job codex_index_heal_contract "$CODEX_INDEX_HEAL_CONTRACT" "$CODEX_INDEX_HEAL_CONTRACT_SHOULD_RUN" check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN" diff --git a/config/scripts/check-node-runtime-pin.test.mjs b/config/scripts/check-node-runtime-pin.test.mjs index e1be15f0f7f..a805985d966 100644 --- a/config/scripts/check-node-runtime-pin.test.mjs +++ b/config/scripts/check-node-runtime-pin.test.mjs @@ -189,7 +189,7 @@ describe('committed pin', () => { it('runs in the static analysis job', () => { const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8')) - const commands = workflow.jobs.static_analysis.steps.map((step) => step.run ?? '') + const commands = workflow.jobs.preflight.steps.map((step) => step.run ?? '') expect(commands).toContain('pnpm run check:node-runtime-pin') }) }) diff --git a/config/scripts/check-readme-local-links.test.mjs b/config/scripts/check-readme-local-links.test.mjs index e1d69723ee5..c9128630f19 100644 --- a/config/scripts/check-readme-local-links.test.mjs +++ b/config/scripts/check-readme-local-links.test.mjs @@ -156,8 +156,7 @@ describe('README local link check', () => { ]) }) - // Why the ungated job: static_analysis is skipped for docs-only diffs, which is - // exactly the kind of PR that deletes a docs-site GIF the README embeds. + // Docs-only diffs skip preflight, so the detector must check README links. it('runs on every PR through the ungated detector and in the lint script', () => { const { scripts } = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8')) const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8')) diff --git a/config/scripts/ci-background-step-barriers.test.mjs b/config/scripts/ci-background-step-barriers.test.mjs index 329e6c0bbce..e005c60b989 100644 --- a/config/scripts/ci-background-step-barriers.test.mjs +++ b/config/scripts/ci-background-step-barriers.test.mjs @@ -20,8 +20,7 @@ function assertJoinedBefore(steps, id, consumer) { describe('CI background step barriers', () => { it('joins every background check without suppressing failures', () => { for (const job of [ - pr.jobs.static_analysis, - pr.jobs.typecheck, + pr.jobs.preflight, pr.jobs.mobile_web_app, pr.jobs.package, pr.jobs.shell_contracts, @@ -52,7 +51,7 @@ describe('CI background step barriers', () => { it('joins planning before publishing the unit artifact', () => { assertJoinedBefore( - pr.jobs.typecheck.steps, + pr.jobs.preflight.steps, 'unit-plan', (step) => step.uses === 'actions/upload-artifact@v7' ) @@ -88,7 +87,7 @@ describe('CI background step barriers', () => { }) it('finishes native import-cycle analysis before mobile installation changes resolution', () => { - const steps = pr.jobs.static_analysis.steps + const steps = pr.jobs.preflight.steps assertJoinedBefore(steps, 'native-code-quality', (step) => step.uses?.endsWith('/install-mobile-dependencies') ) diff --git a/config/scripts/ci-cache-warmup-workflow.test.mjs b/config/scripts/ci-cache-warmup-workflow.test.mjs index 469800edfac..01b6c214e5d 100644 --- a/config/scripts/ci-cache-warmup-workflow.test.mjs +++ b/config/scripts/ci-cache-warmup-workflow.test.mjs @@ -28,7 +28,7 @@ it('warms the same Linux Node runtime the PR shards restore', () => { const install = arm.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) - const primer = readWorkflow('pr').jobs.static_analysis + const primer = readWorkflow('pr').jobs.preflight expect(arm['runs-on']).toBe(primer['runs-on']) expect(arm.steps.at(-1).run).toBe('node config/scripts/ensure-native-runtime.mjs --check-only') expect(install.with).toMatchObject(primer.steps.find((step) => step.uses === install.uses).with) @@ -51,7 +51,7 @@ it('populates shared Electron archives on both Linux architectures without chang it('publishes incremental state under a key and prefix that new PRs restore', () => { const cache = steps.find((step) => step.id === 'typecheck-cache') - const prCache = readWorkflow('pr').jobs.typecheck.steps.find((step) => step.name === cache.name) + const prCache = readWorkflow('pr').jobs.preflight.steps.find((step) => step.name === cache.name) expect(cache.with.path).toBe(prCache.with.path) expect(cache.with['restore-keys']).toBe(prCache.with['restore-keys']) expect(cache.with.key).toBe( diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs index 80252973e22..3e0cdb67cd8 100644 --- a/config/scripts/ci-unit-files.mjs +++ b/config/scripts/ci-unit-files.mjs @@ -23,6 +23,7 @@ export const UNIT_EXCLUDE = [ 'src/main/pty/omp-shell-wrapper-alias-safety.test.ts', 'src/main/pty/omp-shell-wrapper.node-pty.test.ts', 'src/main/shell-startup-feature-channel.test.ts', + 'src/main/zsh-deferred-startup-line-init.live-shell.test.ts', 'src/main/terminal-history-fish-session.node-pty.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', diff --git a/config/scripts/json-parser-benchmark-fixtures.mjs b/config/scripts/json-parser-benchmark-fixtures.mjs new file mode 100644 index 00000000000..385b4010895 --- /dev/null +++ b/config/scripts/json-parser-benchmark-fixtures.mjs @@ -0,0 +1,65 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + +export const JSON_PARSER_CASES = [ + { name: 'rg-10k', kind: 'rg', count: 10_000, cap: 2000 }, + { name: 'rg-100k', kind: 'rg', count: 100_000, cap: 2000 }, + { name: 'rg-100k-cap1', kind: 'rg', count: 100_000, cap: 1 }, + { name: 'rg-unicode', kind: 'rg', count: 10_000, cap: 2000, unicode: true }, + { name: 'rg-large-dense', kind: 'rg', count: 900_000, cap: 2000, large: true }, + { name: 'rg-fast-path', kind: 'rg', count: 1, cap: 2000, large: true }, + { name: 'session-messages', kind: 'session' }, + { name: 'session-skipped-objects', kind: 'session' }, + { name: 'session-skipped-string', kind: 'session' }, + { name: 'session-selected-string', kind: 'session' } +] + +export function writeJsonParserFixtures(directory) { + for (const fixture of JSON_PARSER_CASES) { + let value + if (fixture.kind === 'rg') { + const text = fixture.unicode + ? '\ufeff日本語😀x' + : fixture.large && fixture.count > 1 + ? 'xxxx' + : 'x' + const matchBytes = Buffer.byteLength(text) + value = { + type: 'match', + data: { + path: { text: `${text}.ts` }, + lines: { + text: text.repeat(fixture.count === 1 ? 4 * 1024 * 1024 : fixture.count) + }, + line_number: 1, + submatches: Array.from({ length: fixture.count }, (_, index) => ({ + match: { text }, + start: index * matchBytes, + end: (index + 1) * matchBytes + })) + } + } + } else { + value = { id: 'synthetic', messages: [{ text: 'one' }] } + if (fixture.name === 'session-messages') { + value.agent = { model: 'model', other: 'ignored' } + value.messages = Array.from({ length: 20_000 }, (_, index) => ({ + role: index % 2 ? 'assistant' : 'user', + text: '\ufeff日本語😀 hello world '.repeat(16), + timestamp: index, + metadata: { model: 'synthetic', tokens: 512 } + })) + } else if (fixture.name === 'session-skipped-objects') { + value.ignored = Array.from({ length: 200_000 }, (_, index) => ({ + id: index, + data: { text: 'x'.repeat(64), values: [1, 2, 3] } + })) + } else if (fixture.name === 'session-skipped-string') { + value.ignored = '日本語😀x'.repeat(1_500_000) + } else { + value.messages = [{ text: '日本語😀x'.repeat(1_500_000) }] + } + } + writeFileSync(join(directory, `${fixture.name}.json`), JSON.stringify(value)) + } +} diff --git a/config/scripts/json-parser-migration-benchmark.mjs b/config/scripts/json-parser-migration-benchmark.mjs new file mode 100644 index 00000000000..0aca1734e1b --- /dev/null +++ b/config/scripts/json-parser-migration-benchmark.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { createReadStream, readFileSync, statSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' +import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs' +import { JSON_PARSER_CASES, writeJsonParserFixtures } from './json-parser-benchmark-fixtures.mjs' + +// Bundle each revision's two consumers as {baseline,candidate}-{rg,session}.mjs first. +const [bundleDirectory, workerFixture, workerArm] = process.argv.slice(2) +if (!bundleDirectory || !global.gc) { + throw new Error('Usage: node --expose-gc json-parser-migration-benchmark.mjs BUNDLE_DIRECTORY') +} + +async function load(arm, kind) { + return import(pathToFileURL(resolve(bundleDirectory, `${arm}-${kind}.mjs`)).href) +} + +function prepareRun(module, fixture, file) { + if (fixture.kind === 'rg') { + const text = readFileSync(file, 'utf8') + return () => + module.parseRipgrepMatchJson(text, fixture.cap, { + structuralTokens: 32 * 1024, + nestingDepth: 16 + }) + } + return () => + module.readStreamedSessionDocument({ + bytes: createReadStream(file, { highWaterMark: 64 * 1024 }), + arrayKey: 'messages', + fields: ['id'], + objectFields: { agent: ['model'] }, + create: () => ({ count: 0, textLength: 0 }), + consume(state, value) { + state.count++ + state.textLength += typeof value?.text === 'string' ? value.text.length : 0 + } + }) +} + +function digest(value) { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + +async function consumedContentDigest(module, file) { + const result = await module.readStreamedSessionDocument({ + bytes: createReadStream(file, { highWaterMark: 64 * 1024 }), + arrayKey: 'messages', + fields: ['id'], + objectFields: { agent: ['model'] }, + create: () => createHash('sha256'), + consume(hash, value) { + hash.update(JSON.stringify(value)).update('\n') + } + }) + return { record: result.record, consumedSha256: result.state.digest('hex') } +} + +if (workerFixture) { + const fixture = JSON_PARSER_CASES.find((item) => workerFixture.endsWith(`${item.name}.json`)) + assert(fixture) + const module = await load(workerArm, fixture.kind) + const run = prepareRun(module, fixture, workerFixture) + global.gc() + const before = process.memoryUsage() + let running = true + let maxLoopGapMs = 0 + let previous = performance.now() + const observe = () => { + const now = performance.now() + maxLoopGapMs = Math.max(maxLoopGapMs, now - previous) + previous = now + if (running) { + setImmediate(observe) + } + } + setImmediate(observe) + const started = performance.now() + const result = await run() + const elapsedMs = performance.now() - started + await new Promise((done) => setImmediate(done)) + running = false + const peakRssMiB = process.resourceUsage().maxRSS / 1024 + global.gc() + const retainedHeapDeltaMiB = (process.memoryUsage().heapUsed - before.heapUsed) / 1024 ** 2 + console.log( + JSON.stringify({ + elapsedMs, + peakRssMiB, + retainedHeapDeltaMiB, + maxLoopGapMs, + digest: digest(result) + }) + ) +} else { + const directory = mkdtempSync(join(tmpdir(), 'orca-json-parser-benchmark-')) + try { + writeJsonParserFixtures(directory) + global.gc() + const results = [] + for (const fixture of JSON_PARSER_CASES) { + const file = join(directory, `${fixture.name}.json`) + const runs = {} + const contents = {} + for (const arm of ['baseline', 'candidate']) { + const module = await load(arm, fixture.kind) + runs[arm] = prepareRun(module, fixture, file) + if (fixture.kind === 'session') { + contents[arm] = await consumedContentDigest(module, file) + } + } + assert.deepEqual(contents.candidate, contents.baseline) + for (let warmup = 0; warmup < 3; warmup++) { + assert.deepEqual(await runs.candidate(), await runs.baseline()) + } + const samples = { baseline: [], candidate: [] } + for (const pair of buildCounterbalancedSchedule(12, 'baseline', 'candidate')) { + for (const arm of pair) { + const started = performance.now() + await runs[arm]() + samples[arm].push(performance.now() - started) + } + } + const memory = { baseline: [], candidate: [] } + for (const pair of buildCounterbalancedSchedule(2, 'baseline', 'candidate')) { + for (const arm of pair) { + const child = spawnSync( + process.execPath, + ['--expose-gc', import.meta.filename, bundleDirectory, file, arm], + { + encoding: 'utf8', + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + windowsHide: true + } + ) + assert.equal(child.status, 0, child.stderr) + memory[arm].push(JSON.parse(child.stdout)) + } + } + for (const sample of [...memory.baseline, ...memory.candidate]) { + assert.equal(sample.digest, memory.baseline[0].digest) + } + results.push({ + name: fixture.name, + bytes: statSync(file).size, + baseline: summarizeBenchmarkSamples(samples.baseline), + candidate: summarizeBenchmarkSamples(samples.candidate), + samples, + memory + }) + } + console.log( + JSON.stringify( + { node: process.version, platform: process.platform, arch: process.arch, results }, + null, + 2 + ) + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/config/scripts/localization-extraction-change-scope.test.mjs b/config/scripts/localization-extraction-change-scope.test.mjs index 8852965ad30..b1c142b8cd0 100644 --- a/config/scripts/localization-extraction-change-scope.test.mjs +++ b/config/scripts/localization-extraction-change-scope.test.mjs @@ -39,10 +39,10 @@ it('preserves deleted and renamed inputs and falls back to extraction on detecti const workflow = parse( readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8') ) - const step = workflow.jobs.static_analysis.steps.find( + const step = workflow.jobs.preflight.steps.find( (candidate) => candidate.name === 'Verify localization extraction' ) - expect(step.env).toEqual({ + expect(step.env).toMatchObject({ BASE_SHA: '${{ github.event.pull_request.base.sha }}' }) // The base side comes from the merge ref's first parent, so the gate needs no merge base and diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 4216beb2556..38b56f0d600 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -310,6 +310,10 @@ it('runs the Bun and Node cross-runtime tests on Linux against pinned inputs', ( expect(build.if).toBeUndefined() expect(build['continue-on-error']).toBeUndefined() expect(build.run).toMatch(/^if \[ "\$RUNNER_OS" != Linux \]; then exit 0; fi\n/) + expect(build.run).toContain( + 'pnpm --dir "$RUNNER_TEMP/bun-orcad-source" install --frozen-lockfile --ignore-scripts' + ) + expect(build.run).not.toContain('"$GITHUB_WORKSPACE/node_modules"') expect(build.run).toContain('echo "slot=$RUNNER_TEMP/bun-orcad" >> "$GITHUB_OUTPUT"') expect(build.run).toContain('echo "executable=$(command -v bun)" >> "$GITHUB_OUTPUT"') expect(build.run).not.toContain('GITHUB_ENV') diff --git a/config/scripts/orcad-terminal-smoke-change-scope.test.mjs b/config/scripts/orcad-terminal-smoke-change-scope.test.mjs index 5ee88ff0492..e3108d782be 100644 --- a/config/scripts/orcad-terminal-smoke-change-scope.test.mjs +++ b/config/scripts/orcad-terminal-smoke-change-scope.test.mjs @@ -92,7 +92,7 @@ it('only skips the unchanged smoke after a successful diff and dependency analys const workflow = parse( readFileSync(new URL('../../.github/workflows/pr.yml', import.meta.url), 'utf8') ) - const step = workflow.jobs.static_analysis.steps.find( + const step = workflow.jobs.preflight.steps.find( (candidate) => candidate.name === 'Boot orcad and round-trip a terminal' ) expect(step.env).toEqual({ diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 297d1f8e671..9bcc7974513 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -587,14 +587,16 @@ describe('PR Checks skip wiring', () => { expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe( '${{ steps.filter.outputs.mobile_dependencies }}' ) - const steps = prWorkflow.jobs.static_analysis.steps + const steps = prWorkflow.jobs.preflight.steps const install = steps.findIndex( (step) => step.uses === './.github/actions/install-mobile-dependencies' ) const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality') expect(install).toBeGreaterThan(-1) expect(install).toBeLessThan(gate) - expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'") + expect(steps[install].if).toBe( + "needs.code_paths.outputs.static_analysis == 'true' && needs.code_paths.outputs.mobile_dependencies == 'true'" + ) // The install itself moved into the action the packaging jobs share; assert it there so // this job cannot keep the step while the action stops installing anything. const action = parse( @@ -621,21 +623,21 @@ describe('PR Checks skip wiring', () => { }) it('gates each expensive job on its classifier and cache prerequisite', () => { - for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) { + for (const jobName of expensiveJobs.filter( + (jobName) => !['test', 'static_analysis', 'typecheck'].includes(jobName) + )) { expect(prWorkflow.jobs[jobName].needs, jobName).toEqual( ['package', 'package_windows'].includes(jobName) - ? ['code_paths', 'static_analysis', 'typecheck'] + ? ['code_paths', 'preflight'] : ['code_paths'] ) expect(prWorkflow.jobs[jobName].if, jobName).toBe( `needs.code_paths.outputs.${jobName} == 'true'` ) } - expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'static_analysis', 'typecheck']) - expect(prWorkflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'") - expect(prWorkflow.jobs.test.if).toContain("needs.typecheck.result == 'success'") + expect(prWorkflow.jobs.test.if).toContain("needs.preflight.result == 'success'") expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'") - expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}') + expect(prWorkflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}') expect(prWorkflow.jobs.unit_plan).toBeUndefined() expect(prWorkflow.jobs.test_native_cache).toBeUndefined() }) @@ -659,7 +661,7 @@ describe('PR Checks skip wiring', () => { expect(verifyStep.run).toContain('expected skipped') expect(verifyStep.run).toContain('expected success') for (const job of prWorkflow.jobs.verify.needs) { - if (job === 'code_paths') { + if (job === 'code_paths' || job === 'preflight') { continue } const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN` diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 4c1268cd7a2..c661327a40a 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -45,7 +45,7 @@ const nativeImeSpec = readFileSync( const filterStep = prWorkflow.jobs.code_paths.steps.find( (step) => step.name === 'Filter changed E2E specs' ) -const rollbackStep = prWorkflow.jobs.static_analysis.steps.find( +const rollbackStep = prWorkflow.jobs.preflight.steps.find( (step) => step.name === 'Check VM runtime rollback compatibility' ) const verifyStep = prWorkflow.jobs.verify.steps.find( @@ -133,7 +133,7 @@ describe('PR E2E gate contract', () => { for (const job of prWorkflow.jobs.verify.needs) { const envVar = job.replaceAll('-', '_').toUpperCase() expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`) - if (job === 'code_paths') { + if (job === 'code_paths' || job === 'preflight') { continue } expect(successLoop).toContain(`"$${envVar}"`) diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index 84345ee9360..250ea94ffe8 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -1,29 +1,21 @@ import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' import { expect, it } from 'vitest' import { parse } from 'yaml' import { classifyPrJobs } from './pr-code-change-scope.mjs' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) -const typecheck = workflow.jobs.typecheck -const steps = typecheck.steps +const preflight = workflow.jobs.preflight +const steps = preflight.steps const compiler = steps.find((step) => step.run === 'pnpm run typecheck') const plan = steps.find((step) => step.id === 'unit-plan') -it('shares planning setup while keeping the heavy checks on separate runners', () => { - expect(workflow.jobs.unit_plan).toBeUndefined() - expect(workflow.jobs.test_native_cache).toBeUndefined() - expect(typecheck.needs).toEqual(['code_paths']) - expect(workflow.jobs.static_analysis.needs).toEqual(['code_paths']) - expect( - steps.filter((step) => step.uses === './.github/actions/install-node-dependencies') - ).toHaveLength(1) - expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2) - expect(compiler.background).toBeUndefined() - expect(plan.background).toBe(true) - expect(plan.run).toBe('node config/scripts/ci-unit-plan.mjs') - expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE') - expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler)) - const installs = workflow.jobs.static_analysis.steps.filter( +it('shares one setup and runs the unchanged compiler after static checks finish', () => { + expect(workflow.jobs.static_analysis).toBeUndefined() + expect(workflow.jobs.typecheck).toBeUndefined() + expect(preflight.needs).toEqual(['code_paths']) + expect(preflight['runs-on']).toBe('ubuntu-24.04-arm') + const installs = steps.filter( (step) => step.uses === './.github/actions/install-node-dependencies' ) expect(installs).toHaveLength(2) @@ -32,35 +24,152 @@ it('shares planning setup while keeping the heavy checks on separate runners', ( expect(install.with['node-version']).toBe('24') expect(install.with['persist-native-cache']).not.toBe('false') } + expect(steps[0].with['fetch-depth']).toBeGreaterThanOrEqual(2) + expect(compiler.background).toBeUndefined() + expect(plan.background).toBe(true) + expect(plan.run.trim().split('\n')).toEqual([ + 'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi', + 'node config/scripts/ci-unit-plan.mjs' + ]) + expect(plan.env.ORCA_UNIT_SELECTION_MODE).toContain('vars.ORCA_UNIT_SELECTION_MODE') + expect(steps.indexOf(plan)).toBeLessThan(steps.indexOf(compiler)) + expect(steps.indexOf(compiler)).toBeGreaterThan( + steps.findIndex((step) => step.wait?.includes('localization-extraction')) + ) }) -it('requires compiler success and joined planning before publishing shards and admitting tests', () => { +it('requires physical preflight success before publishing shards and admitting consumers', () => { const join = steps.findIndex((step) => step.wait === 'unit-plan') const upload = steps.findIndex((step) => step.uses === 'actions/upload-artifact@v7') expect(join).toBeGreaterThan(steps.indexOf(compiler)) expect(upload).toBeGreaterThan(join) expect(steps[upload]['continue-on-error']).toBeUndefined() expect(steps[upload].with.name).toBe('unit-selection-attempt-${{ github.run_attempt }}') - expect(typecheck.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}') - expect(workflow.jobs.test.with.shards).toBe('${{ needs.typecheck.outputs.shards }}') + expect(preflight.outputs.shards).toBe('${{ steps.unit-plan.outputs.shards }}') + expect(workflow.jobs.test.with.shards).toBe('${{ needs.preflight.outputs.shards }}') for (const job of ['test', 'package', 'package_windows']) { - expect(workflow.jobs[job].needs).toEqual(['code_paths', 'static_analysis', 'typecheck']) + expect(workflow.jobs[job].needs).toEqual(['code_paths', 'preflight']) } - expect(workflow.jobs.test.if).toContain("needs.static_analysis.result == 'success'") - expect(workflow.jobs.test.if).toContain("needs.typecheck.result == 'success'") - expect(workflow.jobs.verify.needs).toContain('static_analysis') - expect(workflow.jobs.verify.needs).toContain('typecheck') + for (const result of ['success', 'failure', 'cancelled', 'skipped']) { + const admitted = runInNewContext(workflow.jobs.test.if, { + cancelled: () => false, + needs: { code_paths: { outputs: { test: 'true' } }, preflight: { result } } + }) + expect(admitted, result).toBe(result === 'success') + } + const verify = workflow.jobs.verify.steps.find( + (step) => step.name === 'Require successful checks' + ) + expect(verify.env.PREFLIGHT).toBe('${{ needs.preflight.result }}') + expect(verify.env.PREFLIGHT_SHOULD_RUN).toBe( + "${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }}" + ) + expect(verify.run).toContain('check_job preflight "$PREFLIGHT" "$PREFLIGHT_SHOULD_RUN"') + expect(workflow.jobs.verify.needs).toContain('preflight') + expect(workflow.jobs.verify.needs).not.toContain('typecheck') }) -it.each( - [['README.md'], ['mobile/src/App.tsx'], ['cloud/package.json'], ['src/main/index.ts'], []].map( - (changed) => ({ changed }) +it('pins every foreground and background step to its selected phase', () => { + const staticPhase = "needs.code_paths.outputs.static_analysis == 'true'" + const typePhase = "needs.code_paths.outputs.typecheck == 'true'" + const foreground = steps.filter( + (step) => !step.background && /outputs\.(static_analysis|typecheck)/.test(step.if ?? '') ) -)('keeps desktop typechecking and planning off unrelated paths: $changed', ({ changed }) => { + expect(foreground.map((step) => [step.name ?? step.run ?? step.uses, step.if])).toEqual([ + ['Reject low-evidence patterns', staticPhase], + ['Enforce type-aware code-quality baseline', staticPhase], + [ + './.github/actions/install-mobile-dependencies', + `${staticPhase} && needs.code_paths.outputs.mobile_dependencies == 'true'` + ], + ['Enforce React Doctor on changed lines', staticPhase], + ['Check Zustand selector fan-out budget', staticPhase], + ['Check reliability gate manifest', staticPhase], + ['Enforce dead design-system classes', staticPhase], + ['Check VM runtime rollback compatibility', staticPhase], + ['Enforce max-lines ratchet', staticPhase], + ['Enforce ts-nocheck ratchet', staticPhase], + ['Enforce runtime Electron-import ratchet', staticPhase], + ['Check Node runtime pin', staticPhase], + ['Boot orcad and round-trip a terminal', staticPhase], + ['Verify the generated RPC params catalog', staticPhase], + ['Verify bundled skill guides', staticPhase], + ['Verify skill freshness manifest', staticPhase], + ['Verify localization coverage', staticPhase], + ['Guard against project-owned .d.ts in preload/shared', staticPhase], + ['Check feature wall asset budget', staticPhase], + ['Verify macOS entitlements', staticPhase], + ['Cache TypeScript incremental state', typePhase], + ['pnpm run typecheck', typePhase], + ['actions/upload-artifact@v7', typePhase] + ]) + expect( + steps + .filter((step) => step.background) + .map((step) => [step.id, step.env.PREFLIGHT_PHASE_SELECTED]) + ).toEqual([ + ['root-lint', `\${{ ${staticPhase} }}`], + ['native-code-quality', `\${{ ${staticPhase} }}`], + ['changed-code-quality', `\${{ ${staticPhase} }}`], + ['localization-extraction', `\${{ ${staticPhase} }}`], + ['localization-catalogs', `\${{ ${staticPhase} }}`], + ['unit-plan', `\${{ ${typePhase} }}`] + ]) +}) + +it.each([ + { changed: ['README.md'], static_analysis: false, typecheck: false, mobile_dependencies: false }, + { + changed: ['mobile/src/App.tsx'], + static_analysis: true, + typecheck: false, + mobile_dependencies: true + }, + { + changed: ['cloud/package.json'], + static_analysis: false, + typecheck: false, + mobile_dependencies: false + }, + { + changed: ['src/main/index.ts'], + static_analysis: true, + typecheck: true, + mobile_dependencies: false + }, + { + changed: ['mobile/src/App.tsx', 'src/main/index.ts'], + static_analysis: true, + typecheck: true, + mobile_dependencies: true + }, + { changed: [], static_analysis: true, typecheck: true, mobile_dependencies: true } +])('preserves exact phase selection for unrelated paths: $changed', ({ changed, ...expected }) => { const scope = classifyPrJobs(changed) - expect(typecheck.if).toBe("needs.code_paths.outputs.typecheck == 'true'") - expect(scope.test).toBe(scope.typecheck) - if (scope.test) { - expect(scope.static_analysis).toBe(true) + expect({ + static_analysis: scope.static_analysis, + typecheck: scope.typecheck, + mobile_dependencies: scope.mobile_dependencies + }).toEqual(expected) + const needs = { + code_paths: { + outputs: Object.fromEntries(Object.entries(scope).map(([key, value]) => [key, String(value)])) + } + } + expect(runInNewContext(preflight.if, { needs })).toBe( + expected.static_analysis || expected.typecheck + ) + expect(runInNewContext(compiler.if, { needs })).toBe(expected.typecheck) + expect(scope.test).toBe(expected.typecheck) +}) + +it('registers successful no-op background work when a phase is unselected or already failed', () => { + for (const step of steps.filter((step) => step.background)) { + expect(step.if).toBe('!cancelled()') + expect(step.env.PREFLIGHT_PHASE_SELECTED).toContain('needs.code_paths.outputs.') + expect(step.env.PREFLIGHT_PRIOR_SUCCESS).toBe("${{ job.status == 'success' }}") + expect(step.run.split('\n')[0]).toBe( + 'if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi' + ) } }) diff --git a/config/scripts/pr-ready-check-gate.test.mjs b/config/scripts/pr-ready-check-gate.test.mjs index 27e5cf99103..ade7fada96a 100644 --- a/config/scripts/pr-ready-check-gate.test.mjs +++ b/config/scripts/pr-ready-check-gate.test.mjs @@ -7,10 +7,14 @@ import { PR_CHECK_JOBS } from './pr-code-change-scope.mjs' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const gate = workflow.jobs.verify.steps.find((step) => step.name === 'Require successful checks') const variable = (job) => job.replaceAll('-', '_').toUpperCase() +const requiredJobs = [ + 'preflight', + ...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck') +] function requiredResults(shouldRun) { return Object.fromEntries( - PR_CHECK_JOBS.flatMap((job) => [ + requiredJobs.flatMap((job) => [ [variable(job), shouldRun ? 'success' : 'skipped'], [`${variable(job)}_SHOULD_RUN`, String(shouldRun)] ]) @@ -42,7 +46,7 @@ describe.skipIf(process.platform === 'win32')( }) it('rejects every missing, failed or cancelled required result when reuse is unavailable', async () => { - for (const job of PR_CHECK_JOBS) { + for (const job of requiredJobs) { for (const result of ['', 'skipped', 'failure', 'cancelled']) { const verdict = await verify({ ...requiredResults(true), [variable(job)]: result }) expect(verdict.code, `${job}: ${result}`).toBe(1) @@ -57,7 +61,7 @@ describe.skipIf(process.platform === 'win32')( }) it('rejects unexpected downstream execution when the proven plan requires skips', async () => { - for (const job of PR_CHECK_JOBS) { + for (const job of requiredJobs) { const verdict = await verify({ ...requiredResults(false), [variable(job)]: 'success' }) expect(verdict.code, job).toBe(1) } diff --git a/config/scripts/pr-ready-check-reuse.test.mjs b/config/scripts/pr-ready-check-reuse.test.mjs index 19fb2407233..11f171a2b25 100644 --- a/config/scripts/pr-ready-check-reuse.test.mjs +++ b/config/scripts/pr-ready-check-reuse.test.mjs @@ -157,7 +157,11 @@ describe('ready-for-review required check reuse', () => { "github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'" ) expect(workflow.jobs.verify.if).toBe('${{ !cancelled() }}') - expect(workflow.jobs.verify.needs).toEqual(['code_paths', ...PR_CHECK_JOBS]) + expect(workflow.jobs.verify.needs).toEqual([ + 'code_paths', + 'preflight', + ...PR_CHECK_JOBS.filter((job) => job !== 'static_analysis' && job !== 'typecheck') + ]) }) it.each(['pr-test-loc.yml', 'mobile.yml'])( diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 91156e59bf0..7bc063b1d42 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -27,6 +27,7 @@ const shellContractFiles = [ 'src/main/pty/omp-shell-wrapper-alias-safety.test.ts', 'src/main/pty/omp-shell-wrapper.node-pty.test.ts', 'src/main/shell-startup-feature-channel.test.ts', + 'src/main/zsh-deferred-startup-line-init.live-shell.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', @@ -54,7 +55,7 @@ const realZshUsage = describe('PR workflow parallelism', () => { it('keeps lightweight orchestration jobs on the free slim runner', () => { expect(workflow.jobs.code_paths['runs-on']).toBe('ubuntu-slim') - expect(workflow.jobs.typecheck['runs-on']).toBe('ubuntu-24.04-arm') + expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm') expect(workflow.jobs.verify['runs-on']).toBe('ubuntu-slim') expect(prTestLocWorkflow.jobs.loc['runs-on']).toBe('ubuntu-slim') expect(releasePolicyWorkflow.jobs.enforce['runs-on']).toBe('ubuntu-slim') @@ -79,7 +80,7 @@ describe('PR workflow parallelism', () => { const installStep = sharedTest.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) - const staticInstall = workflow.jobs.static_analysis.steps.find( + const staticInstall = workflow.jobs.preflight.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) const nodeNextPrimerInstall = nodeNextWorkflow.jobs.test_native_cache.steps.find( @@ -91,7 +92,7 @@ describe('PR workflow parallelism', () => { expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml') expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['24', '26']) expect(workflow.jobs.test.with.runner).toBe('ubuntu-24.04-arm') - expect(workflow.jobs.static_analysis['runs-on']).toBe('ubuntu-24.04-arm') + expect(workflow.jobs.preflight['runs-on']).toBe('ubuntu-24.04-arm') expect(sharedTest['runs-on']).toBe('${{ inputs.runner }}') expect(unitTestWorkflow.on.workflow_call.inputs.runner.default).toBe('ubuntu-latest') expect(nodeNextWorkflow.jobs.test.with.runner).toBeUndefined() @@ -121,7 +122,7 @@ describe('PR workflow parallelism', () => { } expect(staticInstall.with['native-runtime']).toBe('node') expect(staticInstall.with['node-version']).toBe('24') - expect(workflow.jobs.test.needs).toContain('static_analysis') + expect(workflow.jobs.test.needs).toContain('preflight') expect(workflow.jobs.test_native_cache).toBeUndefined() expect(nodeNextPrimerInstall.with['native-runtime']).toBe('node') expect(nodeNextPrimerInstall.with['node-version']).toBe('${{ matrix.node }}') @@ -348,11 +349,11 @@ describe('PR workflow parallelism', () => { (step) => step.uses === './.github/actions/install-node-dependencies' ) - for (const jobName of ['typecheck', 'git_compatibility']) { + for (const jobName of ['git_compatibility']) { expect(installFor(jobName).with, jobName).toBeUndefined() } expect(installFor('xterm_patch_sync')).toBeUndefined() - expect(installFor('static_analysis').with['native-runtime']).toBe('node') + expect(installFor('preflight').with['native-runtime']).toBe('node') expect(installFor('shell_contracts').with['native-runtime']).toBe('node') expect(sharedTestInstall.with['native-runtime']).toBe('node') expect(installFor('package').with['native-runtime']).toBe('electron') @@ -478,7 +479,7 @@ describe('PR workflow parallelism', () => { }) it('reuses TypeScript incremental state across typecheck runs', () => { - const steps = workflow.jobs.typecheck.steps + const steps = workflow.jobs.preflight.steps const cacheIndex = steps.findIndex((step) => step.name === 'Cache TypeScript incremental state') const checkIndex = steps.findIndex((step) => step.run === 'pnpm run typecheck') @@ -543,8 +544,7 @@ describe('PR workflow parallelism', () => { it('keeps verify as the aggregate required check', () => { expect(workflow.jobs.verify.needs).toEqual([ 'code_paths', - 'static_analysis', - 'typecheck', + 'preflight', 'git_compatibility', 'codex_index_heal_contract', 'xterm_patch_sync', diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 44edcfd90b7..6afcf4f142f 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -258,8 +258,8 @@ ], "compilerOptions": { "composite": true, - // TypeScript 7 removed node10 resolution; Node16 preserves CommonJS emit for this package. - "module": "Node16", + // The CLI runs on Node 24; Node20 models synchronous ESM imports from CommonJS. + "module": "Node20", "moduleResolution": "Node16", "rootDir": "../src", "outDir": "../out" diff --git a/config/tsconfig.tc.cli.json b/config/tsconfig.tc.cli.json index bd59f47e7be..c613dd688d9 100644 --- a/config/tsconfig.tc.cli.json +++ b/config/tsconfig.tc.cli.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.cli.json", "compilerOptions": { - "module": "node16", + "module": "Node20", "moduleResolution": "node16" } } diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 1efd247e7bb..58cef59dde5 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -46,6 +46,112 @@ used 42 aggregate runner-minutes across 11 test jobs. The estimates 34.9 headless runner-hours, including 23.4 in cancelled runs. These are baseline observations; post-merge savings have not yet been measured. +## October 4 clock, byte and import test fixtures + +These changes retain production behavior, original case names and platform +outcomes. The paired pilots use three alternating one-worker Node 24 invocations +on Ubuntu 24 ARM. Every median below is a complete focused test invocation; +they do not establish whole-shard savings or queue-delay improvements. + +| Workload | Baseline median | Candidate median | Reduction | Hosted evidence | +| ---------------------------------------------------- | --------------- | ---------------- | --------- | ------------------------------------------------------------------------ | +| Codex settlement and Claude stop deadlines, 35 cases | 35.914s | 10.142s | 71.8% | [37186232658](https://github.com/stablyai/orca/actions/runs/37186232658) | +| Native-chat delivery, 15 cases | 22.321s | 7.376s | 67.0% | [37183731823](https://github.com/stablyai/orca/actions/runs/37183731823) | +| Six profile-storage byte suites, 118 cases | 12.629s | 9.978s | 21.0% | [37183141654](https://github.com/stablyai/orca/actions/runs/37183141654) | +| Encrypted account storage, six cases | 18.277s | 0.958s | 94.8% | [37184007241](https://github.com/stablyai/orca/actions/runs/37184007241) | +| SSH remote commands, 27 cases | 6.840s | 6.078s | 11.1% | [37184454532](https://github.com/stablyai/orca/actions/runs/37184454532) | +| OpenCode subscription, 28 cases | 47.347s | 6.284s | 86.7% | [37184858823](https://github.com/stablyai/orca/actions/runs/37184858823) | +| Window-service attachment, 31 cases | 11.910s | 1.619s | 86.4% | [37186340840](https://github.com/stablyai/orca/actions/runs/37186340840) | +| OpenCode 2 TUI ownership, 41 cases | 16.811s | 2.429s | 85.6% | [37187699312](https://github.com/stablyai/orca/actions/runs/37187699312) | +| Title-send authorization, nine cases | 29.545s | 12.995s | 56.0% | [37188423318](https://github.com/stablyai/orca/actions/runs/37188423318) | +| Range selection, 15 cases | 9.737s | 7.130s | 26.8% | [37188996198](https://github.com/stablyai/orca/actions/runs/37188996198) | + +Provider tests wait for the real fake-child write/ready barrier and drain the +host stream before installing a scoped parent clock. The original 2.5/5-second +Codex and 3-second Claude deadlines remain; before/at assertions check their +boundaries. Early rejection and refusal resolve without waiting on an unreachable +barrier; finally and suite teardown restore clocks and spies even after a native +Vitest timeout. Four healthy failure-path controls pass; old-helper hangs and +removed teardown are caught. Missing-child failure retains a real ten-second observation window. +Six faults for early/late stop deadlines, late queued resend and missing child +output fail the intended assertions. Native-chat tests still use the real React +outbox hooks. Their scoped clock retains probe, retry, churn and target-switch +windows, drains async act work, and unmounts before clock restoration. All eight +hook faults fail; two boundary faults pass the old coarse tests and fail the new +before/at assertions. Node/web typecheck, lint and formatting pass. + +Native Buffer.equals replaces deep per-byte assertion traversal. It checks the +complete original bytes and length; fixtures, SQLite operations, encryption and +processes are unchanged. The six profile suites retain 114 passes and four +existing Linux case-sensitivity skips; all 118 pass locally on macOS. Seven +profile last-byte/length faults and two encrypted-vault last-byte/length faults +fail their exact byte assertions. All six encrypted-vault cases still exercise +52 accounts near the 4 MiB encrypted cap, refused growth, restart/readback and +private permissions. + +The old SSH fixture created 15,197 short stage paths but never exceeded the real +1,048,576 UTF-16-character transport tail cap; its two valid entries also left +the 64-result assertion vacuous. The replacement uses about 1,300 real excluded +stage directories under long Unicode path components, a real shell channel and +the production execCommand limiter. Actual find output exceeds that cap, while +the generated filter retains both original valid entries. A separate population +of 65 valid directories proves the first-64 limit and native find ordering. File, +symlink, nested-install and failed-enumeration checks remain. All 27 case outcomes +match across treatments (23 passes and four unavailable PowerShell 5.1 skips on +the hosted image). Eight cap, ordering, filtering and failure faults are caught. +Paths use platform utilities; local PowerShell availability retains its original +skip policy. The deadline and SSH fixtures reuse existing process/stream code. +OpenCode subscription tests batch only uninterrupted fixture writes between the +original observation barriers. Both SQLite schema versions keep every row, rowid, +read cap, poll, clock position and case. No durability pragma or production code +changes. All 28 cases pass in every pair. Separate captures compare ordered +schema and rows, transaction state, pragmas, signals, page requests/results and +subscriber callbacks: 124,754,101 payload bytes match, canonical digest +`ba0eb6f09281746071d73fae88e2e8eb45332f36892b90998b374b1b8c59b3e3`. +Missing rows, collapsed frontier rowids, read-cap overruns, missing commit and +missing rollback each fail the intended case in both schemas. Positive rollback +controls pass. The unmeasured full-suite timing report ranked this fixture at +134.229 seconds; the paired 47.347-second figure above is the relevant focused +baseline, and the two figures must not be mixed into a claimed saving. + +Window attachment tests mock five unrelated registrar modules using their actual +types. The existing window ownership, reload, media permission, native file drop, +hydration barrier and updater scheduling cases remain real. Exact store/runtime/ +window arguments and daemon registration after the PTY handler are now asserted; +nine actual production wiring/order faults fail. The registrar implementations +retain separate handler tests. Concrete existing stubs moved to one fixture to +stay within the line limit. All 31 cases and statuses match across all pairs. The +final source differs from the timed source only by a required type-assertion +safety comment. +OpenCode 2 TUI tests use a scoped async clock only after the first real native +module import. The unchanged generated plugin runs against the existing fake TUI +and fetch. Original poll, permission, retry, preview, slow POST and endpoint +windows remain. Before/at assertions pin the 100 ms poll, 500 ms permission, +120 ms slow POST and 5-second endpoint boundaries. All 41 original case outcomes +match. Separate ordered captures preserve 678 TUI/event rows and 362 complete +POST start/completion rows; wall timestamps and cross-stream interleaving are +excluded from equivalence. Eleven generated-source faults fail their intended +identity, reload, order, deadline or timer-cleanup assertions. Four import/setup/ +disposer rejection and timeout controls confirm restoration of clocks, fetch, +argv and environment. Teardown holds its fake clock through bounded native cleanup and pending-timer checks, then restores real clocks. The [teardown qualification](https://github.com/stablyai/orca/actions/runs/37195736834) passes all 41 cases and 12 failure and restoration control invocations, including interval and retry leaks missed by the earlier teardown. +Title-send authorization tests retain real terminal creation, graph binding and +positive evidence paths. Negative process-evidence probes use scoped clocks +after setup: both 150 ms polling loops retain their 6,500 ms budget, crossed at +6,600 ms, and the send guard retains its 1,050 ms deadline. Before/at assertions +check 6,599/6,600 and 1,049/1,050 ms. All nine original cases remain. Actual +early/late wrapper and guard deadlines, false spinner identity and unknown-agent +authorization faults fail their intended assertions; native clocks and runtime +instance spies are restored after each failure. +Range-selection tests stub only the saved-note send menu, which their empty +comment populations never render. A facade typed from the actual menu props +throws if invoked, and an afterEach assertion verifies no call with unconditional +mock clearing in finally. The real hook, Monaco constants/model, line and range +drag behavior, draft-card lifecycle and open-inline-card chord remain. All 15 +original test bodies are byte-unchanged. Three actual range/hunk/draft faults +fail their original assertions; a real draft-render menu call hits the facade +and sentinel, and an outer cleanup check proves mock state cleared after failure. +The actual saved-note menu retains its independent component tests. + ## October 2 headless detector compiler cache The deferred detector already avoids dependency setup for known build inputs. @@ -601,6 +707,8 @@ All commands passed and plans matched within each comparison. These command timings exclude setup and queues; compiler variation contributes to the cold difference. The retained arrangement showed no cold compiler penalty. +The sequential gate in [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity) supersedes the earlier rejection below. + Combining static analysis too was rejected. An [alternating same-runner comparison](https://github.com/stablyai/orca/actions/runs/36835091650) saved runner occupancy, but cold compilation slowed from 61–64 to 83–89 seconds @@ -1739,6 +1847,8 @@ these local body measurements do not establish hosted or whole-PR time savings. ## Sequential static analysis and typecheck: retain separate jobs +The earlier recommendation below is superseded by [October 4 shared PR preflight capacity](#october-4-shared-pr-preflight-capacity). + A four-trial hosted screen kept the slim router unchanged and compared the two independent ARM jobs with one ARM job running their unchanged checks sequentially. The [compiler/planner census](https://github.com/stablyai/orca/actions/runs/37069472888) @@ -1858,6 +1968,32 @@ the imported helper has no top-level side effects, and the Linux rebuild branch is unchanged. Focused tests verify its Linux/macOS no-op behavior. Final-head PR checks qualify separately. +## October 4 reusable cells for terminal context scans + +Terminal cursor-context scans now request one reusable cell per invocation when +the adapter offers getNullCell, and pass it through all unchanged text/style +scans. Adapters without that optional method keep the existing allocating path. +The scratch cell is local and no cell reference escapes into returned context. +Browser composer/readiness text, colors, bold flags and wrapping are unchanged. + +Three alternating one-worker ARM pairs in +[37182789677](https://github.com/stablyai/orca/actions/runs/37182789677) +ran all 19 original cases from readiness census suite 2. Baseline complete +invocations were 37.141 / 37.879 / 37.090 seconds; candidate invocations were +33.887 / 33.387 / 32.916 seconds. Median 37.141 to 33.387 seconds saves 10.1%. +This is a focused workload measurement, not a whole-shard or queue-delay claim. + +Separate baseline/candidate captures retained all 192 cases across six census +suites. Every context and visible projection matched: 643,926 of each, with +7,465,308,324 complete length-prefixed payload bytes hashed per test/type/order. +The canonical capture digest was +`f7440c0f1b5bbb57127cd29245530029415c8e9e243c1744359f330b3c7ace19`. +These captures run outside the timing samples. All 41 cursor/composer/browser +consumer checks passed. Seven faults for lost dim filtering, wide continuation, +bold prompt, custom foreground, wrap preservation, adapter fallback and scratch +reuse failed their intended assertions. Node and web typecheck, lint and format +passed. Two added controls prove per-call scratch lifetime and adapter parity. + ## October 3 producer follow-up: automatic selection for the measured profile The first producer rollout in [#24927](https://github.com/stablyai/orca/pull/24927) @@ -1895,6 +2031,36 @@ automatic selection and cold publication, not a new timing result. Local verification passed eight suites / 184 tests, the changed-code quality gate and compiled-composite actionlint. +## October 4 shared PR preflight capacity + +Static analysis and the unchanged compiler now share one ARM runner and guarded +Node 24 install. Static checks finish and all background work joins before the +compiler starts; unit planning still overlaps compilation. Each phase keeps its +classifier output. Successful no-op background bodies register every required +join when a phase is unselected or an earlier step failed. Unit and package +consumers depend on physical job success, including action cleanup. + +Three counterbalanced pairs in +[37180613601](https://github.com/stablyai/orca/actions/runs/37180613601) +used the same frozen checkout `f199a20c3acd`, Node 24.21.0, pnpm 12.8.1, +policy hashes, native cache hits, warm TypeScript cache and 10,787-file unit plan. +Both arms used the PR root-only download-store policy. Total active job time was +152 / 153 / 151 seconds separately and 138 / 133 / 129 combined. Excluding the +extra measurement-only evidence steps gives 151 / 151 / 149 versus +136 / 132 / 128 seconds: median 151 to 132, saving 19 seconds (12.6%). +Two heavy runner admissions become one. This saves capacity; it does not prove a +whole-PR latency or queue gain. The median active dependency barrier increases +from 116 to 132 seconds because compilation follows static checks. + +The separate physical-failure run +[37180755694](https://github.com/stablyai/orca/actions/runs/37180755694) +proved that an included TypeScript error failed the actual compiler, its planner +still joined, and unit/package admissions skipped. A registered late action post +failure also blocked both consumers after successful foreground checks and +published shards. All 12 unselected/prior-failure no-op backgrounds joined, and +the downstream audit passed. Local workflow contracts passed 239 tests across +12 suites; lint and formatting passed. + ## October 3 retired-cache collection observation The same owner-collection assertion failed in unit shard 3 of @@ -1912,6 +2078,47 @@ The source was restored afterward. Extra collection turns therefore preserve the strong-retention oracle. Hosted qualification is still required; these observations do not prove a particular VM-retention cause or quantify avoided retries. +## October 4 terminal oracle execution + +Three measured test-support changes preserve the original seeds, payloads, +chunk boundaries and meaningful assertions. Serializer comparisons reuse cells +and format only the first mismatch instead of allocating descriptors for every +cell. The terminal parity writer submits every original chunk in FIFO order and +awaits the final parser callback. The independent legacy frame oracle memoizes +measured code-point widths. Its discarded algebra-only case never called +production and still passed when production always threw. + +Three alternating one-worker hosted ARM pairs measured complete invocations: + +| Cohort | Baseline median | Candidate median | Saving | +| ---------------------------------------- | --------------- | ---------------- | ------ | +| Serializer replay/fuzz/descriptor checks | 71.675s | 46.581s | 35.0% | +| Emulator/reconciliation/color parity | 24.095s | 5.411s | 77.5% | +| Frame equivalence | 18.472s | 13.736s | 25.6% | + +[37180517143](https://github.com/stablyai/orca/actions/runs/37180517143) +retained 116 timed serializer passes and three existing/paired-control skips. +Separate captures matched all 190,796,645 raw bytes over 1,611 scenarios and +8,617 checkpoints (SHA256 `00ab219cfb31456af2ecd5e766d1b82d47abc751f6f2de0d7f795e36a936d3c7`), +including complete outputs and diagnostic payloads. Twenty candidate controls +passed; formatting/color/blank/clipping fault controls detected regressions. + +[37181073275](https://github.com/stablyai/orca/actions/runs/37181073275) +retained all 16 parity cases and default fuzz counts. Captures matched 2,325 +batches, 28,182 original chunks and 1,698,285 input bytes, with identical +terminal state and serialization per terminal/batch. Independent terminal +completion order differs, so comparison uses canonical per-terminal ordering +(SHA256 `c38ac1dbbefb9f6dc33ecfe7c495d65b707c1664614544622af93cfc1850e421`). +All 73 callback/parser/other-consumer controls passed; first-callback, reversed +chunks, missing empty boundary and early-completion faults failed. + +The frame candidate passed all 19 retained cases directly against the original +uncached legacy oracle, preserving 4,000 short and 800 near-cap seeded trials. +Sequence, surrogate width, byte width and span-transform faults failed real +assertions. A part-array alternative was rejected after adding time locally. +Hosted Node typecheck passed. These are focused workload savings, not measured +whole-shard or queue-delay improvements; application behavior is unchanged. + ## October 3 unit-selection evidence: include failed references The caller's `needs.test.result == 'success'` condition prevented the advisory @@ -1962,11 +2169,11 @@ Three alternating one-worker hosted ARM pairs in [37182181976](https://github.com/stablyai/orca/actions/runs/37182181976) measured these complete invocations: -| Cohort | Baseline seconds | Candidate seconds | Median saving | -| --- | --- | --- | --- | -| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% | -| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% | -| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% | +| Cohort | Baseline seconds | Candidate seconds | Median saving | +| --------------------------------- | ------------------------ | ------------------------ | ------------- | +| Three imports only, same 15 tests | 19.257 / 19.167 / 19.363 | 1.769 / 1.768 / 1.768 | 90.8% | +| Final four-file runtime cohort | 22.312 / 22.122 / 21.969 | 13.494 / 13.793 / 13.601 | 38.5% | +| Recovery crash boundaries | 24.082 / 24.075 / 24.814 | 8.061 / 8.105 / 9.074 | 66.3% | The final runtime cohort has seven real cases versus 16 including the copied loops; its new runtime case is included in candidate timing. Recovery has 50 @@ -2048,9 +2255,9 @@ Three alternating one-worker hosted ARM pairs in [37180614492](https://github.com/stablyai/orca/actions/runs/37180614492) measured these complete focused invocations: -| Suite | Baseline seconds | Candidate seconds | Median saving | -| --- | --- | --- | --- | -| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) | +| Suite | Baseline seconds | Candidate seconds | Median saving | +| --------------------- | ------------------------ | ------------------------ | --------------- | +| Git admission storm | 26.619 / 26.635 / 26.582 | 1.017 / 1.018 / 1.016 | 25.602s (96.2%) | | Antigravity readiness | 27.347 / 27.910 / 27.550 | 13.855 / 13.894 / 13.800 | 13.695s (49.7%) | Each candidate passed its original meaningful checks. Hosted Node typecheck @@ -2059,3 +2266,120 @@ FIFO-only priority failed the queued-contention or interactive-start assertion. Two additional local transcript faults failed the original picker-rejection and repaint-readiness assertions. These are focused suite savings; whole-shard time and queue delay were not measured by this experiment. + +## October 4 aggregate unit-test comparison + +A [counterbalanced hosted comparison](https://github.com/stablyai/orca/actions/runs/37197643399) +measured 128.605 seconds less summed test-process time (4.36%) and a 37.694-second +reduction in the slowest shard (5.98%). It compares the accepted optimizations +with their original file snapshots on the same source, five fixed shard +assignments, Node 24, Ubuntu ARM and four workers per process. This is one paired +trial, not a population estimate or a measurement of PR queue delay. + +| Test process | Original snapshots | Accepted optimizations | +| ----------------------------- | ------------------ | ---------------------- | +| Shard 1 | 574.221s | 533.116s | +| Shard 2 | 572.553s | 569.593s | +| Shard 3 | 630.629s | 592.935s | +| Shard 4 | 565.412s | 546.759s | +| Shard 5 | 609.410s | 581.218s | +| Sum: runner time during tests | 2952.225s | 2823.621s | +| Maximum: test critical path | 630.629s | 592.935s | + +Both arms cover exactly 10,838 timed modules on source `9574c8adb253` and tree +`4d5487e8b827`. One added eight-case batching qualification passes in a separate +0.770-second invocation outside the table, completing the 10,839-module ordinary +census. The complete timed case and outcome +comparison accounts for eight approved coverage changes: 105,231 original cases +versus 105,242 candidate cases. Removed copied simulations and an algebra-only +case are accompanied by real runtime, retention, recovery and reusable-cell +regressions. No unexpected case or outcome difference is accepted. + +Each arm starts with distinct empty transform and result caches; Node compilation +caching is disabled. Cold-cache execution ordering remains Vitest's default and +can change with source size. Source snapshots, assignments, raw reports and case outcomes +are checked. Only three case-title fields containing random temporary paths or +a UUID use stable identities, bound to the exact two test-source hashes; raw +titles remain in the artifacts. + +The five dependency setups total 84.284 seconds and are shared by both arms. +The actual paired jobs consumed 5,969 seconds and spanned 2,031 seconds from the +first start to the last completion. Those job figures include both treatments, +setup, uploads and staggered starts; they cannot be assigned to either arm or +used as a workflow saving. The table measures test-process wall time, not CPU +time or the complete CI workflow. Focused-suite percentages elsewhere in this +report are separate measurements and must not be summed into these results. + +The [earlier aggregate trial](https://github.com/stablyai/orca/actions/runs/37193799646) +is rejected because a real test failed; its timings do not qualify a gain. Two +local invocations sharing one XDG directory reproduced the Muse refresher failure. +The fixture now isolates and restores that setting in both arms. The historical +serializer case ledger was also independently corrected from the original source +before this fresh trial; its seven original cases and twenty candidate cases are +an explicit coverage change rather than an assumed equal census. + +## October 4 shard-weight holdouts + +Fresh shard weights were generated with the production importer from a complete +successful run of the accepted source. Two subsequent hosted holdouts used those +same weights and assignments without retraining. The +[first pair](https://github.com/stablyai/orca/actions/runs/37199891967) alternated +existing and fresh assignments across the five jobs; the +[second pair](https://github.com/stablyai/orca/actions/runs/37201939057) reversed +each job's treatment order. + +| Test-process measurement | First: existing | First: fresh | Reversed: existing | Reversed: fresh | +| ------------------------ | --------------- | ------------ | ------------------ | --------------- | +| Sum across five shards | 2813.595s | 2776.421s | 2924.689s | 2878.481s | +| Maximum shard wall | 578.735s | 584.709s | 603.995s | 614.408s | + +Fresh weights reduced summed test-process time by 1.32% and 1.58%, but increased +the slowest shard's time by 1.03% and 1.72%. The small capacity saving comes with +a repeated critical-path regression, so the existing weights remain. The 3.01% +improvement projected from training module durations is not a measured speed +gain. + +Every arm covers the same 10,839 modules and 105,250 case outcomes on source +`9574c8adb253`, tree `4d5487e8b827`, Node 24.21.0, Ubuntu ARM and four workers. +Both trials use cold caches and the same training data, weights, plans and case +identity rules. Complete raw reports, assignments, hashes and opposite treatment +orders are checked before combining the results. Two pairs supply no statistical +confidence or account-wide queue measurement. The second run's jobs started 119 +seconds apart; that stagger and the paired jobs' setup and upload costs are +separate from the treatment timings above. + +## October 4 remaining unit-test opportunities + +The audit retained real child-process, PTY, SSH and crash-boundary tests. It +removed copied simulations or algebra-only cases after fault controls showed +that they could pass with production behavior broken. The retained or replacement +tests exercise production behavior directly. The focused timings in this report +use the final qualified checks. They must not be added together to estimate a +whole-workflow saving. + +Several further changes did not justify promotion: + +- A synchronous readiness-clock screen retained all 49 shard cases and their + outcomes, but complete invocation time changed only from 34.210 to 33.518 + seconds in one local pair. That 2% result was too small to ship without a + stronger result; the original implementation remains. +- A larger local transform-cache screen reduced warm test execution. Separately + measured medians for warm tests (17.251 seconds), extraction (3.539 seconds) and + archive creation (3.092 seconds) sum to 23.882 seconds, versus 23.473 seconds + with caching disabled. This component estimate excludes transfer costs; it is not an + end-to-end measurement. Unkeyed plugin options, inherited configuration and + import priority also produced stale reuse. Persisted test transforms remain + disabled. +- Two successful full-run shadow references identified about 1.9% of + recorded worker time as omittable. That is advisory worker time, not measured + runner occupancy. It does not supply the failed-reference evidence or a + complete selected-run comparison needed to enable test selection. +- Six sampled failed PR runs contained no failed unit job that could trigger + unit-matrix fail-fast. Successful unit siblings of failures in other jobs + cannot be counted as savings from that policy. The sample is too small to + establish a population-wide rate, and the policy remains unchanged. + +These screens reject the examined changes; they do not establish that every +future optimization is exhausted. Shorter admitted jobs and one shared preflight +reduce demand on the existing runner allowance. They do not increase that +allowance or prove lower queue delay under different account traffic. diff --git a/docs/reference/jcode-hook-events.md b/docs/reference/jcode-hook-events.md deleted file mode 100644 index 5eaf0d52463..00000000000 --- a/docs/reference/jcode-hook-events.md +++ /dev/null @@ -1,163 +0,0 @@ -# jcode hook events - -What jcode actually emits, and why Orca's status mapping is shaped the way it is. -Everything below was captured from jcode **v0.87.1 (944f747e9)** by pointing every -`[hooks]` entry in `config.toml` at a script that appends `$JCODE_HOOK_PAYLOAD` to -a log, then running real turns. Re-capture before changing the mapping; do not -edit it from memory. - -## The six events - -jcode's `[hooks]` table (`crates/jcode-base/src/hooks.rs`) has six lifecycle -points. Five are **observers** — detached, fire-and-forget, they can never slow -the agent. One, `pre_tool`, is a **gate**: jcode spawns it, writes the tool input -to its stdin, and waits for it to exit before the tool runs. - -| Event | When | Orca state | Notable payload fields | -| --------------- | -------------------------------------------- | ---------- | --------------------------------------------------------- | -| `session_start` | TUI open, attach, or `--resume` | none | `source` = `create`/`attach`/`resume`, `model` | -| `turn_start` | prompt submitted, before the model generates | `working` | `source`, `model` | -| `pre_tool` | before each tool call (gate) | `working` | `tool_name`, `tool_input` (argument JSON as a string) | -| `post_tool` | after each tool call | `working` | `tool_name`, `status`, `duration_ms`, `output_bytes`/`error` | -| `turn_end` | turn finished | `done` | `status`, `duration_ms`, `model`, `last_assistant_text`, `error` | -| `session_end` | session closed | `done` | `source` = `close` | - -`session_start` is identity-only. jcode fires it on an idle TUI open, so mapping -it to `working` would spin before the user has typed anything (same reason Devin -does not map its `SessionStart`). - -## Captured payloads - -A `jcode run` turn that read one file and wrote another: - -```json -{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-haiku-4-5","session_id":"session_pawprint_1790149117838_071c2a106812396c","source":"create"} -{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"file_path\":\"sample.txt\",\"intent\":\"Read sample.txt to get its contents\"}","tool_name":"read"} -{"cwd":"/private/tmp/jcode-work","duration_ms":"0","event":"post_tool","output_bytes":"12","session_id":"session_pawprint_…","status":"ok","tool_name":"read"} -{"cwd":"/private/tmp/jcode-work","event":"pre_tool","session_id":"session_pawprint_…","tool_input":"{\"content\":\"HELLO\",\"file_path\":\"out.txt\",\"intent\":\"Write uppercased contents of sample.txt to out.txt\"}","tool_name":"write"} -{"cwd":"/private/tmp/jcode-work","duration_ms":"9","event":"post_tool","output_bytes":"137","session_id":"session_pawprint_…","status":"ok","tool_name":"write"} -``` - -A TUI turn that failed upstream (note `turn_start`, which the `run` path does not emit): - -```json -{"cwd":"/private/tmp/jcode-work","event":"session_start","model":"claude-opus-5","session_id":"session_snail_…","source":"create"} -{"cwd":"/private/tmp/jcode-work","event":"turn_start","model":"claude-opus-5","session_id":"session_snail_…","source":"chat"} -{"cwd":"/private/tmp/jcode-work","duration_ms":"6868","error":"Anthropic API error (503 Service Unavailable): …","event":"turn_end","model":"claude-opus-5","session_id":"session_snail_…","status":"error"} -``` - -Three consequences the mapping depends on: - -- **`turn_start` only fires on the streaming turn path** (TUI, desktop, swarm - workers, headless sessions), not `jcode run`. It is what fills the otherwise - blank window between a submitted prompt and the first tool call. -- **Only `pre_tool` carries `tool_input`.** `post_tool` reports the name and the - outcome, so the tool preview has to be held from the matching `pre_tool`. -- **Every jcode tool schema has an `intent` string** the model fills in. It is - the preview fallback when no tool-specific key (`file_path`, `command`, …) - matches. - -## Why Orca subscribes to the gate - -`pre_tool` is the only event that can report a tool *while it runs*. Without it a -three-minute `bash` shows no tool at all until it finishes. Two rules keep the -gate from ever costing the agent anything: - -1. **The POST is detached.** jcode calls `child.wait_with_output()`, which waits - for the process *and* reads its stderr to EOF — a backgrounded child that - inherited stderr would hold the gate open for as long as it ran. The managed - script runs the POST as `orca_post_jcode_event >/dev/null 2>&1 &`, so the - inherited pipes are closed and the script exits immediately. -2. **stdin is drained first.** jcode `write_all`s the full tool input to the - hook's stdin. A tool input larger than the pipe buffer (a big `write`) would - block that write until the gate timed out if nobody read it, so the script - drains stdin before any exit path. - -Orca never blocks a jcode tool call: the script always exits 0. - -## Questions and permissions - -jcode has **no interactive per-tool approval prompt**. Its safety model -(`crates/jcode-app-core/src/tool/bash_destructive_gate.rs`) either denies a -command outright or asks the model to justify it — both inside the tool, with no -human in the loop. There is therefore no hook, and no terminal-title state, for -"jcode is waiting on you" during ordinary tool use. - -The one tool a *human* answers is ambient mode's `request_permission` -(`crates/jcode-app-core/src/tool/ambient.rs`), resolved out of band with -`jcode permissions`. Orca maps a `pre_tool` for it to `waiting` and publishes the -tool input as the question card. `post_tool` for the same tool is *not* mapped — -by then the human has already answered. - -Matching is by exact tool name. jcode's live tool set is `agentgrep, apply_patch, -bash, batch, bg, browser, compile_remote, conversation_search, edit, gmail, -integration_tools, ls, macos_computer_use, maintainer_feedback, mcp, memory, -multiedit, open, panel, patch, read, schedule, session_search, side_panel, -skill_manage, swarm, todo, webfetch, websearch, write` plus the ambient tools; -a substring rule over that set would be matching on coincidence. - -## Terminal titles - -jcode paints OSC 0 titles roughly once a second. Captured sequence from one TUI -session: - -``` -jcode → 🐍 jcode Snake → 🐍 jcode/creek Snake → 🌐 jcode Snake · work ~0s → … → 🌐 jcode Snake · last ~6s -``` - -The format is ` jcode [ · +N -M][ · work|last ~]` -(`crates/jcode-tui/src/tui/app/terminal_title.rs`). Orca uses it for tab-bar -identity only — status comes from hooks, never from a parsed title. Note there is -no "needs input" title state; that is the same gap as above, not an omission in -the parser. - -## Per-pane daemons - -jcode runs one server/client daemon per runtime dir, and lifecycle hooks fire -*inside the daemon*. Every TUI client connects the daemon the first pane started, -so without isolation a second jcode pane's events carry the first pane's -`ORCA_PANE_KEY` and its status lands on the wrong tab. - -jcode does forward a client's terminal identity to hooks -(`CLIENT_TERMINAL_ENV_VARS` in `crates/jcode-terminal-launch/src/lib.rs`), but -that allowlist covers tmux/zellij/herdr and the terminal emulators — not -`ORCA_PANE_KEY`. Until it does, Orca stamps a per-pane `JCODE_RUNTIME_DIR` so -each pane gets its own daemon, socket, and lock. The value is a 16-hex hash of -the pane key because the socket path is capped at `SUN_LEN` (104 bytes) and a -full pane key never fits. - -## Windows hook launcher - -Use Jcode **v0.89.0 or newer** on Windows. Earlier observer hooks launch with -`DETACHED_PROCESS`, leaving their children without a console to inherit. A -console program such as the managed hook's `curl.exe` can then open a Windows -Terminal tab on every event. Jcode's launcher fix uses `CREATE_NO_WINDOW` for -observer hooks and the `pre_tool` gate, keeping their descendants invisible. -Changing the managed script alone cannot repair an older Jcode launcher. - -The managed Windows hook redirects its payload file into curl directly, avoiding -the extra shells that a `type ... | curl` pipeline starts. Existing managed scripts -are refreshed on Orca startup without changing the user's hook configuration. - -Report: https://github.com/stablyai/orca/pull/22539#issuecomment-5809618574 -Launcher fix: https://github.com/1jehuang/jcode/pull/1490 - -## Config shape - -`[hooks]` values accept a string or an array of strings (`HookCommands` in -`crates/jcode-config-types/src/lib.rs`), and jcode re-reads the config on reload, -so hooks can be added without restarting. jcode parses a hook command line -shell-style but **executes it directly, not through a shell** — the managed value -must be the script path, never an `if [ -f … ]` wrapper. - -That shell-style parse is `parse_hook_command` -(`crates/jcode-terminal-launch/src/lib.rs`), and it is why Orca stores the path -**shell-quoted**. The tokenizer splits on unquoted whitespace and consumes every -unquoted backslash as an escape, so a bare Windows path reaches `exec` as -`C:Usersme.orcaagent-hooksjcode-hook.cmd` and no hook fires at all; a POSIX home -with a space splits into two arguments. Single quotes pass a path through -verbatim — backslashes are literal inside them — so Orca single-quotes by -default and falls back to double quotes (escaping `\` and `"`) only for a path -that itself contains a single quote. The value is then TOML-quoted on the way -into the file, so neither the raw path nor the shell-quoted string appears -alone. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index a899fd97f9f..ac23e12afba 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -13,7 +13,8 @@ import { import packageJson from './package.json' with { type: 'json' } const BUNDLED_MAIN_DEPENDENCIES = new Set([ - '@streamparser/json', + 'stream-json', + 'stream-chain', '@xterm/headless', '@xterm/addon-serialize', 'tldts', diff --git a/mobile/src/components/PickerModal.tsx b/mobile/src/components/PickerModal.tsx index 9456307091f..45b6593c09f 100644 --- a/mobile/src/components/PickerModal.tsx +++ b/mobile/src/components/PickerModal.tsx @@ -15,6 +15,7 @@ export type PickerOption = { type Props = { visible: boolean title: string + subtitle?: string options: PickerOption[] selected: T onSelect: (value: T) => void @@ -32,6 +33,7 @@ type PickerModalContentProps = Pick< export function PickerModal({ visible, title, + subtitle, options, selected, onSelect, @@ -44,6 +46,7 @@ export function PickerModal({ {title} + {subtitle ? {subtitle} : null} state.setShowFilterModal(false)}> - Filter + + Filter + {WORKSPACE_VIEW_SHARED_NOTE} + {settings.activeFilterCount > 0 && ( Clear filters diff --git a/mobile/src/host-screen/host-screen-secondary-styles.ts b/mobile/src/host-screen/host-screen-secondary-styles.ts index af14aa2b97a..7f6c4f3f65c 100644 --- a/mobile/src/host-screen/host-screen-secondary-styles.ts +++ b/mobile/src/host-screen/host-screen-secondary-styles.ts @@ -47,11 +47,19 @@ export const hostScreenSecondaryStyles = StyleSheet.create({ paddingHorizontal: spacing.xs, marginBottom: spacing.md }, + filterModalHeading: { + flexShrink: 1 + }, filterModalTitle: { fontSize: 15, fontWeight: '600', color: colors.textPrimary }, + filterModalSubtitle: { + fontSize: 11, + color: colors.textMuted, + marginTop: 2 + }, clearFiltersText: { fontSize: 13, color: colors.textSecondary diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 653d7fbaaa1..ae688ab9072 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -158,7 +158,11 @@ export function useMobileStructuredAgentSession(args: { }) const messages = useMemo( - () => projectStructuredAgentSessionMessages(state.items, [], state.submissions), + // Off: the phone hands a rejected message back to its composer, so a row would show it twice. + () => + projectStructuredAgentSessionMessages(state.items, [], state.submissions, { + rejectedInPlace: false + }), [state.items, state.submissions] ) const turnId = activeStructuredAgentSessionTurnId(state.items) diff --git a/mobile/src/worktree/workspace-list-picker-options.ts b/mobile/src/worktree/workspace-list-picker-options.ts index 180d4038320..ca597ed38cc 100644 --- a/mobile/src/worktree/workspace-list-picker-options.ts +++ b/mobile/src/worktree/workspace-list-picker-options.ts @@ -1,6 +1,9 @@ import type { PickerOption } from '../components/PickerModal' import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings' +// Why: the host may be headless, so the note can't promise a desktop sidebar. +export const WORKSPACE_VIEW_SHARED_NOTE = 'Synced across your devices' + export const WORKSPACE_SORT_OPTIONS: PickerOption[] = [ // Why: desktop and persisted state keep the `smart` key, while mobile shows the product label. { @@ -11,7 +14,7 @@ export const WORKSPACE_SORT_OPTIONS: PickerOption[] = [ { value: 'name', label: 'Name', subtitle: 'Alphabetical by name' }, { value: 'recent', label: 'Recent', subtitle: 'Most recent output first' }, { value: 'repo', label: 'Repo', subtitle: 'Repository, then workspace name' }, - { value: 'manual', label: 'Manual', subtitle: 'Server order' } + { value: 'manual', label: 'Manual', subtitle: 'Desktop drag order' } ] export const WORKSPACE_GROUP_OPTIONS: PickerOption[] = [ diff --git a/mobile/src/worktree/workspace-view-settings.ts b/mobile/src/worktree/workspace-view-settings.ts index f1d17189adc..221fa7211be 100644 --- a/mobile/src/worktree/workspace-view-settings.ts +++ b/mobile/src/worktree/workspace-view-settings.ts @@ -7,7 +7,7 @@ import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/typ import { coerceMobileWorkspaceStatuses } from './mobile-workspace-statuses' export type MobileGroupMode = 'none' | 'workspaceStatus' | 'repo' | 'prStatus' -// Desktop sort adds 'manual'; mobile renders it but sorts by server order. +// Desktop sort adds 'manual'; mobile orders it by the desktop's drag ranks. export type MobileSortMode = 'smart' | 'name' | 'recent' | 'repo' | 'manual' // Desktop PersistedUIState fields this screen syncs (a structural subset). diff --git a/package.json b/package.json index 3e67dfb4813..3c1979d5ff0 100644 --- a/package.json +++ b/package.json @@ -182,7 +182,6 @@ "@floating-ui/dom": "1.8.0", "@linear/sdk": "^97.0.0", "@parcel/watcher": "^2.5.6", - "@streamparser/json": "0.0.26", "@xterm/addon-serialize": "0.15.0-beta.300", "@xterm/headless": "6.1.0-beta.302", "agent-browser": "~0.27.0", @@ -198,6 +197,8 @@ "sherpa-onnx": "1.12.37", "smol-toml": "1.8.0", "ssh2": "^1.17.0", + "stream-chain": "4.2.6", + "stream-json": "3.7.0", "tldts": "7.4.16", "tweetnacl": "^1.0.3", "ws": "^8.22.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fb1428002d..fbf12ea1ae1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,9 +196,6 @@ importers: '@parcel/watcher': specifier: ^2.5.6 version: 2.5.6 - '@streamparser/json': - specifier: 0.0.26 - version: 0.0.26 '@xterm/addon-serialize': specifier: 0.15.0-beta.300 version: 0.15.0-beta.300(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.303(patch_hash=dd0ccc59cd1ccf99f4d76e5aa2456da165fa0804dce19a833d7638bd07ffa393)) @@ -244,6 +241,12 @@ importers: ssh2: specifier: ^1.17.0 version: 1.17.0 + stream-chain: + specifier: 4.2.6 + version: 4.2.6 + stream-json: + specifier: 3.7.0 + version: 3.7.0 tldts: specifier: 7.4.16 version: 7.4.16 @@ -3185,9 +3188,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@streamparser/json@0.0.26': - resolution: {integrity: sha512-46597LNFI+MFdUnzX2QJWwmdTRdq0XVD+vVNJTtGVzIrnCuhG9pFo1OAzbNBqci8UJgk/X5KJZ6LcV+y7PTuDQ==} - '@swc/core-darwin-arm64@1.15.46': resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==} engines: {node: '>=10'} @@ -7354,6 +7354,12 @@ packages: resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} engines: {node: '>=18'} + stream-chain@4.2.6: + resolution: {integrity: sha512-1zeJ8CrtJfmiba26ui8jXkq/xLRFvhzkdH02D5QLO9Cnovgeb28IJxg88DdraWnjnkbOol/++uIAybJbJhk7ig==} + + stream-json@3.7.0: + resolution: {integrity: sha512-rCSBdcBP/bPk6T8QFcxAj1MSzAuc5i49cYW6IE7sYObNEPccBJIUiL6fU9c9BWt40aJK0siVynLX7lWaW8alXw==} + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -10208,8 +10214,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@streamparser/json@0.0.26': {} - '@swc/core-darwin-arm64@1.15.46': optional: true @@ -14965,6 +14969,12 @@ snapshots: stdin-discarder@0.3.2: {} + stream-chain@4.2.6: {} + + stream-json@3.7.0: + dependencies: + stream-chain: 4.2.6 + strict-event-emitter@0.5.1: optional: true diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index 2d161a01289..c8de61a9fda 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -30,6 +30,12 @@ __orca_restore_agent_teams_path() { export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac +fi # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 2187aaca0f0..18850b4a0ef 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -45,6 +45,40 @@ __orca_osc133_preexec() { # which prints a warning above every command under warn_create_global. builtin typeset -g __orca_in_command=1 } +__orca_deferred_line_init() { + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then + zle zle-line-init "$@" + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" + fi +} +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 + fi +} __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset @@ -54,12 +88,27 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if (( ${+widgets[__orca_saved_line_init]} )); then + if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi if __orca_has_feature markers; then precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + (( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd) preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) else precmd_functions=(${precmd_functions:#__orca_deferred_init}) fi + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { @@ -202,13 +251,25 @@ __orca_deferred_init() { # the permanent hook has not run yet and the first prompt would lose its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature + (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index fd151e03b12..05f3da594e0 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -33,6 +33,12 @@ __orca_restore_agent_teams_path() { export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac +fi if [ -n "${ORCA_WSL_CLI_DIR:-}" ]; then if [ -x "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" ]; then export PATH="$ORCA_WSL_CLI_DIR${PATH:+:$PATH}" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 7172b28d2dc..b3c8a92869c 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -45,6 +45,40 @@ __orca_osc133_preexec() { # which prints a warning above every command under warn_create_global. builtin typeset -g __orca_in_command=1 } +__orca_deferred_line_init() { + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then + zle zle-line-init "$@" + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" + fi +} +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 + fi +} __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset @@ -54,8 +88,17 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if (( ${+widgets[__orca_saved_line_init]} )); then + if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi if __orca_has_feature markers; then precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + (( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd) preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) else precmd_functions=(${precmd_functions:#__orca_deferred_init}) @@ -67,6 +110,12 @@ __orca_deferred_init() { printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\n' "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" >&2 fi fi + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { @@ -219,13 +268,25 @@ __orca_deferred_init() { # the permanent hook has not run yet and the first prompt would lose its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature + (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt index 3042bb11df4..e9cc47d8361 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt @@ -26,6 +26,12 @@ fi [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" [[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac +if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac +fi # Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as `omp config` keep # their normal argv shape. diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 102534564b7..21dce8480e6 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -31,6 +31,40 @@ builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}" builtin unset ORCA_HISTFILE __orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } __orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +__orca_deferred_line_init() { + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then + zle zle-line-init "$@" + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" + fi +} +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 + fi +} __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset @@ -40,7 +74,21 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if (( ${+widgets[__orca_saved_line_init]} )); then + if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi precmd_functions=(${precmd_functions:#__orca_deferred_init}) + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi if __orca_has_feature overlay; then # Why: remote startup files can re-export user defaults after relay spawn. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" @@ -170,13 +218,25 @@ __orca_deferred_init() { zle -N zle-line-init __orca_prompt_mark fi builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature + (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi } diff --git a/src/main/agent-hooks/managed-hook-script-refresh.test.ts b/src/main/agent-hooks/managed-hook-script-refresh.test.ts index 4cd1905d8aa..30fe4a658a9 100644 --- a/src/main/agent-hooks/managed-hook-script-refresh.test.ts +++ b/src/main/agent-hooks/managed-hook-script-refresh.test.ts @@ -147,8 +147,10 @@ describe('managed hook script refresh', () => { homedirMock.mockReturnValue(home) const previousGrokHome = process.env.GROK_HOME const previousKimiHome = process.env.KIMI_CODE_HOME + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME delete process.env.GROK_HOME delete process.env.KIMI_CODE_HOME + delete process.env.XDG_CONFIG_HOME try { await withPlatform('win32', () => { for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) { @@ -187,6 +189,11 @@ describe('managed hook script refresh', () => { } else { process.env.KIMI_CODE_HOME = previousKimiHome } + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome + } rmSync(home, { recursive: true, force: true }) } }) diff --git a/src/main/agent-hooks/server-startup-prompt-control.test.ts b/src/main/agent-hooks/server-startup-prompt-control.test.ts new file mode 100644 index 00000000000..19a6efd5a89 --- /dev/null +++ b/src/main/agent-hooks/server-startup-prompt-control.test.ts @@ -0,0 +1,146 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' +import { PANE } from './server.test-fixtures' + +describe('startup prompt control with status hooks disabled', () => { + it.each([false, true])( + 'persists a pending terminal status at shutdown after starting with hooks %s', + async (statusHooksEnabled) => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-terminal-persist-')) + const server = new AgentHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled }) + server.setStatusHooksEnabled(false) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'folder-1', + payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' } + }) + expect(server.getStatusSnapshotForPane(PANE)[0]?.state).toBe('done') + const statusPath = server.lastStatusPath + if (!statusPath) { + throw new Error('missing status persistence path') + } + server.stop() + expect(existsSync(statusPath)).toBe(true) + expect(JSON.parse(readFileSync(statusPath, 'utf8')).entries[PANE]).toMatchObject({ + paneKey: PANE, + worktreeId: 'folder-1', + payload: { state: 'done', prompt: 'terminal status survives quit', agentType: 'opencode' } + }) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + } + ) + + it('enables and disables status without restarting the control listener', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-toggle-')) + class IsolatedHookServer extends AgentHookServer { + constructor() { + super() + this._setOpenCodeBinderDepsForTests({ + dbPath: () => join(dir, 'no-user-db'), + listSessions: async () => [], + listPanes: () => [], + sweep: async () => [] + }) + } + } + const server = new IsolatedHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled: false }) + const endpoint = server.endpointFilePath + if (!endpoint) { + throw new Error('missing control endpoint') + } + const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8')) + const post = (path: string) => + fetch(`http://127.0.0.1:${coords.port}${path}`, { + method: 'POST', + headers: { 'x-orca-agent-hook-token': coords.token }, + body: '{}' + }) + expect((await post('/hook/opencode')).status).toBe(404) + await server.start({ statusHooksEnabled: true }) + expect(server.buildPtyEnv()).toHaveProperty('ORCA_AGENT_HOOK_PORT', coords.port) + expect((await post('/hook/opencode')).status).toBe(204) + server.setStatusHooksEnabled(false) + expect(server.buildPtyEnv()).toEqual({}) + expect((await post('/hook/opencode')).status).toBe(404) + await server.start() + expect((await post('/hook/opencode')).status).toBe(404) + server.setStartupPromptClaimListener( + () => 'pending', + () => {} + ) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH)).json()).toEqual({ + allowed: false, + pending: true + }) + server.setStatusHooksEnabled(true) + expect((await post('/hook/opencode')).status).toBe(204) + expect(server.endpointFilePath).toBe(endpoint) + expect(parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))).toEqual(coords) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-')) + const server = new AgentHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled: false }) + expect(server.buildPtyEnv()).toEqual({}) + const statusPath = server.lastStatusPath + if (!statusPath) { + throw new Error('missing status persistence path') + } + writeFileSync(statusPath, 'existing status must survive control-only shutdown') + const endpoint = server.endpointFilePath + if (!endpoint) { + throw new Error('missing control endpoint') + } + const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8')) + const post = (path: string, body: string, token = coords.token) => + fetch(`http://127.0.0.1:${coords.port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-orca-agent-hook-token': token }, + body + }) + expect((await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}', 'wrong')).status).toBe(403) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({ + allowed: false + }) + const clear = vi.fn() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, clear) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({ + allowed: true + }) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{')).json()).toEqual({ + allowed: false + }) + expect(claim).toHaveBeenCalledTimes(1) + expect((await post('/hook/opencode', '{}')).status).toBe(404) + expect((await post('/statusline/claude', '{}')).status).toBe(404) + server.stop() + expect(clear).toHaveBeenCalledTimes(1) + expect(readFileSync(statusPath, 'utf8')).toBe( + 'existing status must survive control-only shutdown' + ) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/server-transport-interference.test.ts b/src/main/agent-hooks/server-transport-interference.test.ts index 37c80087bdb..e8d5a8e357b 100644 --- a/src/main/agent-hooks/server-transport-interference.test.ts +++ b/src/main/agent-hooks/server-transport-interference.test.ts @@ -2,9 +2,11 @@ // short of its own Content-Length. The listener fails open on every request error, so the only // way this stays diagnosable is if the truncation is classified before it is swallowed. import { connect } from 'node:net' +import { ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import type { HookTransportInterferenceReport } from '../../shared/agent-hook-transport-interference' import { AgentHookServer } from './server' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' async function postTruncatedHook( port: number, @@ -40,11 +42,15 @@ async function postTruncatedHook( } /** Opens a POST that announces a body and then never sends it, so Orca's own slowloris cap ends it. */ -async function postStalledHook(port: number, token: string): Promise { +async function postStalledHook( + port: number, + token: string, + pathname = '/hook/claude' +): Promise { const socket = connect({ port, host: '127.0.0.1' }) await new Promise((resolve) => socket.on('connect', () => resolve())) socket.write( - `POST /hook/claude HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n` + `POST ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n` ) await new Promise((resolve) => { socket.on('close', () => resolve()) @@ -118,6 +124,41 @@ describe('AgentHookServer transport interference', () => { expect(reports).toHaveLength(1) }, 20_000) + it('classifies an interrupted startup claim as retryable without consuming it', async () => { + const { server, port, token, reports } = await startServer() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, () => {}) + const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead') + try { + await postTruncatedHook(port, token, { + pathname: OPENCODE_STARTUP_PROMPT_CLAIM_PATH, + sentBytes: '{"nonce":', + announcedLength: 1000 + }) + // The reset peer cannot receive this response; observe the real handler's classification. + expect(writeHead.mock.calls).toEqual([[503]]) + expect(claim).not.toHaveBeenCalled() + expect(reports).toEqual([]) + } finally { + writeHead.mockRestore() + } + }) + + it('keeps a startup claim stopped by its own slowloris cap as a denial', async () => { + const { server, port, token, reports } = await startServer() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, () => {}) + const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead') + try { + await postStalledHook(port, token, OPENCODE_STARTUP_PROMPT_CLAIM_PATH) + expect(writeHead.mock.calls).toEqual([[200, { 'content-type': 'application/json' }]]) + expect(claim).not.toHaveBeenCalled() + expect(reports).toEqual([]) + } finally { + writeHead.mockRestore() + } + }, 30_000) + it('never reports for POSTs that deliver their whole body', async () => { const { port, token, reports } = await startServer() diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 80c81f09142..825b51b8a63 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -11,21 +11,26 @@ import { readRequestBody } from '../../../shared/agent-hook-listener/request-bod import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing' import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits' import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference' -import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state' import { trackEmptyPaneKeyHook } from './server-transport-rules' -import { AgentHookServerRuntimeEnv } from './server-runtime-env' +import { AgentHookServerStatusHookLifecycle } from './server-status-hook-lifecycle' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt' -export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv { +export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle { /** Start the loopback listener after hydration and spool replay have settled. */ async start(options?: { env?: string userDataPath?: string endpointNamespace?: string + statusHooksEnabled?: boolean }): Promise { if (this.server) { + if (options?.statusHooksEnabled !== undefined) { + this.setStatusHooksEnabled(options.statusHooksEnabled) + } return } + this.statusHooksEnabled = options?.statusHooksEnabled !== false if (options?.env) { this.env = options.env @@ -37,30 +42,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.token = randomUUID() this.endpointFileWritten = false this.lastWrittenJson = null - if (!this.ownerStateInitialized) { - // Why: hydrate before binding the listener so an early hook POST runs against a populated map. - if (this.lastStatusFilePath) { - this.hydrateLastStatusFromDisk() - } - this.captureHydratedAuthorityCommitments() - // Drain before binding the listener so replay cannot race a live hook during startup. - if (this.endpointDir) { - const replayedPaneKeys = new Set() - drainAgentHookSpool({ - endpointDir: this.endpointDir, - getPersistedLaunchTokenHash: (paneKey) => - this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => { - this.ingestSpoolRecord(record) - replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) - } - }) - // Why: the owner may have died while Orca was down; check each replayed pane once. - for (const paneKey of replayedPaneKeys) { - void this.checkAgentPresence(paneKey) - } - } - this.ownerStateInitialized = true + if (this.statusHooksEnabled) { + this.initializeStatusHookOwner() } const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { @@ -84,6 +67,22 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname try { const body = await readRequestBody(req) + if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + res.writeHead(200, { 'content-type': 'application/json' }) + const claim = this.onStartupPromptClaim?.(body) + res.end( + JSON.stringify({ + allowed: claim === true, + ...(claim === 'pending' ? { pending: true } : {}) + }) + ) + return + } + if (!this.statusHooksEnabled) { + res.writeHead(404) + res.end() + return + } if (pathname === CLAUDE_STATUSLINE_PATHNAME) { const statusLineEvent = parseClaudeStatusLineBody(body) if (statusLineEvent) { @@ -152,6 +151,16 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv res.writeHead(204) res.end() } catch (error) { + if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) { + res.writeHead(503) + res.end() + return + } + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{"allowed":false}') + return + } // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut // by something on the loopback path, not by a bad payload. Fail open as before, but count it — // this is the one failure mode that silently stops status for every runtime at once. @@ -192,7 +201,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.rollbackTransportStart() throw error } - this.startOpenCodeBinderLoop() + if (this.statusHooksEnabled) { + this.startOpenCodeBinderLoop() + } } private rollbackTransportStart(): void { @@ -204,14 +215,19 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv } stop(): void { - // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. - this.flushStatusPersistSync() + // Terminal status may still have a pending write while hook ingress is disabled. + if (this.statusHooksEnabled || this.statusPersistTimer) { + this.flushStatusPersistSync() + } this.stopOpenCodeBinderLoop() this.stopTmuxStatus() this.rollbackTransportStart() this.env = 'production' this.onAgentStatus = null this.onClaudeStatusLine = null + this.clearStartupPromptClaims?.() + this.clearStartupPromptClaims = null + this.onStartupPromptClaim = null this.onPaneStatusCleared = null this.onTransportInterference = null this.transportInterference.reset() diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 1de09c3ebdf..14b78262734 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -26,6 +26,14 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row' const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER export abstract class AgentHookServerListeners extends AgentHookServerState { + setStartupPromptClaimListener( + listener: (body: unknown) => boolean | 'pending', + clear: () => void + ): void { + this.onStartupPromptClaim = listener + this.clearStartupPromptClaims = clear + } + protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { this.onAgentStatus?.(enriched) for (const listener of this.enrichedStatusListeners) { diff --git a/src/main/agent-hooks/server/server-runtime-env.ts b/src/main/agent-hooks/server/server-runtime-env.ts index 7bf7eaaee30..2f94bdd4b94 100644 --- a/src/main/agent-hooks/server/server-runtime-env.ts +++ b/src/main/agent-hooks/server/server-runtime-env.ts @@ -11,7 +11,7 @@ import { AgentHookServerIngestRemote } from './server-ingest-remote' export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote { buildPtyEnv(): Record { - if (this.port <= 0 || !this.token) { + if (!this.statusHooksEnabled || this.port <= 0 || !this.token) { return {} } const env: Record = { diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index f429c0e6350..172eaeaf1ef 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -89,6 +89,9 @@ export abstract class AgentHookServerState { protected env = 'production' protected onAgentStatus: ServerAgentStatusListener = null protected onClaudeStatusLine: ServerStatusLineListener = null + protected onStartupPromptClaim: ((body: unknown) => boolean | 'pending') | null = null + protected clearStartupPromptClaims: (() => void) | null = null + protected statusHooksEnabled = true protected onPaneStatusCleared: PaneStatusClearListener | null = null protected paneStatusClearListeners = new Set() protected statusDropListeners = new Set() diff --git a/src/main/agent-hooks/server/server-status-hook-lifecycle.ts b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts new file mode 100644 index 00000000000..15a43182ab2 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts @@ -0,0 +1,52 @@ +import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' +import { AgentHookServerRuntimeEnv } from './server-runtime-env' + +export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServerRuntimeEnv { + setStatusHooksEnabled(enabled: boolean): void { + if (enabled === this.statusHooksEnabled) { + return + } + if (!enabled) { + this.flushStatusPersistSync() + this.stopOpenCodeBinderLoop() + for (const timer of this.assistantMessageRetryTimers.values()) { + clearTimeout(timer) + } + this.assistantMessageRetryTimers.clear() + this.clearAllTranscriptPolls() + } + this.statusHooksEnabled = enabled + if (enabled && this.server) { + this.initializeStatusHookOwner() + this.startOpenCodeBinderLoop() + } + } + + protected initializeStatusHookOwner(): void { + if (!this.ownerStateInitialized) { + // Why: hydrate before binding the listener so an early hook POST runs against a populated map. + if (this.lastStatusFilePath) { + this.hydrateLastStatusFromDisk() + } + this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + const replayedPaneKeys = new Set() + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => { + this.ingestSpoolRecord(record) + replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) + } + }) + // Why: the owner may have died while Orca was down; check each replayed pane once. + for (const paneKey of replayedPaneKeys) { + void this.checkAgentPresence(paneKey) + } + } + this.ownerStateInitialized = true + } + } +} diff --git a/src/main/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index e956494fc76..e0052aa8b45 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -6,6 +6,7 @@ import { readFileSync, realpathSync, rmSync, + statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -122,6 +123,44 @@ describe('markCopilotFolderTrusted', () => { } }) + it('keeps a token-bearing config.json owner-only', () => { + if (process.platform === 'win32') { + return + } + const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) + const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json') + // Why: a restrictive runner umask would mask a dropped mode. + const originalUmask = process.umask(0o022) + try { + mkdirSync(join(testState.fakeHomeDir, '.copilot'), { recursive: true }) + writeFileSync(configPath, JSON.stringify({ copilotTokens: { a: 'secret' } }), { + mode: 0o600 + }) + markCopilotFolderTrusted(workspace, testState.fakeHomeDir) + expect(statSync(configPath).mode & 0o777).toBe(0o600) + expect(JSON.parse(readFileSync(configPath, 'utf-8')).copilotTokens).toEqual({ a: 'secret' }) + } finally { + process.umask(originalUmask) + rmSync(workspace, { recursive: true, force: true }) + } + }) + + it('creates a missing config.json owner-only', () => { + if (process.platform === 'win32') { + return + } + const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) + const originalUmask = process.umask(0o022) + try { + markCopilotFolderTrusted(workspace, testState.fakeHomeDir) + const configPath = join(testState.fakeHomeDir, '.copilot', 'config.json') + expect(statSync(configPath).mode & 0o777).toBe(0o600) + } finally { + process.umask(originalUmask) + rmSync(workspace, { recursive: true, force: true }) + } + }) + it('preserves existing config keys and dedups already-trusted folders', () => { const workspace = mkdtempSync(join(tmpdir(), 'orca-copilot-ws-')) const realpath = realpathSync(workspace) diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 2d52ee005e4..eac0591728b 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -97,7 +97,8 @@ export function markCopilotFolderTrusted(workspacePath: string, home: string): v if (!existsSync(configDir)) { mkdirSync(configDir, { recursive: true }) } - writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`) + // Why: config.json can hold copilotTokens, so it must stay owner-only (also on shared SSH hosts). + writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) } /** diff --git a/src/main/ai-vault/session-document-stream-contract.test.ts b/src/main/ai-vault/session-document-stream-contract.test.ts new file mode 100644 index 00000000000..3ab4053b303 --- /dev/null +++ b/src/main/ai-vault/session-document-stream-contract.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' +import { readStreamedSessionDocument } from './session-document-stream' + +async function* bytes(content: string, chunkSize = 7): AsyncGenerator { + const buffer = Buffer.from(content) + for (let offset = 0; offset < buffer.length; offset += chunkSize) { + yield buffer.subarray(offset, offset + chunkSize) + } +} + +function read( + content: string, + overrides: Partial>[0]> = {} +) { + return readStreamedSessionDocument({ + bytes: bytes(content), + arrayKey: 'messages', + fields: ['id'], + objectFields: { agent: ['model'] }, + create: (): unknown[] => [], + consume: (state, value) => { + state.push(value) + }, + ...overrides + }) +} + +describe('streamed session document contracts', () => { + it.each(['[]', 'null', 'false', '0', '"not an array"', '{}'])( + 'a later messages value %s clears an earlier fold', + async (last) => { + expect(await read(`{"messages":[1,2],"messages":${last}}`)).toEqual({ record: {}, state: [] }) + } + ) + + it('replaces a failed earlier array and continues with its successor', async () => { + const consume = (state: unknown[], value: unknown): void => { + if (value === 'bad') { + throw new Error('discarded failure') + } + state.push(value) + } + expect(await read('{"messages":["bad",1],"messages":[2,3]}', { consume })).toEqual({ + record: {}, + state: [2, 3] + }) + expect(await read('{"messages":["bad"],"messages":[]}', { consume })).toEqual({ + record: {}, + state: [] + }) + }) + + it('keeps the first consumer failure unless a later array replaces it', async () => { + const failure = new Error('consumer failed') + let calls = 0 + await expect( + read('{"messages":[1,2]}', { + consume: () => { + calls++ + throw failure + } + }) + ).rejects.toBe(failure) + expect(calls).toBe(1) + }) + + it('gives malformed trailing JSON precedence over a consumer failure', async () => { + await expect( + read('{"messages":[1]} trailing', { + consume: () => { + throw new Error('consumer') + } + }) + ).rejects.toBeInstanceOf(SyntaxError) + }) + + it('keeps projected object resets and full-field overlap precedence', async () => { + expect(await read('{"agent":{"model":"old"},"agent":null}')).toEqual({ + record: { agent: {} }, + state: [] + }) + expect(await read('{"agent":{"model":"m","extra":[1,2]}}', { fields: ['agent'] })).toEqual({ + record: { agent: { model: 'm', extra: [1, 2] } }, + state: [] + }) + expect(await read('{"messages":[1,2]}', { objectFields: { messages: ['model'] } })).toEqual({ + record: { messages: {} }, + state: [1, 2] + }) + expect( + await read('{"messages":{"model":"m","ignored":1}}', { + objectFields: { messages: ['model'] } + }) + ).toEqual({ record: { messages: { model: 'm' } }, state: [] }) + }) + + it('preserves selected prototype keys as own properties', async () => { + const content = + '{"id":{"__proto__":{"polluted":true}},"agent":{"model":{"constructor":"value","__proto__":{"x":1}}}}' + const parsed = await read(content) + expect(parsed?.record).toEqual(JSON.parse(content)) + expect(Object.getPrototypeOf(parsed?.record)).toBeNull() + expect(Object.prototype).not.toHaveProperty('polluted') + }) + + it.each(['', ' ', '{', '{"messages":[1,]}', '{"messages":[]}{"messages":[]}'])( + 'rejects malformed input %j', + async (content) => { + await expect(read(content)).rejects.toBeInstanceOf(SyntaxError) + } + ) + + it.each(['null', '[]', '123', '"text"'])( + 'does not publish a nonobject document %s', + async (content) => { + expect(await read(content)).toBeNull() + } + ) + + it('validates discarded subtrees and closes their source on malformed input', async () => { + let closed = false + async function* malformed() { + try { + yield Buffer.from('{"ignored":[{"deep":1},]}') + throw new Error('must not request another chunk') + } finally { + closed = true + } + } + await expect(read('', { bytes: malformed() })).rejects.toBeInstanceOf(SyntaxError) + expect(closed).toBe(true) + }) + + it('preserves source failure and closes the source even after a consumer failure', async () => { + const failure = new Error('disk failure') + let closed = false + async function* failing() { + try { + yield Buffer.from('{"messages":[1]') + throw failure + } finally { + closed = true + } + } + await expect( + read('', { + bytes: failing(), + consume: () => { + throw new Error('consumer') + } + }) + ).rejects.toBe(failure) + expect(closed).toBe(true) + }) + + it('preserves escaped astral text across large and byte-sized chunks', async () => { + const value = `${'x'.repeat(65530)}日本語😀\\literal` + const content = JSON.stringify({ messages: [value, '\ud800', '\udc00'] }) + for (const size of [1, 65536, content.length * 4]) { + expect(await read(content, { bytes: bytes(content, size) })).toEqual({ + record: {}, + state: [value, '\ud800', '\udc00'] + }) + } + }) +}) diff --git a/src/main/ai-vault/session-document-stream.ts b/src/main/ai-vault/session-document-stream.ts index 4d12ab0fcc2..4fcf24f7a55 100644 --- a/src/main/ai-vault/session-document-stream.ts +++ b/src/main/ai-vault/session-document-stream.ts @@ -1,6 +1,7 @@ import { StringDecoder } from 'node:string_decoder' -import { JSONParser, TokenizerError, TokenParserError, TokenType } from '@streamparser/json' +import { FlexAssembler, arrayRule, objectRule } from 'stream-json/core/utils/flex-assembler.js' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { createJsonTokenReader } from '../../shared/json-token-reader' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' /** Fold one root array while retaining only the root fields the agent parser uses. */ @@ -13,92 +14,108 @@ export async function readStreamedSessionDocument(args: { consume: (state: T, value: unknown) => void signal?: AbortSignal }): Promise<{ record: Record; state: T } | null> { - const parser = new JSONParser({ - paths: [ - ...args.fields.map((field) => `$.${field}`), - ...Object.entries(args.objectFields ?? {}).flatMap(([root, fields]) => - fields.map((field) => `$.${root}.${field}`) - ), - ...(args.arrayKey ? [`$.${args.arrayKey}`, `$.${args.arrayKey}.*`] : []) - ], - keepStack: false, - stringBufferSize: 64 * 1024 - }) const record: Record = Object.create(null) const fields = new Set(args.fields) - let depth = 0 - let expectingRootKey = false - parser.onToken = ({ token, value }) => { - if (depth === 1 && expectingRootKey && token === TokenType.STRING) { - if (typeof value === 'string' && Object.hasOwn(args.objectFields ?? {}, value)) { - record[value] = Object.create(null) - } - expectingRootKey = false - } - if (token === TokenType.LEFT_BRACE || token === TokenType.LEFT_BRACKET) { - if (depth === 0 && token === TokenType.LEFT_BRACE) { - expectingRootKey = true - } - depth++ - } else if (token === TokenType.RIGHT_BRACE || token === TokenType.RIGHT_BRACKET) { - depth-- - } else if (token === TokenType.COMMA && depth === 1) { - expectingRootKey = true - } - } + const objectFields = new Map( + Object.entries(args.objectFields ?? {}).map(([root, keys]) => [root, new Set(keys)]) + ) + const foldedArray = Symbol('folded session array') let state = args.create() - let currentArray: unknown = null let consumeFailure: { error: unknown } | undefined + let inFoldedArray = false + const reset = (): void => { + state = args.create() + consumeFailure = undefined + } + const retain = (path: (string | number)[]): boolean => { + const [root, child] = path + return ( + typeof root === 'string' && + ((root !== args.arrayKey && fields.has(root)) || + (inFoldedArray && root === args.arrayKey && path.length >= 2) || + (typeof child === 'string' && objectFields.get(root)?.has(child) === true)) + ) + } + // Every discarded container needs a rule; dropping only its parent still builds large children. + const discard = { + filter: (path: (string | number)[]) => !retain(path), + create: () => null, + add: () => {} + } + const assembler = new FlexAssembler({ + maxDepth: Infinity, + objectRules: [ + objectRule>({ + filter: (path) => path.length === 0, + create: () => record, + add: (target, key, value) => { + if (key === args.arrayKey) { + if (value !== foldedArray) { + reset() + } + } else if (fields.has(key)) { + target[key] = value + } + } + }), + objectRule>({ + filter: (path) => + path.length === 1 && + typeof path[0] === 'string' && + objectFields.has(path[0]) && + (!fields.has(path[0]) || path[0] === args.arrayKey), + create: (path) => { + const projected: Record = Object.create(null) + record[String(path[0])] = projected + return projected + }, + add: (target, key, value) => { + const root = assembler.path[0] + if (typeof root === 'string' && objectFields.get(root)?.has(key)) { + target[key] = value + } + } + }), + discard + ], + arrayRules: [ + arrayRule({ + filter: (path) => Boolean(args.arrayKey) && path.length === 1 && path[0] === args.arrayKey, + create: () => { + reset() + inFoldedArray = true + return null + }, + add: (_target, value) => { + if (!consumeFailure) { + try { + args.consume(state, value) + } catch (error) { + consumeFailure = { error } + } + } + }, + finalize: () => { + inFoldedArray = false + return foldedArray + } + }), + discard + ] + }) + const parser = createJsonTokenReader((token) => { + if ( + token.name === 'keyValue' && + assembler.depth === 1 && + !assembler.isArray && + objectFields.has(token.value) + ) { + record[token.value] = Object.create(null) + } + assembler.consume(token) + }) const decoder = new StringDecoder('utf8') let objectRoot: boolean | undefined - parser.onValue = ({ key, value, parent, stack }) => { - if (stack.length === 2 && stack[1].key === args.arrayKey && Array.isArray(parent)) { - if (parent !== currentArray) { - state = args.create() - consumeFailure = undefined - currentArray = parent - } - if (!consumeFailure) { - try { - args.consume(state, value) - } catch (error) { - consumeFailure = { error } - } - } - // The parser's array cursor is independent of retained array slots. - parent.pop() - } else if ( - stack.length === 2 && - typeof stack[1].key === 'string' && - typeof key === 'string' && - parent && - !Array.isArray(parent) - ) { - const root = stack[1].key - const projected = record[root] - if ( - Object.hasOwn(args.objectFields ?? {}, root) && - args.objectFields?.[root]?.includes(key) && - projected && - typeof projected === 'object' - ) { - Reflect.set(projected, key, value) - } - } else if (stack.length === 1 && typeof key === 'string') { - if (key === args.arrayKey) { - if (value !== currentArray || !Array.isArray(value)) { - state = args.create() - consumeFailure = undefined - } - currentArray = null - } else if (fields.has(key)) { - record[key] = value - } - if (parent && typeof parent === 'object') { - Reflect.deleteProperty(parent, key) - } - } - } for await (const chunk of args.bytes) { throwIfAiVaultScanCancelled(args.signal) if (objectRoot === undefined) { @@ -107,37 +124,17 @@ export async function readStreamedSessionDocument(args: { objectRoot = first === 123 } } - parseJson(() => parser.write(decoder.write(chunk))) + parser.write(decoder.write(chunk)) await yieldToEventLoop() } const tail = decoder.end() if (tail) { - parseJson(() => parser.write(tail)) - } - if (objectRoot === undefined) { - throw new SyntaxError('Unexpected end of JSON input') - } - if (!parser.isEnded) { - parseJson(() => parser.end(), true) + parser.write(tail) } + parser.end() throwIfAiVaultScanCancelled(args.signal) if (consumeFailure) { throw consumeFailure.error } return objectRoot ? { record, state } : null } - -function parseJson(run: () => void, ending = false): void { - try { - run() - } catch (error) { - if ( - error instanceof TokenizerError || - error instanceof TokenParserError || - (ending && error instanceof Error) - ) { - throw new SyntaxError(error.message) - } - throw error - } -} diff --git a/src/main/ai-vault/session-scanner-opencode-cancellation.test.ts b/src/main/ai-vault/session-scanner-opencode-cancellation.test.ts index 4f47af380cd..084a73cd661 100644 --- a/src/main/ai-vault/session-scanner-opencode-cancellation.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-cancellation.test.ts @@ -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 { @@ -61,6 +70,83 @@ function untilAborted(signal: AbortSignal | undefined): Promise { } 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) => { diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-scan-scope.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-scan-scope.test.ts new file mode 100644 index 00000000000..bc97f1aa388 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-scan-scope.test.ts @@ -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 { + 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 { + 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) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-scan-scope.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-scan-scope.ts new file mode 100644 index 00000000000..e3a53a535b2 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-scan-scope.ts @@ -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( + callerSignal: AbortSignal | undefined, + fn: (signal: AbortSignal, owner: WorkerThreadRequestOwner) => Promise + ): Promise { + 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() + +export async function withOpenCodeSqliteScanScope(fn: () => Promise): Promise { + 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( + signal: AbortSignal | undefined, + fn: (signal?: AbortSignal, owner?: WorkerThreadRequestOwner) => Promise, + agent?: 'opencode2' | 'zcode' | 'native-chat' +): Promise { + const scope = agent === 'zcode' || agent === 'native-chat' ? undefined : scanScope.getStore() + return scope ? scope.run(signal, fn) : fn(signal) +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts index 93df4ed987c..68fb8d09d90 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.test.ts @@ -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' }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts index f303de52443..c6a85cb58b0 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts @@ -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, signal?: AbortSignal ): Promise { - 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 { 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) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts index 13b9cd3cd5e..e89a6361b1e 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts @@ -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) } } diff --git a/src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts b/src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts index 754211c323c..9db4ba85e83 100644 --- a/src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts @@ -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 }) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 8716440a565..f0525c918c8 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -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 { + return withOpenCodeSqliteScanScope(() => scanAiVaultSessionStores(options)) +} + +async function scanAiVaultSessionStores(options: AiVaultScanOptions): Promise { // 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. diff --git a/src/main/antigravity/native-account-store.test.ts b/src/main/antigravity/native-account-store.test.ts index f6d3398c679..9fbd8ab183f 100644 --- a/src/main/antigravity/native-account-store.test.ts +++ b/src/main/antigravity/native-account-store.test.ts @@ -67,11 +67,11 @@ describe('protected Antigravity account snapshots', () => { const service = new AntigravityAccountService(store, h.backend) h.setNative(paddedCredential('new-account', 60000)) await expect(service.addCurrentAccount()).rejects.toThrow('could not be saved') - expect(readFileSync(path)).toEqual(before) + expect(readFileSync(path).equals(before)).toBe(true) expect(store.read().accounts).toHaveLength(52) h.setNative(paddedCredential('large-51', 65000)) await expect(service.listAccounts()).rejects.toThrow('could not be saved') - expect(readFileSync(path)).toEqual(before) + expect(readFileSync(path).equals(before)).toBe(true) expect(store.read().accounts).toHaveLength(52) }) @@ -115,7 +115,7 @@ describe('protected Antigravity account snapshots', () => { 'Protected secret storage' ) expect(() => store.read()).toThrow('Protected secret storage') - expect(readFileSync(path)).toEqual(before) + expect(readFileSync(path).equals(before)).toBe(true) } ) diff --git a/src/main/cli/orca-cli-child-path.test.ts b/src/main/cli/orca-cli-child-path.test.ts index 727732dd64c..7440b1a9ad8 100644 --- a/src/main/cli/orca-cli-child-path.test.ts +++ b/src/main/cli/orca-cli-child-path.test.ts @@ -27,6 +27,7 @@ describe('prependOrcaCliDirToChildPath', () => { platform: 'linux' }) expect(env.PATH).toBe(`${SHIM_DIR}:/usr/local/bin:/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe(SHIM_DIR) expect(shim.ensureLinuxTerminalOrcaCliShimDir).toHaveBeenCalledWith({ userDataPath: USER_DATA }) @@ -44,13 +45,14 @@ describe('prependOrcaCliDirToChildPath', () => { it('leaves packaged Linux PATH untouched when no shim could be written', () => { shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(null) - const env: Record = { PATH: '/usr/bin' } + const env: Record = { PATH: '/usr/bin', ORCA_CLI_BIN_DIR: '/old-host/cli' } prependOrcaCliDirToChildPath(env, { isPackaged: true, userDataPath: USER_DATA, platform: 'linux' }) expect(env.PATH).toBe('/usr/bin') + expect(env.ORCA_CLI_BIN_DIR).toBeUndefined() }) it('leads packaged macOS PATH with the bundled CLI dir', () => { @@ -62,11 +64,16 @@ describe('prependOrcaCliDirToChildPath', () => { platform: 'darwin' }) expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe(join(RESOURCES, 'bin')) expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() }) it('leads packaged Windows PATH with the bundled CLI dir under the env block spelling', () => { - const env: Record = { Path: 'C:\\Windows\\System32' } + const env: Record = { + Path: 'C:\\Windows\\System32', + ORCA_CLI_BIN_DIR: '/parent-host/cli', + ORCA_WSL_CLI_DIR: '/guest/orca/bin' + } prependOrcaCliDirToChildPath(env, { isPackaged: true, userDataPath: USER_DATA, @@ -75,6 +82,8 @@ describe('prependOrcaCliDirToChildPath', () => { }) expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows\\System32`) expect(env.PATH).toBeUndefined() + expect(env.ORCA_CLI_BIN_DIR).toBeUndefined() + expect(env.ORCA_WSL_CLI_DIR).toBe('/guest/orca/bin') }) it('leaves a packaged darwin/win32 PATH alone with no resources root', () => { @@ -101,6 +110,9 @@ describe('prependOrcaCliDirToChildPath', () => { platform }) expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}${pathDelimiter}/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe( + platform === 'win32' ? undefined : join(USER_DATA, 'cli', 'bin') + ) expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() }) diff --git a/src/main/cli/orca-cli-child-path.ts b/src/main/cli/orca-cli-child-path.ts index 2a8136c367f..a15943827cc 100644 --- a/src/main/cli/orca-cli-child-path.ts +++ b/src/main/cli/orca-cli-child-path.ts @@ -39,12 +39,16 @@ export function prependOrcaCliDirToChildPath( opts: OrcaCliChildPathOptions ): string | null { const platform = opts.platform ?? process.platform + delete env.ORCA_CLI_BIN_DIR // Why: matches node:path's `delimiter` for the running platform, but stays correct when a test // drives a foreign platform through the seam. const pathDelimiter = platform === 'win32' ? ';' : delimiter // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca. if (!opts.isPackaged) { const devCliBin = join(opts.userDataPath, 'cli', 'bin') + if (platform !== 'win32') { + env.ORCA_CLI_BIN_DIR = devCliBin + } const inheritedPath = readInheritedPath(env, platform) // Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter. env[resolvePathEnvKey(env, platform)] = inheritedPath @@ -55,6 +59,7 @@ export function prependOrcaCliDirToChildPath( // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) if (shimDir) { + env.ORCA_CLI_BIN_DIR = shimDir const inheritedEntries = readInheritedPath(env, platform) .split(pathDelimiter) .filter((entry) => entry.length > 0 && entry !== shimDir) @@ -64,6 +69,9 @@ export function prependOrcaCliDirToChildPath( } else if (opts.resourcesPath && (platform === 'darwin' || platform === 'win32')) { // Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI. const bundledCliBin = join(opts.resourcesPath, 'bin') + if (platform === 'darwin') { + env.ORCA_CLI_BIN_DIR = bundledCliBin + } const inheritedPath = readInheritedPath(env, platform) env[resolvePathEnvKey(env, platform)] = inheritedPath ? `${bundledCliBin}${pathDelimiter}${inheritedPath}` diff --git a/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts b/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts index 085777fdf8c..fc8f7b815a7 100644 --- a/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts +++ b/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts @@ -88,6 +88,49 @@ describe('legacy shared Codex config compatibility', () => { expect(readFileSync(join(sharedRuntimeHome, 'auth.json'), 'utf-8')).toBe(staleSharedAuth) }) + it('keeps an Orca-added MCP server while settings and trust still refresh', () => { + const baselinePath = join(sharedRuntimeHome, '.orca-config-settings-baseline.json') + const baseline = JSON.stringify({ version: 3, settings: {}, mcpServers: [] }) + writeFileSync(baselinePath, baseline) + writeFileSync( + join(systemCodexHome, 'config.toml'), + ['model = "canonical"', '', '[projects."/revoked"]', 'trust_level = "untrusted"', ''].join( + '\n' + ) + ) + writeFileSync( + join(sharedRuntimeHome, 'config.toml'), + [ + 'model = "stale"', + '', + '[projects."/trusted-in-orca"]', + 'trust_level = "trusted"', + '', + '[projects."/revoked"]', + 'trust_level = "trusted"', + '', + '[hooks.state."orca:stop:0:0"]', + 'enabled = true', + '', + '[mcp_servers.demo-mcp]', + 'command = "demo"', + '' + ].join('\n') + ) + + syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome }) + + const sharedConfig = readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8') + expect(sharedConfig).toContain('model = "canonical"') + expect(sharedConfig).not.toContain('model = "stale"') + expect(sharedConfig).toContain('[projects."/trusted-in-orca"]\ntrust_level = "trusted"') + expect(sharedConfig).toContain('[projects."/revoked"]\ntrust_level = "untrusted"') + expect(sharedConfig).not.toContain('[projects."/revoked"]\ntrust_level = "trusted"') + expect(sharedConfig).toContain('[hooks.state."orca:stop:0:0"]') + expect(sharedConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "demo"') + expect(readFileSync(baselinePath, 'utf-8')).toBe(baseline) + }) + it('does not delete config when the canonical source is transiently missing', () => { const staleConfig = 'model_provider = "stale-provider"\n' writeFileSync(join(sharedRuntimeHome, 'config.toml'), staleConfig, 'utf-8') diff --git a/src/main/codex/codex-collab-call-delegation.test.ts b/src/main/codex/codex-collab-call-delegation.test.ts index 96131248ddc..dbc116802e4 100644 --- a/src/main/codex/codex-collab-call-delegation.test.ts +++ b/src/main/codex/codex-collab-call-delegation.test.ts @@ -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 { 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') diff --git a/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts index 795716ed553..ae7770b5beb 100644 --- a/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts +++ b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts @@ -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 = [ diff --git a/src/main/codex/codex-config-mirror-mcp-ownership.test.ts b/src/main/codex/codex-config-mirror-mcp-ownership.test.ts index 2c8a86fd28b..44172582e21 100644 --- a/src/main/codex/codex-config-mirror-mcp-ownership.test.ts +++ b/src/main/codex/codex-config-mirror-mcp-ownership.test.ts @@ -21,6 +21,20 @@ beforeEach(() => { afterEach(() => rmSync(root, { recursive: true, force: true })) +const BASELINE_FILE = '.orca-config-settings-baseline.json' + +type StoredBaselineFixture = { + version: 1 | 3 + settings: Record + mcpServers?: string[] +} + +function writeBaseline(baseline: StoredBaselineFixture): string { + const serialized = JSON.stringify(baseline) + writeFileSync(join(runtimeHomePath, BASELINE_FILE), serialized) + return serialized +} + describe('canonical MCP ownership during config mirroring', () => { it('does not duplicate a server defined inline in the canonical MCP table', () => { writeFileSync( @@ -130,6 +144,60 @@ describe('MCP ownership migration', () => { ) }) + it('keeps the retained shared home canonical under a pre-ownership baseline', () => { + writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.removed]\ncommand = "old"\n') + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + writeBaseline({ version: 1, settings: {} }) + + syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath }) + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain( + '[mcp_servers.' + ) + }) + + it('keeps Orca-only servers in the retained shared home and drops ones removed from ~/.codex', () => { + writeFileSync( + join(runtimeHomePath, 'config.toml'), + [ + '[mcp_servers.demo-mcp]', + 'command = "orca-only"', + '[mcp_servers.removed]', + 'command = "mirrored"', + '[mcp_servers.kept]', + 'command = "stale"', + '' + ].join('\n') + ) + writeFileSync( + join(systemHomePath, 'config.toml'), + 'model = "system"\n[mcp_servers.kept]\ncommand = "system"\n' + ) + const baseline = writeBaseline({ version: 3, settings: {}, mcpServers: ['removed', 'kept'] }) + + syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath }) + + const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8') + expect(runtimeConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "orca-only"') + expect(runtimeConfig).not.toContain('[mcp_servers.removed]') + expect(runtimeConfig).toContain('[mcp_servers.kept]\ncommand = "system"') + expect(runtimeConfig).not.toContain('"stale"') + expect(readFileSync(join(runtimeHomePath, BASELINE_FILE), 'utf-8')).toBe(baseline) + }) + + it('leaves the retained shared home untouched when its baseline cannot be read', () => { + const runtimeConfig = 'model = "retained"\n[mcp_servers.demo-mcp]\ncommand = "orca-only"\n' + writeFileSync(join(runtimeHomePath, 'config.toml'), runtimeConfig) + writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n') + mkdirSync(join(runtimeHomePath, BASELINE_FILE)) + + expect(() => + syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath }) + ).toThrow() + + expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toBe(runtimeConfig) + }) + it('tracks commented CRLF names so their later removal remains authoritative', () => { writeFileSync( join(runtimeHomePath, 'config.toml'), diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index a97a8b9644c..74f8b2f0b53 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -16,7 +16,7 @@ import { type CodexSettingsPromotionHomes, type CodexSettingsPromotionPlan } from './config-settings-promotion' -import { readCodexSettingsBaseline } from './config-settings-baseline' +import { observeCodexSettingsBaseline, readCodexSettingsBaseline } from './config-settings-baseline' import { getCodexConfigSyncStatus, reportCodexConfigSyncOutcome } from './config-sync-stall' import { preserveRuntimeConflictValues } from './codex-config-settings-preservation' import { applyCodexDaemonSocketGuard } from './codex-daemon-socket-path-guard' @@ -161,15 +161,13 @@ export function syncSystemConfigIntoLegacySharedCodexHome( let mirroredRuntimeConfig = runtimeConfigBeforeMirror ?? '' if (rawSystemConfig.trim() !== '') { const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath) - // The retired home has no ownership baseline; its entire MCP root stays canonical. mirroredRuntimeConfig = runtimeConfigBeforeMirror !== null ? mergeSystemCodexConfigIntoRuntime( runtimeConfigBeforeMirror, prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir), sourceConfigDir, - new Set(), - true + ...readLegacySharedHomeMcpOwnership(homes.runtimeHomePath) ) : prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir) } @@ -186,6 +184,24 @@ export function syncSystemConfigIntoLegacySharedCodexHome( writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, nextRuntimeConfig) } +/** + * Why: MCP servers added from an Orca terminal exist only in the retired home, + * so its last mirror's baseline decides which ones ~/.codex owns. With no + * baseline the whole root stays canonical, as before; an unreadable one throws + * rather than guess. Read-only: this one-way refresh never advances it. + */ +function readLegacySharedHomeMcpOwnership( + runtimeHomePath: string +): [mirroredMcpServerNames: ReadonlySet, mirroredMcpServerRoot: boolean] { + const observation = observeCodexSettingsBaseline(runtimeHomePath) + if (observation.kind === 'indeterminate') { + throw new Error('Codex settings baseline could not be read') + } + return observation.kind === 'present' + ? [observation.baseline.mcpServers, observation.baseline.mcpServerRoot] + : [new Set(), true] +} + type CodexConfigMirrorResult = | { status: 'skipped-missing-source' } | { status: 'refused-indeterminate'; error: unknown } diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index 4dcd49e9645..517f9effb90 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -10,6 +10,7 @@ import { } from '../runtime/structured-worker-identity' const DEV_CLI_BIN_FIRST = /^[^:;]*[\\/]cli[\\/]bin[:;]/ +const DEV_CLI_BIN_DIR = /^[^:;]*[\\/]cli[\\/]bin$/ // The dev launcher by absolute path: a login shell's profile cannot reorder it behind a global. const DEV_CLI_LAUNCHER = /^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/ @@ -23,7 +24,11 @@ describe('buildCodexStructuredChildEnvironment', () => { cwd: '/worktree', codexHome: '/pinned/home', resumeThreadId: null, - env: { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' } + env: { + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/shell/home', + ORCA_CLI_BIN_DIR: '/inherited/unowned-cli' + } }, 'spawn-token', 'session-not-a-worker' @@ -35,6 +40,9 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: 'session-not-a-worker', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ...(process.platform !== 'win32' + ? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) } + : {}), ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) @@ -57,6 +65,9 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: sessionId, ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ...(process.platform !== 'win32' + ? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) } + : {}), ORCA_USER_DATA_PATH: expect.any(String), PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) @@ -97,6 +108,7 @@ const ENV_REPORTING_APP_SERVER = String.raw` result: { sessionId: process.env.ORCA_AGENT_SESSION_ID ?? null, cliCommand: process.env.ORCA_CLI_COMMAND ?? null, + cliBinDir: process.env.ORCA_CLI_BIN_DIR ?? null, path: process.env.PATH ?? process.env.Path ?? null } }) @@ -135,6 +147,7 @@ describe('the spawned Codex child', () => { await expect(connection.request('test/env')).resolves.toEqual({ sessionId, cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER), + cliBinDir: process.platform === 'win32' ? null : expect.stringMatching(DEV_CLI_BIN_DIR), path: expect.stringMatching(DEV_CLI_BIN_FIRST) }) } finally { diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index 8ad4b75bd38..78c197644e2 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -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)) }) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 70b2ecd7147..9276d670b4c 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -38,6 +38,9 @@ describe('CodexStructuredSessionAdapter.acquire', () => { ORCA_AGENT_SESSION_ID: 'session-1', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), + ...(process.platform !== 'win32' + ? { ORCA_CLI_BIN_DIR: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin$/) } + : {}), ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) diff --git a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts index 6ae5c7f4c63..bf2e2545102 100644 --- a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts +++ b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts @@ -1,4 +1,5 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell' import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' @@ -35,6 +36,7 @@ __orca_restore_agent_teams_path() { export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +${ORCA_CLI_POSIX_PATH_RESTORE} # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" diff --git a/src/main/daemon/serialize-grid-cell-descriptors.test.ts b/src/main/daemon/serialize-grid-cell-descriptors.test.ts index 490b40a1c9d..8aec878b790 100644 --- a/src/main/daemon/serialize-grid-cell-descriptors.test.ts +++ b/src/main/daemon/serialize-grid-cell-descriptors.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { Terminal } from '@xterm/headless' -import { bufferRows, cellDescriptor } from './serialize-grid-cell-descriptors' +import { cellDescriptor, compareBufferRows } from './serialize-grid-cell-descriptors' import { createFuzzTerminal, writeTerminal } from './serialize-grid-roundtrip' // Frozen allocating oracle from f69052e; a reused cell must preserve every descriptor. @@ -78,48 +78,6 @@ function allocatingBufferRows( } describe('serialize oracle cell reuse', () => { - it.each([false, true])( - 'matches allocating cells across SGR, wide text, buffers and resize (ConPTY=%s)', - (conpty) => { - const terminal = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty }) - try { - const writes = [ - 'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n', - '\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n', - '\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩‍💻é\r\n', - 'scroll1\r\nscroll2\r\nscroll3\r\n', - '\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m', - '\x1b[?1049l\x1b[2J\x1b[Hclear' - ] - for (const data of writes) { - writeTerminal(terminal, data) - for (const buffer of [ - terminal.buffer.normal, - terminal.buffer.alternate, - terminal.buffer.active - ]) { - expect(bufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)).toEqual( - allocatingBufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2) - ) - } - terminal.resize(terminal.cols === 14 ? 9 : 14, 4) - expect( - bufferRows(terminal.buffer.active, 0, terminal.buffer.active.length, terminal.cols) - ).toEqual( - allocatingBufferRows( - terminal.buffer.active, - 0, - terminal.buffer.active.length, - terminal.cols - ) - ) - } - } finally { - terminal.dispose() - } - } - ) - it('preserves the order of every text flag combination while reloading a plain cell', () => { const terminal = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 }) try { @@ -167,50 +125,11 @@ describe('serialize oracle cell reuse', () => { const scratch = terminal.buffer.active.getNullCell() expect(cellDescriptor(line, 3, 4, scratch)).toBe('CLIPPED') expect(cellDescriptor(line, 3, 4, scratch)).toBe(allocatingCellDescriptor(line, 3, 4)) - expect(bufferRows(terminal.buffer.active, 0, 1, 4)).toEqual( - allocatingBufferRows(terminal.buffer.active, 0, 1, 4) - ) } finally { terminal.dispose() } }) - it('loads every cell into one traversal-local scratch object and retains immutable descriptors', () => { - const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 }) - try { - writeTerminal(terminal, '\x1b[1mA\x1b[0m B界') - const buffer = terminal.buffer.active - const scratch = buffer.getNullCell() - const allocate = vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch) - const getLine = buffer.getLine.bind(buffer) - const loaded: unknown[] = [] - vi.spyOn(buffer, 'getLine').mockImplementation((y) => { - const line = getLine(y) - if (line) { - const getCell = line.getCell.bind(line) - vi.spyOn(line, 'getCell').mockImplementation((x, cell) => { - loaded.push(cell) - return getCell(x, cell) - }) - } - return line - }) - const expected = allocatingBufferRows(buffer, 0, 2, 4) - loaded.length = 0 - const actual = bufferRows(buffer, 0, 2, 4) - expect(actual).toEqual(expected) - expect(allocate).toHaveBeenCalledTimes(1) - expect(loaded).toHaveLength(8) - expect(loaded.every((cell) => cell === scratch)).toBe(true) - writeTerminal(terminal, '\x1b[2J\x1b[Hnew') - bufferRows(buffer, 0, 2, 4) - expect(actual).toEqual(expected) - } finally { - terminal.dispose() - vi.restoreAllMocks() - } - }) - it('keeps missing lines and invalid columns blank after a styled cell occupied the scratch', () => { const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 }) try { @@ -230,3 +149,202 @@ describe('serialize oracle cell reuse', () => { } }) }) + +function allocatingRowDiff(stage: string, expected: string[][], actual: string[][]) { + for (let y = 0; y < Math.max(expected.length, actual.length); y++) { + const expectedRow = expected[y] + const actualRow = actual[y] + if ( + !expectedRow || + !actualRow || + expectedRow.length !== actualRow.length || + !expectedRow.every( + (cell, x) => cell === actualRow[x] || (cell === CLIPPED && actualRow[x]?.startsWith('▯')) + ) + ) { + return { stage, row: y, expected: expectedRow?.join('|'), actual: actualRow?.join('|') } + } + } + return null +} + +function expectComparisonParity( + expected: Buffer, + actual: Buffer, + cols: number, + expectedStart = 0, + expectedEnd = expected.length, + actualStart = 0, + actualEnd = actual.length +): ReturnType { + const frozen = allocatingRowDiff( + 'parity', + allocatingBufferRows(expected, expectedStart, expectedEnd, cols), + allocatingBufferRows(actual, actualStart, actualEnd, cols) + ) + expect( + compareBufferRows( + 'parity', + expected, + expectedStart, + expectedEnd, + actual, + actualStart, + actualEnd, + cols + ) + ).toEqual(frozen) + return frozen +} + +describe('serialize oracle streaming comparison', () => { + it('reuses one cell per buffer and stops before rows after the first difference', () => { + const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 }) + const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 0 }) + try { + writeTerminal(source, 'first\r\nsecond\r\nthird\r\nlast') + writeTerminal(replay, 'wrong\r\nsecond\r\nthird\r\nlast') + for (const buffer of [source.buffer.active, replay.buffer.active]) { + const scratch = buffer.getNullCell() + vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch) + const getLine = buffer.getLine.bind(buffer) + vi.spyOn(buffer, 'getLine').mockImplementation((y) => { + const line = getLine(y) + if (line) { + const getCell = line.getCell.bind(line) + vi.spyOn(line, 'getCell').mockImplementation((x, cell) => { + expect(cell).toBe(scratch) + return getCell(x, cell) + }) + } + return line + }) + } + const diff = compareBufferRows( + 'visible-grid', + source.buffer.active, + 0, + 4, + replay.buffer.active, + 0, + 4, + 8 + ) + expect(diff?.row).toBe(0) + for (const buffer of [source.buffer.active, replay.buffer.active]) { + expect(buffer.getNullCell).toHaveBeenCalledTimes(1) + expect(buffer.getLine).toHaveBeenCalledTimes(2) + expect(buffer.getLine).toHaveBeenCalledWith(0) + expect(buffer.getLine).toHaveBeenCalledWith(3) + } + writeTerminal(source, '\x1b[2J\x1b[Hchanged') + expect(diff?.expected).toContain('f·w1·f0:-1·b0:-1·0000000') + } finally { + source.dispose() + replay.dispose() + vi.restoreAllMocks() + } + }) + + it.each([false, true])( + 'preserves first-row diagnostics through buffer changes (ConPTY=%s)', + (conpty) => { + const source = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty }) + const replay = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty }) + try { + for (const [index, data] of [ + 'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n', + '\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n', + '\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩‍💻é\r\n', + 'scroll1\r\nscroll2\r\nscroll3\r\n', + '\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m', + '\x1b[?1049l\x1b[2J\x1b[Hclear' + ].entries()) { + writeTerminal(source, data) + writeTerminal(replay, data) + for (const [expected, actual] of [ + [source.buffer.active, replay.buffer.active], + [source.buffer.normal, replay.buffer.normal], + [source.buffer.alternate, replay.buffer.alternate] + ]) { + expect(expectComparisonParity(expected!, actual!, source.cols)).toBeNull() + expectComparisonParity(expected!, actual!, source.cols + 2, -1, expected!.length + 1) + } + const corruption = `\x1b[H\x1b[0mFAULT${index}` + writeTerminal(replay, corruption) + expect( + expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols) + ).not.toBeNull() + writeTerminal(source, corruption) + source.resize(source.cols === 14 ? 9 : 14, 4) + replay.resize(source.cols, 4) + expectComparisonParity(source.buffer.active, replay.buffer.active, source.cols) + } + } finally { + source.dispose() + replay.dispose() + } + } + ) + + it('detects every text-flag loss and compares all combinations against the allocating oracle', () => { + const source = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 }) + const replay = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 }) + try { + const sgr = [1, 2, 3, 4, 7, 8, 9] + const writeFlags = (terminal: Terminal, mask: number): void => { + const codes = sgr.filter((_code, bit) => (mask & (1 << bit)) !== 0) + writeTerminal(terminal, `\x1b[H\x1b[0m\x1b[${codes.length ? codes.join(';') : 0}mA\x1b[0mB`) + } + for (let mask = 0; mask < 128; mask++) { + writeFlags(source, mask) + writeFlags(replay, mask) + expect(expectComparisonParity(source.buffer.active, replay.buffer.active, 4)).toBeNull() + for (let bit = 0; bit < sgr.length; bit++) { + if ((mask & (1 << bit)) !== 0) { + writeFlags(replay, mask & ~(1 << bit)) + expect( + expectComparisonParity(source.buffer.active, replay.buffer.active, 4) + ).not.toBeNull() + } + } + } + } finally { + source.dispose() + replay.dispose() + } + }) + + it.each([ + ['abc界', 'abc ', 4, false], + ['abc界', 'abc\x1b[48;2;1;2;3m ', 4, false], + ['abc ', 'abc界', 4, true], + ['é', 'è', 8, true], + ['\x1b[32mA', '\x1b[38;5;2mA', 8, false], + ['\x1b[42m ', '\x1b[48;5;2m ', 8, false], + ['\x1b[7;31m ', '\x1b[7;32m ', 8, true], + ['\x1b[4m\x1b[2J', '\x1b[2J', 8, false], + ['\x1b[4m ', ' ', 8, true], + ['\x1b[4m \x1b[0mB', ' B', 8, true], + ['text\r\n', 'text', 8, false], + ['text\r\n\x1b[48;2;1;2;3m\x1b[2K', 'text', 8, true] + ] as const)( + 'preserves blank, clipped and color policy for %j / %j', + (expected, actual, cols, differs) => { + const source = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 }) + const replay = createFuzzTerminal({ cols: 8, rows: 4, scrollback: 10 }) + try { + writeTerminal(source, expected) + writeTerminal(replay, actual) + expect( + Boolean(expectComparisonParity(source.buffer.active, replay.buffer.active, cols)) + ).toBe(differs) + expectComparisonParity(source.buffer.active, replay.buffer.active, cols, -1, 6, -1, 5) + expectComparisonParity(source.buffer.active, replay.buffer.active, cols, 1, 5, 0, 4) + } finally { + source.dispose() + replay.dispose() + } + } + ) +}) diff --git a/src/main/daemon/serialize-grid-cell-descriptors.ts b/src/main/daemon/serialize-grid-cell-descriptors.ts index 8b6c331f5fc..9adbbc6c694 100644 --- a/src/main/daemon/serialize-grid-cell-descriptors.ts +++ b/src/main/daemon/serialize-grid-cell-descriptors.ts @@ -7,6 +7,7 @@ export type GridDiff = { stage: string; row?: number; expected: unknown; actual: type BufferLine = NonNullable> type Buffer = Terminal['buffer']['active'] +type Cell = ReturnType const COLOR_MODE_P16 = 16777216 const COLOR_MODE_P256 = 33554432 @@ -64,33 +65,158 @@ function cellsMatch(expected: string, actual: string): boolean { return expected === actual || (expected === CLIPPED && actual.startsWith('▯')) } -function rowsMatch(expected: string[], actual: string[]): boolean { - return expected.length === actual.length && expected.every((e, i) => cellsMatch(e, actual[i]!)) +function sameColor( + expectedMode: number, + expectedColor: number, + actualMode: number, + actualColor: number +): boolean { + return ( + expectedColor === actualColor && + canonicalColorMode(expectedMode, expectedColor) === canonicalColorMode(actualMode, actualColor) + ) } -export function bufferRows(buffer: Buffer, start: number, end: number, cols: number): string[][] { - const rows: string[][] = [] - const reusableCell = buffer.getNullCell() - for (let y = start; y < end; y++) { - rows.push(rowCells(buffer.getLine(y), cols, reusableCell)) +// Equal public fields imply identical descriptors; differing fields still use the frozen policy. +function sameCellFields(expected: Cell, actual: Cell): boolean { + if ( + expected.getWidth() !== actual.getWidth() || + !sameColor( + expected.getBgColorMode(), + expected.getBgColor(), + actual.getBgColorMode(), + actual.getBgColor() + ) + ) { + return false } - while (rows.length > 0 && rows.at(-1)!.every((c) => c === DEFAULT_BLANK)) { - rows.pop() + const expectedChars = expected.getChars() + const actualChars = actual.getChars() + const expectedBlank = expectedChars === '' || expectedChars === ' ' + const actualBlank = actualChars === '' || actualChars === ' ' + if (expectedBlank || actualBlank) { + return ( + expectedBlank && + actualBlank && + (expectedChars === ' ' && expected.isUnderline() !== 0) === + (actualChars === ' ' && actual.isUnderline() !== 0) && + (expectedChars === ' ' && expected.isStrikethrough() !== 0) === + (actualChars === ' ' && actual.isStrikethrough() !== 0) && + (expectedChars === ' ' && expected.isOverline() !== 0) === + (actualChars === ' ' && actual.isOverline() !== 0) && + (expected.isInverse() !== 0) === (actual.isInverse() !== 0) && + (expected.isInverse() === 0 || + sameColor( + expected.getFgColorMode(), + expected.getFgColor(), + actual.getFgColorMode(), + actual.getFgColor() + )) + ) } - return rows + return ( + expectedChars === actualChars && + sameColor( + expected.getFgColorMode(), + expected.getFgColor(), + actual.getFgColorMode(), + actual.getFgColor() + ) && + (expected.isBold() !== 0) === (actual.isBold() !== 0) && + (expected.isDim() !== 0) === (actual.isDim() !== 0) && + (expected.isItalic() !== 0) === (actual.isItalic() !== 0) && + (expected.isUnderline() !== 0) === (actual.isUnderline() !== 0) && + (expected.isInverse() !== 0) === (actual.isInverse() !== 0) && + (expected.isInvisible() !== 0) === (actual.isInvisible() !== 0) && + (expected.isStrikethrough() !== 0) === (actual.isStrikethrough() !== 0) + ) } -export function compareRowSets( +function trimmedEnd( + buffer: Buffer, + start: number, + end: number, + cols: number, + scratch: Cell +): number { + while (end > start) { + const line = buffer.getLine(end - 1) + let blank = true + for (let x = 0; x < cols; x++) { + if (cellDescriptor(line, x, cols, scratch) !== DEFAULT_BLANK) { + blank = false + break + } + } + if (!blank) { + break + } + end-- + } + return end +} + +function lineCellsMatch( + expected: BufferLine | undefined, + actual: BufferLine | undefined, + cols: number, + expectedScratch: Cell, + actualScratch: Cell +): boolean { + for (let x = 0; x < cols; x++) { + const expectedCell = + expected && x < expected.length ? expected.getCell(x, expectedScratch) : null + const actualCell = actual && x < actual.length ? actual.getCell(x, actualScratch) : null + const clipped = + x === cols - 1 && + ((expected && expected.length > cols && (expectedCell?.getWidth() ?? 0) > 1) || + (actual && actual.length > cols && (actualCell?.getWidth() ?? 0) > 1)) + if (expectedCell && actualCell && !clipped && sameCellFields(expectedCell, actualCell)) { + continue + } + if ( + !cellsMatch( + cellDescriptor(expected, x, cols, expectedScratch), + cellDescriptor(actual, x, cols, actualScratch) + ) + ) { + return false + } + } + return true +} + +/** Compares in place and formats only the first differing row, preserving diagnostic bytes. */ +export function compareBufferRows( stage: string, - expected: string[][], - actual: string[][] + expected: Buffer, + expectedStart: number, + expectedEnd: number, + actual: Buffer, + actualStart: number, + actualEnd: number, + cols: number ): GridDiff | null { - const length = Math.max(expected.length, actual.length) - for (let y = 0; y < length; y++) { - const e = expected[y] - const a = actual[y] - if (!e || !a || !rowsMatch(e, a)) { - return { stage, row: y, expected: e?.join('|'), actual: a?.join('|') } + const expectedScratch = expected.getNullCell() + const actualScratch = actual.getNullCell() + const expectedLength = + trimmedEnd(expected, expectedStart, expectedEnd, cols, expectedScratch) - expectedStart + const actualLength = trimmedEnd(actual, actualStart, actualEnd, cols, actualScratch) - actualStart + for (let y = 0; y < Math.max(expectedLength, actualLength); y++) { + const expectedLine = expected.getLine(expectedStart + y) + const actualLine = actual.getLine(actualStart + y) + if ( + y >= expectedLength || + y >= actualLength || + !lineCellsMatch(expectedLine, actualLine, cols, expectedScratch, actualScratch) + ) { + return { + stage, + row: y, + expected: + y < expectedLength ? rowCells(expectedLine, cols, expectedScratch).join('|') : undefined, + actual: y < actualLength ? rowCells(actualLine, cols, actualScratch).join('|') : undefined + } } } return null diff --git a/src/main/daemon/serialize-grid-roundtrip.ts b/src/main/daemon/serialize-grid-roundtrip.ts index d22bee6e511..26176c9ff5a 100644 --- a/src/main/daemon/serialize-grid-roundtrip.ts +++ b/src/main/daemon/serialize-grid-roundtrip.ts @@ -18,12 +18,7 @@ import { variantTrailingBackgroundRows } from './serialize-grid-variant-scope' import type { GridDiff } from './serialize-grid-cell-descriptors' -import { - bufferRows, - cellDescriptor, - CLIPPED, - compareRowSets -} from './serialize-grid-cell-descriptors' +import { cellDescriptor, CLIPPED, compareBufferRows } from './serialize-grid-cell-descriptors' export type NamedSerializer = { name: string; create: () => SerializeAddon } @@ -156,15 +151,25 @@ async function compareReplay( (src.type === dst.type ? null : { stage: 'active-buffer', expected: src.type, actual: dst.type }) ?? - compareRowSets( + compareBufferRows( 'visible-grid', - bufferRows(src, src.baseY, src.baseY + rows, cols), - bufferRows(dst, dst.baseY, dst.baseY + rows, cols) + src, + src.baseY, + src.baseY + rows, + dst, + dst.baseY, + dst.baseY + rows, + cols ) ?? - compareRowSets( + compareBufferRows( 'normal-buffer', - bufferRows(source.buffer.normal, normalStart, source.buffer.normal.length, cols), - bufferRows(replay.buffer.normal, 0, replay.buffer.normal.length, cols) + source.buffer.normal, + normalStart, + source.buffer.normal.length, + replay.buffer.normal, + 0, + replay.buffer.normal.length, + cols ) ?? (src.cursorX === dst.cursorX && src.cursorY === dst.cursorY ? null diff --git a/src/main/daemon/shell-ready-bash-wrapper.test.ts b/src/main/daemon/shell-ready-bash-wrapper.test.ts index 0fbdce87029..059612f5a4b 100644 --- a/src/main/daemon/shell-ready-bash-wrapper.test.ts +++ b/src/main/daemon/shell-ready-bash-wrapper.test.ts @@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import type * as DaemonBashRcfileModule from './daemon-bash-shell-ready-rcfile' +import { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile' +import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile' +import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path' import { OVERLAY_ONLY_FEATURES, STARTUP_COMMAND_FEATURES @@ -57,6 +60,45 @@ describePosix('daemon shell-ready bash wrapper', () => { vi.restoreAllMocks() }) + itWithBash.each([ + ['daemon', getDaemonBashShellReadyRcfileContent], + ['local', getBashShellReadyRcfileContent] + ] as const)('keeps this app CLI first after %s Bash profiles reset PATH', (_lane, content) => { + const cliBin = join(userDataPath, 'cli', 'bin') + const ambientBin = join(userDataPath, 'ambient-bin') + mkdirSync(cliBin, { recursive: true }) + mkdirSync(ambientBin) + for (const bin of [cliBin, ambientBin]) { + const launcher = join(bin, 'orca-dev') + writeFileSync(launcher, '#!/bin/sh\nexit 0\n') + chmodSync(launcher, 0o755) + } + const env: Record = { + HOME: userDataPath, + USERPROFILE: userDataPath, + PATH: `${ambientBin}:/usr/bin:/bin`, + ORCA_BACKGROUND_LAUNCH: '1' + } + const expectedLauncher = prependOrcaCliDirToChildPath(env, { + isPackaged: false, + userDataPath + }) + writeFileSync( + join(userDataPath, '.bash_profile'), + 'export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n' + ) + const rcfile = join(userDataPath, 'cli-path-rcfile') + writeFileSync(rcfile, content()) + const result = spawnSync('bash', ['-c', '. "$1"; command -v orca-dev', 'bash', rcfile], { + env, + encoding: 'utf8', + timeout: 5000 + }) + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + expect(result.stdout.split('\x1b]133;C\x07').join('').trim()).toBe(expectedLauncher) + }) + // Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows. it('emits OSC 133 C/D markers in the daemon bash wrapper', async () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() diff --git a/src/main/daemon/terminal-cursor-line-context.test.ts b/src/main/daemon/terminal-cursor-line-context.test.ts index 86066b95b8c..29afe00618b 100644 --- a/src/main/daemon/terminal-cursor-line-context.test.ts +++ b/src/main/daemon/terminal-cursor-line-context.test.ts @@ -9,7 +9,96 @@ function writeSync(terminal: Terminal, data: string): void { core.writeSync(data) } +type Cell = ReturnType + +function observeCellReads(terminal: Terminal) { + const allocated: Cell[] = [] + const targets: (Cell | undefined)[] = [] + const active = terminal.buffer.active + const source = { + rows: terminal.rows, + modes: terminal.modes, + buffer: { + active: { + baseY: active.baseY, + cursorX: active.cursorX, + cursorY: active.cursorY, + viewportY: active.viewportY, + getNullCell: () => { + const cell = active.getNullCell() + allocated.push(cell) + return cell + }, + getLine: (row: number) => { + const line = active.getLine(row) + if (!line) { + return undefined + } + return { + isWrapped: line.isWrapped, + length: line.length, + translateToString: line.translateToString.bind(line), + getCell: (column: number, reusableCell?: Cell) => { + targets.push(reusableCell) + return line.getCell(column, reusableCell) + } + } + } + } + } + } + return { source, allocated, targets } +} + describe('readTerminalCursorLineContext', () => { + it('reuses one cell across every scan without retaining it between reads', () => { + const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true }) + try { + writeSync(terminal, '\x1b[1m❯\x1b[22m 界e\u0301\x1b7\r\n\x1b[31mfooter\x1b8') + const rig = observeCellReads(terminal) + const first = readTerminalCursorLineContext(rig.source, terminal.rows) + expect(rig.allocated).toHaveLength(1) + expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2) + expect(rig.targets.every((cell) => cell === rig.allocated[0])).toBe(true) + rig.targets.length = 0 + expect(readTerminalCursorLineContext(rig.source, terminal.rows)).toEqual(first) + expect(rig.allocated).toHaveLength(2) + expect(rig.allocated[1]).not.toBe(rig.allocated[0]) + expect(rig.targets.every((cell) => cell === rig.allocated[1])).toBe(true) + } finally { + terminal.dispose() + } + }) + + it('preserves full context for an adapter without reusable cells', () => { + const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true }) + try { + writeSync( + terminal, + '\x1b[1m❯\x1b[22m typed\x1b7\x1b[2m hint\x1b[22m\r\n\x1b[38;2;1;2;3m界e\u0301\x1b[0m\x1b8' + ) + const rig = observeCellReads(terminal) + const source = { + ...rig.source, + buffer: { active: { ...rig.source.buffer.active, getNullCell: undefined } } + } + const context = readTerminalCursorLineContext(source, terminal.rows) + expect(context).toEqual(readTerminalCursorLineContext(terminal, terminal.rows)) + expect(context?.typedRows).toEqual(['❯ typed']) + expect(context?.typedRowsBelow[0]).toBe('界e\u0301') + expect(context?.beforeCursor).toBe('❯ typed') + expect(context?.afterCursor).toBe('') + expect(context?.rawAfterCursor).toBe(' hint') + expect(context?.promptGlyphBoldRows).toEqual([true]) + expect(context?.rowsBelowCustomForeground?.[0]).toBe(true) + expect(rig.allocated).toEqual([]) + expect(rig.targets.length).toBeGreaterThan(terminal.cols * 2) + expect(rig.targets.every((cell) => cell === undefined)).toBe(true) + } finally { + terminal.dispose() + } + }) + it.each([ { cols: 19, cursorRowTail: 'proceed with the ', continuation: 'release' }, { cols: 18, cursorRowTail: 'proceed with the', continuation: ' release' } diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 907092515f5..664bbaf1aa3 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -20,6 +20,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/bitbucket/client.ts', 1], ['main/bitbucket/user-request.ts', 1], ['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], ['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], diff --git a/src/main/ipc/filesystem-list-files-name-filter.test.ts b/src/main/ipc/filesystem-list-files-name-filter.test.ts index b529dd34b39..e22043a4d69 100644 --- a/src/main/ipc/filesystem-list-files-name-filter.test.ts +++ b/src/main/ipc/filesystem-list-files-name-filter.test.ts @@ -62,7 +62,7 @@ describe('listQuickOpenFiles name filter', () => { it('counts only matches against the ripgrep cap', async () => { wslAwareSpawnMock - .mockImplementationOnce(() => fakeRipgrep('a.ts\nb.ts\nc.ts\nios/AppDelegate.swift\n')) + .mockImplementationOnce(() => fakeRipgrep('a.ts\0b.ts\0c.ts\0ios/AppDelegate.swift\0')) .mockImplementationOnce(() => fakeRipgrep('')) const files = await listQuickOpenFiles( @@ -80,7 +80,7 @@ describe('listQuickOpenFiles name filter', () => { it('rejects with the bundled-ripgrep error when the filtered ignored pass cannot start', async () => { wslAwareSpawnMock - .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n')) + .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0')) .mockImplementationOnce(() => fakeRipgrep('', null, -2)) await expect( @@ -98,7 +98,7 @@ describe('listQuickOpenFiles name filter', () => { it('keeps primary matches when the ignored-file pass fails during a filtered scan', async () => { wslAwareSpawnMock - .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n')) + .mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\0')) .mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL')) await expect( @@ -116,7 +116,7 @@ describe('listQuickOpenFiles name filter', () => { it('still rejects an ignored-pass failure for unfiltered listings', async () => { wslAwareSpawnMock - .mockImplementationOnce(() => fakeRipgrep('a.ts\n')) + .mockImplementationOnce(() => fakeRipgrep('a.ts\0')) .mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL')) await expect( diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index 84567c85419..b6fe3453c22 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -88,6 +88,33 @@ describe('filesystem-list-files', () => { ) }) + it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { + const child = createMockProcess() + spawnMock.mockReturnValue(child) + const store: Store = Object.create(null) + const promise = listQuickOpenFiles('/repo', store) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) + child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) + if (kind === 'incomplete') { + child.emit('close', 0, null) + } + await expect(promise).rejects.toThrow('not valid UTF-8') + if (kind === 'invalid') { + expect(child.kill).toHaveBeenCalled() + } + }) + + it('counts NUL-delimited filenames containing newlines as one result each', async () => { + const child = createMockProcess() + spawnMock.mockReturnValue(child) + const store: Store = Object.create(null) + const promise = listQuickOpenFiles('/repo', store, undefined, undefined, 2) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1)) + child.stdout?.emit('data', 'first\nsecond.ts\0trailing\r\0third.ts\0') + await expect(promise).resolves.toEqual(['first\nsecond.ts', 'trailing\r']) + expect(child.kill).toHaveBeenCalled() + }) + it('rejects a synchronous launch failure before cleanup has been initialized', async () => { spawnMock.mockImplementationOnce(() => { throw Object.assign(new Error('spawn EMFILE'), { code: 'EMFILE' }) @@ -122,7 +149,7 @@ describe('filesystem-list-files', () => { ) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', 'one.ts\ntwo.ts') + p1.stdout?.emit('data', 'one.ts\0two.ts') p1.emit('close', 0, null) }, 0) const result = await promise @@ -154,12 +181,12 @@ describe('filesystem-list-files', () => { await flushMicrotasks() expect(spawnMock).toHaveBeenCalledTimes(1) expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--no-ignore-vcs') - source.stdout?.emit('data', 'source.ts\n') + source.stdout?.emit('data', 'source.ts\0') source.emit('close', 0, null) await flushMicrotasks() expect(spawnMock).toHaveBeenCalledTimes(2) expect(spawnMock.mock.calls[1]?.[1]).toContain('--no-ignore-vcs') - broad.stdout?.emit('data', 'ignored-file.ts\n') + broad.stdout?.emit('data', 'ignored-file.ts\0') await expect(listing).resolves.toEqual(['source.ts']) expect(broad.kill).toHaveBeenCalledOnce() }) @@ -177,18 +204,18 @@ describe('filesystem-list-files', () => { // Simulate stdout output for normal files setTimeout(() => { - p1.stdout?.emit('data', 'file1.ts\n') - p1.stdout?.emit('data', 'node_modules/bad.js\n') - p1.stdout?.emit('data', '.git/config\n') - p1.stdout?.emit('data', '.github/workflows/ci.yml\n') + p1.stdout?.emit('data', 'file1.ts\0') + p1.stdout?.emit('data', 'node_modules/bad.js\0') + p1.stdout?.emit('data', '.git/config\0') + p1.stdout?.emit('data', '.github/workflows/ci.yml\0') p1.stdout?.emit('data', 'dir1/') // incomplete line - p1.stdout?.emit('data', 'file2.js\n') + p1.stdout?.emit('data', 'file2.js\0') // The broad pass includes ignored files too. - p1.stdout?.emit('data', '.env.local\n') - p1.stdout?.emit('data', 'dist/generated.js\n') - p1.stdout?.emit('data', 'file1.ts\n') // Duplicate - p1.stdout?.emit('data', 'node_modules/ignored.js\n') + p1.stdout?.emit('data', '.env.local\0') + p1.stdout?.emit('data', 'dist/generated.js\0') + p1.stdout?.emit('data', 'file1.ts\0') // Duplicate + p1.stdout?.emit('data', 'node_modules/ignored.js\0') p1.emit('close', 0, null) }, 10) @@ -213,7 +240,7 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('C:\\repo', storeMock) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + p1.stdout?.emit('data', 'src/index.ts\0') p1.emit('close', 0, null) }, 10) @@ -237,7 +264,7 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('C:\\repo', storeMock) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', '/mnt/c/repo/src/index.ts\n') + p1.stdout?.emit('data', '/mnt/c/repo/src/index.ts\0') p1.emit('close', 0, null) }, 10) @@ -310,7 +337,7 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('/mock/root', storeMock) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + p1.stdout?.emit('data', 'src/index.ts\0') p1.emit('close', 2, null) }, 10) @@ -332,7 +359,7 @@ describe('filesystem-list-files', () => { await Promise.resolve() await Promise.resolve() - ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\npartial') + p1.stdout?.emit('data', 'src/index.ts\0partial') const rejection = expect(promise).rejects.toThrow('rg list timed out') await vi.advanceTimersByTimeAsync(10000) @@ -376,12 +403,12 @@ describe('filesystem-list-files', () => { const promise = listQuickOpenFiles('/mock/root', storeMock) setTimeout(() => { - ;(p1.stdout as unknown as EventEmitter).emit('data', '.next/cache/1.js\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.cache/data.json\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.stably/config.json\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.vscode/settings.json\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', '.idea/workspace.xml\n') - ;(p1.stdout as unknown as EventEmitter).emit('data', 'valid.ts\n') + p1.stdout?.emit('data', '.next/cache/1.js\0') + p1.stdout?.emit('data', '.cache/data.json\0') + p1.stdout?.emit('data', '.stably/config.json\0') + p1.stdout?.emit('data', '.vscode/settings.json\0') + p1.stdout?.emit('data', '.idea/workspace.xml\0') + p1.stdout?.emit('data', 'valid.ts\0') p1.emit('close', 0, null) }, 10) diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 4e3ea49dbe8..9d9042584d9 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -1,3 +1,5 @@ +import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode' +import { RipgrepFilenameDecoder, RipgrepFilenameError } from '../../shared/ripgrep-filename-decoder' import { sep } from 'node:path' import type { ChildProcess } from 'node:child_process' import type { Store } from '../persistence' @@ -8,7 +10,6 @@ import { buildExcludePathPrefixes, buildRgArgsForQuickOpen, normalizeQuickOpenRgLine, - type RgOutputMode, shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from '../../shared/quick-open-filter' @@ -79,6 +80,10 @@ export async function listQuickOpenFiles( const runRg = (args: string[]): Promise => { return new Promise((resolve, reject) => { + const filenameDecoder = new RipgrepFilenameDecoder((error) => { + killSpawnedRipgrepProcess(child) + finish(error) + }, Boolean(wslDistroForOutput)) let buf = '' let done = false let parseablePathCount = 0 @@ -138,18 +143,22 @@ export async function listQuickOpenFiles( return } let timer: ReturnType - const handleStdoutData = (chunk: string): void => { - buf += chunk + const handleStdoutData = (chunk: Buffer | string): void => { + const decoded = filenameDecoder.decode(chunk) + if (decoded === null) { + return + } + buf += decoded let start = 0 - let newlineIdx = buf.indexOf('\n', start) - while (newlineIdx !== -1) { - if (processLine(buf.substring(start, newlineIdx))) { + let delimiterIdx = buf.indexOf('\0', start) + while (delimiterIdx !== -1) { + if (processLine(buf.substring(start, delimiterIdx))) { buf = '' finishAtLimit() return } - start = newlineIdx + 1 - newlineIdx = buf.indexOf('\n', start) + start = delimiterIdx + 1 + delimiterIdx = buf.indexOf('\0', start) } buf = start < buf.length ? buf.substring(start) : '' } @@ -211,14 +220,15 @@ export async function listQuickOpenFiles( finish(new Error(`rg killed by ${signal}`)) return } + if (!filenameDecoder.finish()) { + return + } if (buf && processLine(buf)) { buf = '' finishAtLimit() return } - if (code === 0 || code === 1) { - finish() - } else if (code === 2 && parseablePathCount > 0) { + if (code === 0 || code === 1 || (code === 2 && parseablePathCount > 0)) { // rg can return 2 for unreadable subdirectories while still listing // usable files from the rest of the root. finish() @@ -257,7 +267,6 @@ export async function listQuickOpenFiles( children.push({ child, isDone: () => done, finish }) - child.stdout?.setEncoding('utf-8') child.stdout?.on('data', handleStdoutData) child.stderr?.on('data', handleStderrData) child.once('error', handleError) @@ -290,16 +299,9 @@ export async function listQuickOpenFiles( } function finishAtLimit(): void { - for (const entry of children) { - if (entry.isDone()) { - continue - } - entry.finish() - if (entry.child.exitCode === null && entry.child.signalCode === null) { - killSpawnedRipgrepProcess(entry.child) - } - } + killSurvivors() } + try { if (maxResults === undefined && maxSerializedBytes === undefined) { // The broader pass already includes source files; an unbounded listing needs only one scan. @@ -314,7 +316,12 @@ export async function listQuickOpenFiles( ) { // Why: a filtered scan walks the whole tree; an ignored-pass timeout keeps primary matches. await runRg(ignoredPass).catch((err: unknown) => { - if (!pathFilter || signal?.aborted || err instanceof RipgrepUnavailableError) { + if ( + !pathFilter || + signal?.aborted || + err instanceof RipgrepUnavailableError || + err instanceof RipgrepFilenameError + ) { throw err } }) @@ -329,19 +336,3 @@ export async function listQuickOpenFiles( ? result : limitQuickOpenFilesBySerializedBytes(result, maxSerializedBytes) } - -function getQuickOpenRgOutputMode( - rawLine: string, - translatedLine: string, - rootPath: string -): RgOutputMode { - if ( - translatedLine !== rawLine || - rawLine.startsWith('/') || - /^[A-Za-z]:[\\/]/.test(rawLine) || - rawLine.startsWith('\\\\') - ) { - return { kind: 'absolute', rootPath } - } - return { kind: 'cwd-relative' } -} diff --git a/src/main/ipc/filesystem-search-file-paths.test.ts b/src/main/ipc/filesystem-search-file-paths.test.ts index e9415d66431..57c817c3b40 100644 --- a/src/main/ipc/filesystem-search-file-paths.test.ts +++ b/src/main/ipc/filesystem-search-file-paths.test.ts @@ -81,6 +81,64 @@ describe('searchQuickOpenFilePaths', () => { ) }) + it('preserves cancellation while an incomplete UTF-8 scalar is buffered', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const controller = new AbortController() + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { + query: 'file', + limit: 2, + signal: controller.signal + }) + await flushMicrotasks() + child.stdout?.emit('data', Buffer.from([0xf0, 0x9f])) + controller.abort() + await expect(promise).rejects.toSatisfy(isFileListingCancellation) + }) + + it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { query: 'file', limit: 2 }) + await flushMicrotasks() + child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) + if (kind === 'incomplete') { + child.emit('close', 0, null) + } + await expect(promise).rejects.toThrow('not valid UTF-8') + if (kind === 'invalid') { + expect(child.kill).toHaveBeenCalled() + } + }) + + it('preserves control characters within ranked paths', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { + query: 'target', + limit: 2 + }) + await flushMicrotasks() + child.stdout?.emit('data', 'first\ntarget.ts\0target.ts\r\0') + child.emit('close', 0, null) + expect((await promise).paths.sort()).toEqual(['first\ntarget.ts', 'target.ts\r'].sort()) + }) + + it('rejects and stops an oversized path rather than ranking a truncated suffix', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + const promise = searchQuickOpenFilePaths('/repo', UNUSED_STORE, { + query: 'target', + limit: 2 + }) + await flushMicrotasks() + child.stdout?.emit('data', 'x'.repeat(64 * 1024 + 1)) + await expect(promise).rejects.toThrow('file path exceeds the listing limit') + expect(child.kill).toHaveBeenCalledTimes(1) + child.stdout?.emit('data', 'target.ts\0') + child.emit('close', 0, null) + }) + it('finds fuzzy matches after 100k paths without returning excluded worktrees', async () => { const child = createMockProcess() wslAwareSpawnMock.mockReturnValue(child) @@ -94,14 +152,11 @@ describe('searchQuickOpenFilePaths', () => { expect(wslAwareSpawnMock).toHaveBeenCalledTimes(1) expect(wslAwareSpawnMock.mock.calls[0][0]).toBe('/bundled/rg') expect(wslAwareSpawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs') - ;(child.stdout as unknown as EventEmitter).emit( + child.stdout?.emit( 'data', - `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\n')}\n` - ) - ;(child.stdout as unknown as EventEmitter).emit( - 'data', - 'nested/src/sta-4354-target.ts\nsrc/sta-4354-target.ts\n' + `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\0')}\0` ) + child.stdout?.emit('data', 'nested/src/sta-4354-target.ts\0src/sta-4354-target.ts\0') child.emit('close', 0, null) await expect(promise).resolves.toEqual({ @@ -138,7 +193,7 @@ describe('searchQuickOpenFilePaths', () => { limit: 32 }) await flushMicrotasks() - ;(child.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n') + child.stdout?.emit('data', 'src/target.ts\0') child.emit('close', 0, null) await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] }) @@ -164,10 +219,7 @@ describe('searchQuickOpenFilePaths', () => { await flushMicrotasks() expect(wslAwareSpawnMock).toHaveBeenCalledTimes(2) - ;(succeeded.stdout as unknown as EventEmitter).emit( - 'data', - 'data/chunk-077568/sta-4354-gitignored-target.bin\n' - ) + succeeded.stdout?.emit('data', 'data/chunk-077568/sta-4354-gitignored-target.bin\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual({ @@ -189,7 +241,7 @@ describe('searchQuickOpenFilePaths', () => { limit: 32 }) await flushMicrotasks() - ;(succeeded.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n') + succeeded.stdout?.emit('data', 'src/target.ts\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toMatchObject({ paths: ['src/target.ts'] }) diff --git a/src/main/ipc/filesystem-search-file-paths.ts b/src/main/ipc/filesystem-search-file-paths.ts index 4a003aa17c5..3de6f4c6342 100644 --- a/src/main/ipc/filesystem-search-file-paths.ts +++ b/src/main/ipc/filesystem-search-file-paths.ts @@ -1,3 +1,5 @@ +import { getQuickOpenRgOutputMode } from '../../shared/quick-open-ripgrep-output-mode' +import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder' import { sep } from 'node:path' import type { Store } from '../persistence' import { fileListingCancellationError } from '../../shared/file-listing-cancellation' @@ -6,8 +8,7 @@ import { buildRgArgsForQuickOpen, normalizeQuickOpenRgLine, shouldExcludeQuickOpenRelPath, - shouldIncludeQuickOpenPath, - type RgOutputMode + shouldIncludeQuickOpenPath } from '../../shared/quick-open-filter' import { isQuickOpenQueryTooLarge, QuickOpenPathRanker } from '../../shared/quick-open-path-search' import { @@ -110,7 +111,11 @@ function scanRipgrepPaths(args: { return Promise.reject(fileListingCancellationError(args.signal)) } return new Promise((resolve, reject) => { - const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0x0a) + const filenameDecoder = new RipgrepFilenameDecoder((error) => { + killSpawnedRipgrepProcess(child) + finish(error) + }, Boolean(args.wslDistroForOutput)) + const pathAccumulator = new QuickOpenSubprocessPathAccumulator(0) let done = false let parseablePathCount = 0 let processErrorObserved = false @@ -141,7 +146,7 @@ function scanRipgrepPaths(args: { : rawLine const relPath = normalizeQuickOpenRgLine( translated, - getOutputMode(rawLine, translated, args.authorizedRootPath) + getQuickOpenRgOutputMode(rawLine, translated, args.authorizedRootPath) ) if (relPath === null) { return @@ -178,11 +183,19 @@ function scanRipgrepPaths(args: { resolve() } } - const handleStdoutData = (chunk: string): void => { - pathAccumulator.push(chunk, (path) => { + const handleStdoutData = (chunk: Buffer | string): void => { + const decoded = filenameDecoder.decode(chunk) + if (decoded === null) { + return + } + const result = pathAccumulator.push(decoded, (path) => { processLine(path) return true }) + if (result === 'path-too-large') { + killSpawnedRipgrepProcess(child) + finish(new Error('Quick Open file path exceeds the listing limit')) + } } const handleStderrData = (): void => { /* drain */ @@ -233,6 +246,9 @@ function scanRipgrepPaths(args: { finish(new Error(`rg killed by ${signal}`)) return } + if (!filenameDecoder.finish()) { + return + } const trailingPath = pathAccumulator.finish() if (trailingPath) { processLine(trailingPath) @@ -249,7 +265,6 @@ function scanRipgrepPaths(args: { finish(fileListingCancellationError(args.signal)) } - child.stdout?.setEncoding('utf-8') child.stdout?.on('data', handleStdoutData) child.stderr?.on('data', handleStderrData) child.once('error', handleError) @@ -265,12 +280,3 @@ function scanRipgrepPaths(args: { } }) } - -function getOutputMode(rawLine: string, translatedLine: string, rootPath: string): RgOutputMode { - return translatedLine !== rawLine || - rawLine.startsWith('/') || - /^[A-Za-z]:[\\/]/.test(rawLine) || - rawLine.startsWith('\\\\') - ? { kind: 'absolute', rootPath } - : { kind: 'cwd-relative' } -} diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts index 9c37bacb20a..d4096500381 100644 --- a/src/main/ipc/filesystem-search-rg-timeout.test.ts +++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts @@ -199,7 +199,7 @@ describe('filesystem rg search timeout', () => { } ) - it('keeps post-spawn errors on the existing empty-result path', async () => { + it('rejects post-spawn errors instead of returning an empty result', async () => { const child = createMockProcess() Object.defineProperty(child, 'pid', { value: 1 }) wslAwareSpawnMock.mockReturnValue(child) @@ -212,7 +212,7 @@ describe('filesystem rg search timeout', () => { await flushMicrotasks() child.emit('error', new Error('post-spawn failure')) - await expect(promise).resolves.toMatchObject({ files: [] }) + await expect(promise).rejects.toThrow('post-spawn failure') }) // Why close(97): the WSL wrapper's "cd failed" code. It is above rg's own 0/1/2, so a handler @@ -298,6 +298,34 @@ describe('filesystem rg search timeout', () => { expect(wslAwareSpawnMock.mock.calls[0]?.[0]).toBe('/bundled/linux/rg') }) + it('marks WSL filenames that UNC cannot represent as incomplete', async () => { + const child = createMockProcess() + wslAwareSpawnMock.mockReturnValue(child) + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: all store access is mocked for this handler. + registerFilesystemHandlers({} as never) + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: 'C:\\repo', query: 'hello' } + ) + await flushMicrotasks() + child.stdout?.emit( + 'data', + `${JSON.stringify({ + type: 'match', + data: { + path: { text: './a\\b.txt' }, + lines: { text: 'hello\n' }, + line_number: 1, + submatches: [{ start: 0, end: 5 }] + } + })}\n` + ) + child.emit('close', 0, null) + await expect(promise).resolves.toMatchObject({ files: [], truncated: true }) + expect(toWindowsWslPathMock).not.toHaveBeenCalled() + }) + it('translates WSL rg output for Windows-path project search results', async () => { const child = createMockProcess() wslAwareSpawnMock.mockReturnValue(child) diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts index 9212200e151..94cc07a2f1f 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -1,3 +1,4 @@ +import { RipgrepSearchDiagnostics } from '../../../shared/ripgrep-search-diagnostics' import { SearchSubprocessLineAccumulator } from '../../../shared/search-subprocess-lines' import { ipcMain } from 'electron' import type { ChildProcess } from 'node:child_process' @@ -65,7 +66,7 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro return new Promise((resolvePromise, rejectPromise) => { - const rgArgs = buildRgArgs(args.query, rootPath, args) + const rgArgs = buildRgArgs(args.query, '.', args) // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. const previousChild = activeTextSearches.get(searchKey) if (previousChild) { @@ -73,7 +74,8 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte } const acc = createAccumulator() - const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) + const lines = new SearchSubprocessLineAccumulator() + const diagnostics = new RipgrepSearchDiagnostics() let resolved = false let processErrorObserved = false let unavailableExitObserved = false @@ -81,8 +83,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte let killTimeout: ReturnType const transformAbsPath = wslDistroForOutput - ? (path: string): string => - path.startsWith('/') ? toWindowsWslPath(path, wslDistroForOutput) : path + ? (path: string): string | null => + path.includes('\\') + ? null + : path.startsWith('/') + ? toWindowsWslPath(path, wslDistroForOutput) + : path : undefined const finish = (result: SearchResult | PromiseLike): void => { @@ -108,7 +114,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte } resolvePromise(result) } - const resolveOnce = (): void => finish(finalize(acc)) + const resolveOnce = (code = 0, signal: NodeJS.Signals | null = null): void => { + const error = diagnostics.failure(code, signal, acc) + finish(error ? Promise.reject(error) : finalize(acc)) + } const rejectUnavailable = (): void => finish(Promise.reject(bundledRipgrepUnavailableError())) const processLine = (line: string): void => { @@ -138,10 +147,16 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte activeTextSearches.set(searchKey, nextChild) const handleStdoutData = (chunk: string): void => { - lines.push(chunk, processLine) + if (!lines.push(chunk, processLine)) { + acc.truncated = true + if (child) { + killSpawnedRipgrepProcess(child) + } + resolveOnce() + } } - const handleStderrData = (): void => { - // Drain stderr so rg cannot block on a full pipe. + const handleStderrData = (chunk: Buffer): void => { + diagnostics.append(chunk) } const handleError = (error: NodeJS.ErrnoException): void => { processErrorObserved = true @@ -151,18 +166,12 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte return } if (child && isRipgrepUnavailableExit(child, null, null)) { - // Why the cwd check first: spawn reports a missing cwd as ENOENT too, and blaming the - // binary for it tells the user to reinstall Orca over a workspace that simply moved. - // Why detach close first: a failed spawn emits error THEN close(code < 0), and - // close settles synchronously, so this probe would otherwise race it on a sub-ms - // margin -- two measurements disagreed on which wins. Detaching makes it deterministic. + // Distinguish a missing workspace from a missing binary before close can settle. child.off('close', handleClose) - // Why catch: a failed probe must not strand the search; fall back to the prior verdict. void isRipgrepSpawnCwdUsable(rootPath) .catch(() => true) .then((usable) => { - // Why re-check: finish() drops its argument once settled, so a rejected promise - // built after the close handler already won would go unhandled. + // A late rejected promise must not escape after close settles the search. if (resolved) { return } @@ -174,7 +183,10 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte }) return } - resolveOnce() + finish(Promise.reject(error)) + if (child) { + killSpawnedRipgrepProcess(child) + } } const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { // Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise @@ -193,11 +205,11 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte rejectUnavailable() return } - const tail = lines.finish() + const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null if (tail !== null) { processLine(tail) } - resolveOnce() + resolveOnce(code ?? -1, signal) } nextChild.stdout?.setEncoding('utf-8') diff --git a/src/main/ipc/markdown-documents-remote-paths.test.ts b/src/main/ipc/markdown-documents-remote-paths.test.ts new file mode 100644 index 00000000000..0d5dc66983a --- /dev/null +++ b/src/main/ipc/markdown-documents-remote-paths.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import type * as NodePath from 'node:path' + +vi.mock('node:path', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, extname: actual.win32.extname } +}) + +import { isMarkdownDocumentName, markdownDocumentFromRelativePath } from './markdown-documents' + +describe('remote Markdown filenames on a Windows client', () => { + it('keeps the local filename helper on Windows semantics', () => { + expect(isMarkdownDocumentName('notes\\.md')).toBe(false) + expect(isMarkdownDocumentName('notes.md\\')).toBe(true) + }) + + it.each(['notes\\.md', 'a\\b.MDX', '..\\a.markdown', 'nested/README.md'])( + 'preserves POSIX filename %s and its extension', + (relativePath) => { + const basename = relativePath.slice(relativePath.lastIndexOf('/') + 1) + expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toEqual({ + filePath: `/home/repo/${relativePath}`, + relativePath, + basename, + name: basename.slice(0, basename.lastIndexOf('.')) + }) + } + ) + + it.each(['notes.md\\', 'nested/.md', '../outside.md'])( + 'rejects non-Markdown or escaping POSIX filename %s', + (relativePath) => { + expect(markdownDocumentFromRelativePath('/home/repo', relativePath)).toBeNull() + } + ) + + it('normalizes Windows remote separators before reading the basename', () => { + expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\README.MD')).toEqual({ + filePath: 'C:\\repo/notes/README.MD', + relativePath: 'notes/README.MD', + basename: 'README.MD', + name: 'README' + }) + expect(markdownDocumentFromRelativePath('C:\\repo', 'notes\\.md')).toBeNull() + expect(markdownDocumentFromRelativePath('C:\\repo', '..\\outside.md')).toBeNull() + }) +}) diff --git a/src/main/ipc/markdown-documents-ripgrep.test.ts b/src/main/ipc/markdown-documents-ripgrep.test.ts index daee482bae1..13f48b78e17 100644 --- a/src/main/ipc/markdown-documents-ripgrep.test.ts +++ b/src/main/ipc/markdown-documents-ripgrep.test.ts @@ -47,6 +47,24 @@ describe('Markdown document ripgrep lifecycle', () => { expect(child.listenerCount('close')).toBe(0) }) + it('preserves timeout when an incomplete UTF-8 scalar is abandoned', async () => { + vi.useFakeTimers() + const result = listMarkdownDocuments(root) + const outcome = expect(result).rejects.toThrow('timed out') + child.stdout.write(Buffer.from([0xf0, 0x9f])) + await vi.advanceTimersByTimeAsync(15_000) + await outcome + }) + + it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { + const result = listMarkdownDocuments(root) + child.stdout.write(Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) + if (kind === 'incomplete') { + child.emit('close', 0, null) + } + await expect(result).rejects.toThrow('not valid UTF-8') + }) + it('accepts an empty listing', async () => { const result = listMarkdownDocuments(root) child.emit('close', 1, null) diff --git a/src/main/ipc/markdown-documents.test.ts b/src/main/ipc/markdown-documents.test.ts index b23f5748cb4..698935b1f62 100644 --- a/src/main/ipc/markdown-documents.test.ts +++ b/src/main/ipc/markdown-documents.test.ts @@ -1,7 +1,41 @@ import { describe, expect, it } from 'vitest' -import { markdownDocumentFromFilePath } from './markdown-documents' +import { + markdownDocumentFromFilePath, + markdownDocumentFromRelativePath +} from './markdown-documents' describe('markdownDocumentFromFilePath', () => { + it.skipIf(process.platform === 'win32')('preserves local literal backslash names', () => { + expect(markdownDocumentFromFilePath('/repo\\', '/repo\\/a\\b.md')).toMatchObject({ + filePath: '/repo\\/a\\b.md', + relativePath: 'a\\b.md', + basename: 'a\\b.md' + }) + }) + + it('preserves POSIX remote filenames and trailing root backslashes', () => { + for (const name of ['a\\b.md', 'a/b.md', '..\\a.md']) { + expect(markdownDocumentFromRelativePath('/repo\\', name)).toMatchObject({ + filePath: `/repo\\/${name}`, + relativePath: name, + basename: name.slice(name.lastIndexOf('/') + 1) + }) + } + expect(markdownDocumentFromRelativePath('/repo\\', '../a.md')).toBeNull() + }) + + it.each(['C:\\repo\\', '\\\\server\\share\\repo\\'])( + 'preserves Windows separator handling under %s', + (root) => { + expect(markdownDocumentFromRelativePath(root, 'a\\b.md')).toMatchObject({ + filePath: `${root.slice(0, -1)}/a/b.md`, + relativePath: 'a/b.md', + basename: 'b.md' + }) + expect(markdownDocumentFromRelativePath(root, '..\\a.md')).toBeNull() + } + ) + it('keeps in-root path segments that merely start with parent traversal text', () => { expect(markdownDocumentFromFilePath('/workspace', '/workspace/..notes/file.md')).toMatchObject({ filePath: '/workspace/..notes/file.md', diff --git a/src/main/ipc/markdown-documents.ts b/src/main/ipc/markdown-documents.ts index 9bf263d56d4..1f457dba97e 100644 --- a/src/main/ipc/markdown-documents.ts +++ b/src/main/ipc/markdown-documents.ts @@ -1,4 +1,15 @@ -import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path' +import { RipgrepFilenameDecoder } from '../../shared/ripgrep-filename-decoder' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' +import { normalizeRelativePath } from '../../shared/text-search-paths' +import { + basename as pathBasename, + extname, + isAbsolute, + join, + posix, + relative, + resolve +} from 'node:path' import type { FileDocument, MarkdownDocument } from '../../shared/filesystem-entry-types' import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn' import { parseWslPath } from '../wsl' @@ -7,36 +18,34 @@ import { ripgrepMissingCwdError } from '../../shared/ripgrep-process-availability' -function normalizeRelativePath(path: string): string { - return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') +export function isMarkdownDocumentName(name: string): boolean { + return isMarkdownExtension(extname(name)) } -export function isMarkdownDocumentName(name: string): boolean { - const extension = extname(name).toLowerCase() - return extension === '.md' || extension === '.mdx' || extension === '.markdown' +function isMarkdownExtension(extension: string): boolean { + const normalized = extension.toLowerCase() + return normalized === '.md' || normalized === '.mdx' || normalized === '.markdown' } function basenameFromRelativePath(relativePath: string): string { - const normalizedPath = relativePath.replaceAll('\\', '/') - return normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1) + return relativePath.slice(relativePath.lastIndexOf('/') + 1) } function isSafeRelativePath(relativePath: string): boolean { return !relativePath.split('/').includes('..') } -function hasParentTraversalSegment(relativePath: string): boolean { - return relativePath.split(/[\\/]+/).includes('..') -} - function rootRelativePath(rootPath: string, filePath: string): string | null { const resolvedRoot = resolve(rootPath) const resolvedFile = resolve(filePath) const relativePath = relative(resolvedRoot, resolvedFile) - if (hasParentTraversalSegment(relativePath) || isAbsolute(relativePath)) { + if ( + !isSafeRelativePath(normalizeRelativePath(relativePath, rootPath)) || + isAbsolute(relativePath) + ) { return null } - return normalizeRelativePath(relativePath) + return normalizeRelativePath(relativePath, rootPath) } export function fileDocumentFromFilePath( @@ -50,7 +59,7 @@ export function fileDocumentFromFilePath( rootRelativePath(rootPath, filePath) ?? (options.outsideRootRelativePath === 'basename' ? basename - : normalizeRelativePath(relative(rootPath, filePath))) + : normalizeRelativePath(relative(rootPath, filePath), rootPath)) return { filePath, relativePath, @@ -65,18 +74,22 @@ export function markdownDocumentFromRelativePath( rootPath: string, relativePath: string ): MarkdownDocument | null { - const normalizedRelativePath = normalizeRelativePath(relativePath) + const normalizedRelativePath = normalizeRelativePath(relativePath, rootPath) // Why: SSH providers should return root-relative paths; reject escape // segments before building a synthetic absolute path for renderer use. if (!isSafeRelativePath(normalizedRelativePath)) { return null } const basename = basenameFromRelativePath(normalizedRelativePath) - if (!isMarkdownDocumentName(basename)) { + // Remote separators are already normalized; a POSIX backslash stays part of the name. + const extension = posix.extname(basename) + if (!isMarkdownExtension(extension)) { return null } - const extension = extname(basename) - const normalizedRoot = rootPath.replace(/[\\/]+$/, '') + const normalizedRoot = rootPath.replace( + isWindowsAbsolutePathLike(rootPath) ? /[\\/]+$/ : /\/+$/, + '' + ) return { filePath: `${normalizedRoot}/${normalizedRelativePath}`, relativePath: normalizedRelativePath, @@ -131,6 +144,10 @@ export async function listMarkdownDocuments( ) return new Promise((resolveListing, reject) => { + const filenameDecoder = new RipgrepFilenameDecoder( + (error) => finish(error), + Boolean(parseWslPath(rootPath)?.distro ?? options.wslDistro) + ) const documents: MarkdownDocument[] = [] let carry = '' let stderr = '' @@ -172,8 +189,12 @@ export async function listMarkdownDocuments( const onStderr = (chunk: string): void => { stderr = (stderr + chunk).slice(0, 4096) } - const onData = (chunk: string): void => { - carry += chunk + const onData = (chunk: Buffer | string): void => { + const decoded = filenameDecoder.decode(chunk) + if (decoded === null) { + return + } + carry += decoded let start = 0 let end: number while ((end = carry.indexOf('\0', start)) !== -1) { @@ -201,10 +222,11 @@ export async function listMarkdownDocuments( finish(ripgrepMissingCwdError(rootPath)) } else if (signal || (code !== 0 && code !== 1)) { finish(new Error(`Markdown document listing failed (${signal ?? code}): ${stderr.trim()}`)) - } else if (carry) { - finish(new Error('Incomplete path in Markdown document listing')) } else { - finish() + if (!filenameDecoder.finish()) { + return + } + finish(carry ? new Error('Incomplete path in Markdown document listing') : undefined) } } const timer = setTimeout( @@ -212,7 +234,6 @@ export async function listMarkdownDocuments( MARKDOWN_LISTING_TIMEOUT_MS ) timer.unref?.() - child.stdout?.setEncoding('utf8') child.stderr?.setEncoding('utf8') child.stdout?.on('data', onData) child.stderr?.on('data', onStderr) diff --git a/src/main/ipc/pty-controller-process-inventory.test.ts b/src/main/ipc/pty-controller-process-inventory.test.ts index 004735c61cb..50b67963ab9 100644 --- a/src/main/ipc/pty-controller-process-inventory.test.ts +++ b/src/main/ipc/pty-controller-process-inventory.test.ts @@ -1,3 +1,4 @@ +import { openCodeHookServiceModuleMock } from './pty-ipc-mock-registry' import { afterEach, describe, expect, it, vi } from 'vitest' const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({ @@ -46,18 +47,7 @@ vi.mock('node-pty', () => ({ }) })) -vi.mock('../opencode/hook-service', () => ({ - openCodeHookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - }, - openCode2HookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - } -})) +vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock()) vi.mock('../pi/titlebar-extension-service', () => ({ piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index f49978843d5..4eba8d0b164 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -106,6 +106,9 @@ export const childProcessModuleMock = (original: Record) => ({ }) export const openCodeHookServiceModuleMock = () => ({ + OpenCodeHookService: class { + buildPtyEnv = vi.fn(() => ({})) + }, openCodeHookService: { buildPtyEnv: openCodeBuildPtyEnvMock, refreshLegacySharedPlugin: vi.fn<() => void>(), diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 224238bff9a..d530a9cd194 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -11,6 +11,7 @@ import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty' import { buildJcodeRuntimeDir, shouldInjectJcodeRuntimeDir } from '../../shared/jcode-runtime-dir' import { makePaneKey } from '../../shared/stable-pane-id' +import { selectShellStartupFeatures } from '../shell-startup-features' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -60,6 +61,41 @@ describe('registerPtyHandlers', () => { const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite() describe('spawn environment', () => { + it.each(['/bin/bash', '/bin/zsh'])( + 'does not wrap a bare %s pane merely to expose this app CLI', + (shellPath) => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + try { + const env = buildPtyHostEnv( + 'bare-cli-pane', + {}, + { + isPackaged: false, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: null, + agentStatusHooksEnabled: false + } + ) + expect(env.ORCA_CLI_BIN_DIR).toBe('/tmp/orca-user-data/cli/bin') + expect( + selectShellStartupFeatures({ + shellPath, + env, + hasStartupCommand: false, + waitsForShellReady: false, + emitsStartupIdentity: false + }) + ).toEqual([]) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + } + ) + it('does not install managed Pi extensions when Pi is disabled', () => { piBuildPtyEnvMock.mockClear() diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 1cae20bfcbd..8b921b4ebde 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -1,15 +1,10 @@ import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' -import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command' import { detectExplicitPiAgentKindFromCommand, isPiCompatibleAgentType } from '../../../../shared/pi-agent-kind' import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' -import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' -import { - OPENCODE_CONFIG_DIR_ENV_KEYS, - isOpenCodeLegacySharedConfigDir -} from '../../../opencode/legacy-shared-config-dir' +import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer' import { mimoCodeHookService } from '../../../mimo/hook-service' import { agentHookServer } from '../../../agent-hooks/server' import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager' @@ -28,13 +23,13 @@ import { exposePiManagedExtensionEnv, isMimoLaunchCommand, resolveMimocodeSourceHome, - resolveOpenCodeSourceConfigDir, resolvePiAgentSourceDir, resolveScopedPiAgentSourceDir, restoreOrStripOverlayEnv } from './pi-agent' import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys' import { applyManagedDataAccountEnvironment } from '../../../managed-data-accounts/launch-environment' +import { applyOpenCodeStatusPluginEnv, captureOpenCodeSourceConfig } from './opencode-config' /** * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. @@ -50,32 +45,9 @@ export function buildPtyHostEnv( mergePersistedWindowsPath(baseEnv) Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings)) - // Why: pre-1.4.209 panes exported Orca's retired shared hooks dir; inheriting it hides the user's global OpenCode config. - const isLegacyOpenCodeHooksDir = (dir: string | undefined): boolean => - isOpenCodeLegacySharedConfigDir(dir, opts.userDataPath) - const inheritedOpenCodeEnv: NodeJS.ProcessEnv = {} - for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { - if (isLegacyOpenCodeHooksDir(baseEnv[key])) { - delete baseEnv[key] - } - if (!isLegacyOpenCodeHooksDir(process.env[key])) { - inheritedOpenCodeEnv[key] = process.env[key] - } - } - // A daemon or sibling shell can retain a retired path that main no longer sees. - openCodeHookService.refreshLegacySharedPlugin() - openCode2HookService.refreshLegacySharedPlugin() - const resolvedOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv, inheritedOpenCodeEnv) - const preexistingOpenCodeConfigDir = isLegacyOpenCodeHooksDir(resolvedOpenCodeConfigDir) - ? undefined - : resolvedOpenCodeConfigDir + const openCodeConfig = captureOpenCodeSourceConfig(baseEnv, opts.userDataPath) const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) applyManagedDataAccountEnvironment(baseEnv, { ...opts, launchCommand: launchCommandHint }) - const openCodeAgent = selectOpenCodeHookAgent( - opts.launchAgent, - launchCommandHint, - (agent) => opts.agentStatusHooksEnabled && isTuiAgentEnabled(agent, opts.disabledTuiAgents) - ) const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent) ? opts.launchAgent : opts.launchAgent === undefined @@ -110,37 +82,13 @@ export function buildPtyHostEnv( ? resolvePiAgentSourceDir(baseEnv, 'prime-agent') : resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent') - restoreOrStripOverlayEnv( + const openCodeAgent = applyOpenCodeStatusPluginEnv( + id, baseEnv, - { - primary: 'OPENCODE_CONFIG_DIR', - overlay: 'ORCA_OPENCODE_CONFIG_DIR', - source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', - preserveExplicitPrimary: true - }, - inheritedOpenCodeEnv + openCodeConfig, + opts, + launchCommandHint ) - delete baseEnv.ORCA_OPENCODE_AGENT - if (openCodeAgent) { - // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. - const openCodeStatusService = - openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService - baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent - // WSL owns its config writes; only the guest overlay may enter a WSL pane. - if (!opts.isWsl) { - Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) - } - if (baseEnv.OPENCODE_CONFIG_DIR) { - // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value. - baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR - if (preexistingOpenCodeConfigDir) { - // Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays. - baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir - } else { - delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR - } - } - } if (opts.agentStatusHooksEnabled) { if (isMimoLaunchCommand(launchCommandHint)) { const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) @@ -343,5 +291,8 @@ export function buildPtyHostEnv( // process.env when baseEnv carries none, which is the daemon path's normal shape. stripLegacyTerminalShimEnv(baseEnv, process.platform) + if (!opts.isWsl) { + ensureOpenCodeStartupPromptForLaunch(baseEnv) + } return baseEnv } diff --git a/src/main/ipc/pty/host-env/opencode-config.ts b/src/main/ipc/pty/host-env/opencode-config.ts new file mode 100644 index 00000000000..88dafabc88e --- /dev/null +++ b/src/main/ipc/pty/host-env/opencode-config.ts @@ -0,0 +1,84 @@ +import { + OPENCODE_CONFIG_DIR_ENV_KEYS, + isOpenCodeLegacySharedConfigDir +} from '../../../opencode/legacy-shared-config-dir' +import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' +import { resolveOpenCodeSourceConfigDir, restoreOrStripOverlayEnv } from './pi-agent' +import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' +import type { BuildPtyHostEnvOptions } from './types' + +type OpenCodeSourceConfig = { + inheritedEnv: NodeJS.ProcessEnv + directory: string | undefined +} + +export function captureOpenCodeSourceConfig( + env: Record, + userDataPath: string +): OpenCodeSourceConfig { + const isLegacyDirectory = (dir: string | undefined): boolean => + isOpenCodeLegacySharedConfigDir(dir, userDataPath) + const inheritedEnv: NodeJS.ProcessEnv = {} + for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { + if (isLegacyDirectory(env[key])) { + delete env[key] + } + if (!isLegacyDirectory(process.env[key])) { + inheritedEnv[key] = process.env[key] + } + } + // A daemon or sibling shell can retain a retired path that main no longer sees. + openCodeHookService.refreshLegacySharedPlugin() + openCode2HookService.refreshLegacySharedPlugin() + const directory = resolveOpenCodeSourceConfigDir(env, inheritedEnv) + return { inheritedEnv, directory: isLegacyDirectory(directory) ? undefined : directory } +} + +export function applyOpenCodeStatusPluginEnv( + id: string, + env: Record, + config: OpenCodeSourceConfig, + options: Pick< + BuildPtyHostEnvOptions, + 'launchAgent' | 'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'isWsl' + >, + command: string | undefined +): 'opencode' | 'opencode2' | null { + const agent = selectOpenCodeHookAgent( + options.launchAgent, + command, + (candidate) => + options.agentStatusHooksEnabled && isTuiAgentEnabled(candidate, options.disabledTuiAgents) + ) + restoreOrStripOverlayEnv( + env, + { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + preserveExplicitPrimary: true + }, + config.inheritedEnv + ) + delete env.ORCA_OPENCODE_AGENT + if (!agent) { + return null + } + const service = agent === 'opencode2' ? openCode2HookService : openCodeHookService + 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)) + } + if (env.OPENCODE_CONFIG_DIR) { + // Shell startup can re-export the default; preserve this pane's overlay and original source. + env.ORCA_OPENCODE_CONFIG_DIR = env.OPENCODE_CONFIG_DIR + if (config.directory) { + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = config.directory + } else { + delete env.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } + } + return agent +} diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts index a52a9d70360..ddae2771aba 100644 --- a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts +++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts @@ -77,6 +77,28 @@ afterEach(() => { }) describe('OpenCode installation uses the current enabled agents', () => { + it('refuses spawn if a prepared startup intent loses its owned installer', () => { + mkdirSync(fixture.userData, { recursive: true }) + writeFileSync( + join(fixture.userData, 'opencode-startup-prompt-overlays'), + 'blocked fixture root' + ) + expect(() => + buildPtyHostEnv( + 'owned-launch', + { + OPENCODE_CONFIG_DIR: custom, + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'fixture-nonce', + ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'original caller brief' + }, + { ...options, agentStatusHooksEnabled: false } + ) + ).toThrow('launch was canceled') + expect(readFileSync(join(custom, 'opencode.json'), 'utf8')).toBe('{"model":"fixture"}') + expect(readFileSync(join(custom, 'plugins', 'user.js'), 'utf8')).toBe('// user plugin') + }) + const combinations = [ { disabled: [], fallback: 'opencode' }, { disabled: ['opencode'], fallback: 'opencode2' }, diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 77491c9fb11..24df59c1201 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -22,8 +22,13 @@ import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/sp import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size' import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' +import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedPtyIpcSpawn(ctx)) +} + +async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) if (ctx.nativeWindowsConptySpawn) { @@ -100,6 +105,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise commitReservedRuntimePtySpawn(ctx)) +} + +async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result) @@ -205,6 +210,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (ctx.result.incarnationId) { ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) } + claimSshPaneLease({ store: ctx.deps.store, connectionId: args.connectionId, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 2dfdf2d0811..26c3856b45e 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -104,7 +104,7 @@ export async function buildRuntimePtySpawnOptions( env: ctx.env, envToDelete: ctx.spawnOptions.envToDelete }) - ctx.env = await prepareOpenCodePtyLaunch({ + const openCodeLaunch = await prepareOpenCodePtyLaunch({ command: ctx.launchCommand, agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, env: ctx.env, @@ -116,6 +116,8 @@ export async function buildRuntimePtySpawnOptions( ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } : {}) }) + ctx.env = openCodeLaunch.env + ctx.launchCommand = openCodeLaunch.command ctx.spawnOptions.env = ctx.env promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) const noDaemonLaunch = planCodexNoDaemonLaunch({ diff --git a/src/main/jcode/hook-config.test.ts b/src/main/jcode/hook-config.test.ts index b82e6a417b9..17dbcb379c2 100644 --- a/src/main/jcode/hook-config.test.ts +++ b/src/main/jcode/hook-config.test.ts @@ -132,9 +132,6 @@ turn_end = "~/bin/mine" # replaces agent-hooks/jcode-hook.sh }) it('repoints a managed entry left behind by a copied home or a platform switch', () => { - // Why: isManaged matches any agent-hooks/jcode-hook path, but getStatus demands - // the exact script path — a stale entry stuck the install on `partial` forever - // with no Orca action able to repair it. const stale = '/Users/old/.orca/agent-hooks/jcode-hook.sh' const source = `[hooks]\nturn_end = ${tomlQuoteString(stale)}\n` const result = applyJcodeManagedHooks(source, EVENTS, MANAGED_COMMAND, 'jcode-hook.sh') diff --git a/src/main/jcode/hook-service.test.ts b/src/main/jcode/hook-service.test.ts index 3a87aa4ef6a..f86fe52444c 100644 --- a/src/main/jcode/hook-service.test.ts +++ b/src/main/jcode/hook-service.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -17,6 +17,7 @@ import { JcodeHookService } from './hook-service' import { getJcodeConfigPath, getJcodeManagedCommand, + getJcodeManagedScriptFileName, getJcodeManagedScriptPath, JCODE_HOOK_EVENTS } from './hook-settings' @@ -72,6 +73,123 @@ describe('JcodeHookService', () => { expect(script).toContain('payload="$JCODE_HOOK_PAYLOAD"') }) + it('reports a missing managed script and repairs it without changing config', () => { + const service = new JcodeHookService() + service.install() + const config = readFileSync(getJcodeConfigPath(), 'utf8') + rmSync(getJcodeManagedScriptPath()) + + expect(service.getStatus()).toMatchObject({ + state: 'partial', + managedHooksPresent: true, + detail: 'Managed hook script missing' + }) + expect(readFileSync(getJcodeConfigPath(), 'utf8')).toBe(config) + expect(service.install().state).toBe('installed') + expect(readFileSync(getJcodeManagedScriptPath(), 'utf8')).toContain('/hook/jcode') + expect(readFileSync(getJcodeConfigPath(), 'utf8')).toBe(config) + }) + + it('reports missing scripts alongside incomplete event coverage and user hooks', () => { + const service = new JcodeHookService() + service.install() + writeFileSync( + getJcodeConfigPath(), + `[hooks]\nsession_start = ${tomlQuoteString(getJcodeManagedCommand(getJcodeManagedScriptPath()))}\nturn_end = "~/bin/my-turn-notify"\n`, + 'utf8' + ) + rmSync(getJcodeManagedScriptPath()) + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.detail).toContain('Managed hook script missing') + expect(status.detail).toContain( + 'Managed hook missing for events: turn_start, pre_tool, post_tool, session_end' + ) + expect(status.detail).toContain('User-owned hooks kept for events: turn_end') + expect(service.install().state).toBe('partial') + expect(service.getStatus().detail).not.toContain('script missing') + expect(readFileSync(getJcodeConfigPath(), 'utf8')).toContain( + 'turn_end = "~/bin/my-turn-notify"' + ) + }) + + it.each([ + '/Users/previous/.orca/agent-hooks/jcode-hook.sh', + 'C:\\Users\\previous\\.orca\\agent-hooks\\jcode-hook.cmd' + ])('recognizes a stale managed command %s without a local script', (stalePath) => { + const service = new JcodeHookService() + const configPath = getJcodeConfigPath() + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `[hooks]\nturn_end = ${tomlQuoteString(getJcodeManagedCommand(stalePath))}\n`, + 'utf8' + ) + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.managedHooksPresent).toBe(true) + expect(status.detail).toContain('Managed hook command outdated for events: turn_end') + expect(status.detail).toContain('Managed hook script missing') + expect(status.detail).not.toContain('User-owned') + expect(service.install().state).toBe('installed') + expect(readFileSync(configPath, 'utf8')).not.toContain('previous') + }) + + it('does not report complete stale event coverage as installed when the current script exists', () => { + const service = new JcodeHookService() + service.install() + const staleCommand = getJcodeManagedCommand('/Users/previous/.orca/agent-hooks/jcode-hook.sh') + writeFileSync( + getJcodeConfigPath(), + `[hooks]\n${JCODE_HOOK_EVENTS.map((event) => `${event} = ${tomlQuoteString(staleCommand)}`).join('\n')}\n`, + 'utf8' + ) + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.managedHooksPresent).toBe(true) + expect(status.detail).toBe( + `Managed hook command outdated for events: ${JCODE_HOOK_EVENTS.join(', ')}` + ) + expect(service.install().state).toBe('installed') + }) + + it('reports legacy unquoted commands as outdated even when the script exists', () => { + const service = new JcodeHookService() + service.install() + writeFileSync( + getJcodeConfigPath(), + `[hooks]\n${JCODE_HOOK_EVENTS.map((event) => `${event} = ${tomlQuoteString(getJcodeManagedScriptPath())}`).join('\n')}\n`, + 'utf8' + ) + + const status = service.getStatus() + expect(status.state).toBe('partial') + expect(status.detail).toContain('Managed hook command outdated for events:') + expect(status.detail).not.toContain('script missing') + expect(status.detail).not.toContain('User-owned') + expect(service.install().state).toBe('installed') + }) + + it('does not mistake a user command mentioning the managed script in a comment for Orca ownership', () => { + const configPath = getJcodeConfigPath() + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `[hooks]\nturn_end = "~/bin/my-turn-notify" # replaces agent-hooks/${getJcodeManagedScriptFileName()}\n`, + 'utf8' + ) + + const status = new JcodeHookService().getStatus() + expect(status.state).toBe('partial') + expect(status.managedHooksPresent).toBe(false) + expect(status.detail).toContain('User-owned hooks kept for events: turn_end') + expect(status.detail).not.toContain('command outdated') + expect(status.detail).not.toContain('script missing') + }) + it('preserves unrelated config tables when installing hooks', () => { const configPath = getJcodeConfigPath() mkdirSync(dirname(configPath), { recursive: true }) @@ -107,6 +225,8 @@ describe('JcodeHookService', () => { expect(before).toContain('turn_end') const status = new JcodeHookService().remove() expect(status.state).toBe('not_installed') + expect(status.detail).toBeNull() + expect(existsSync(getJcodeManagedScriptPath())).toBe(true) const after = readFileSync(getJcodeConfigPath(), 'utf8') expect(after).not.toContain(getJcodeManagedScriptPath()) }) diff --git a/src/main/jcode/hook-service.ts b/src/main/jcode/hook-service.ts index ebe41a2dc36..eff119f91d5 100644 --- a/src/main/jcode/hook-service.ts +++ b/src/main/jcode/hook-service.ts @@ -4,6 +4,7 @@ import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { buildWindowsAgentHookPostCommand, + createManagedCommandMatcher, writeManagedScript } from '../agent-hooks/installer-utils' import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' @@ -128,33 +129,42 @@ export class JcodeHookService { } const scriptPresent = existsSync(scriptPath) const managedCommand = getJcodeManagedCommand(scriptPath) + const isManaged = createManagedCommandMatcher(getJcodeManagedScriptFileName()) const missing: string[] = [] + const outdated: string[] = [] const userOwned: string[] = [] let managedCount = 0 for (const event of JCODE_HOOK_EVENTS) { const value = table[event] - // Why both forms: installs before the quoting fix stored the bare path, and - // install() repoints those — reporting them user-owned would hide the repair. - if (value === managedCommand || value === scriptPath) { + if (value === managedCommand) { managedCount += 1 + } else if (isManaged(value)) { + outdated.push(event) } else if (value === undefined) { missing.push(event) } else { userOwned.push(event) } } - const managedHooksPresent = managedCount > 0 || scriptPresent + const hasManagedEntries = managedCount > 0 || outdated.length > 0 + const managedHooksPresent = hasManagedEntries || scriptPresent let state: AgentHookInstallState let detail: string | null - if (missing.length === 0 && userOwned.length === 0) { + if (managedCount === JCODE_HOOK_EVENTS.length && scriptPresent) { state = 'installed' detail = null - } else if (managedCount === 0 && missing.length === JCODE_HOOK_EVENTS.length) { + } else if (!hasManagedEntries && missing.length === JCODE_HOOK_EVENTS.length) { state = 'not_installed' detail = null } else { state = 'partial' const parts: string[] = [] + if (hasManagedEntries && !scriptPresent) { + parts.push('Managed hook script missing') + } + if (outdated.length > 0) { + parts.push(`Managed hook command outdated for events: ${outdated.join(', ')}`) + } if (missing.length > 0) { parts.push(`Managed hook missing for events: ${missing.join(', ')}`) } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts index d51a98b3d8b..02025485151 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.test.ts @@ -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((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } + } + + function coldService(probe: (home: string) => Promise) { + 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 => { + 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(() => {})) + 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() diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts index 8c794331a2c..638b0f54272 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-service.ts @@ -35,6 +35,8 @@ export type AgentModelCatalogService = { sessionId?: string /** Where a new chat would run; null when one was named but is not a local directory. */ workspacePath?: string | null + /** With no entry yet, answer from the listing this read starts or joins instead of `unknown`. */ + waitForListing?: boolean }) => Promise } @@ -79,8 +81,9 @@ async function workspaceKeepsListedDefault( * launch); without one, the key is the account a launch would pin right now — * never "whichever account listed last". `unknown` tells the client to keep * its static seed, and a missing or aged entry kicks one joined background - * probe so the next read is warm. Failures are the store's 30s TTL, never an - * answer — a picker is a user surface and must not block. + * probe so the next read is warm. With no entry, the answer says that listing + * is running, and only a read that asks waits for it. Failures are the store's + * 30s TTL, never an answer: inside it a read answers `unknown` at once. */ export function createAgentModelCatalogService( deps: AgentModelCatalogServiceDeps @@ -110,14 +113,27 @@ export function createAgentModelCatalogService( }) accountHomePath = resolved.path } - const entry = deps.store.get(fingerprint) + let entry = deps.store.get(fingerprint) const probe = deps.probes?.[params.agent] - if (probe && accountHomePath && deps.store.shouldRefresh(fingerprint)) { - const home = accountHomePath - void deps.store.refresh(fingerprint, params.agent, () => probe(home)) - } + const home = accountHomePath + // Without an entry, join a running listing too: that is the one a waiting read answers from. + const listing = + probe && + home && + (entry ? deps.store.shouldRefresh(fingerprint) : !deps.store.hasActiveFailure(fingerprint)) + ? deps.store.refresh(fingerprint, params.agent, () => probe(home)) + : null if (!entry) { - return { origin: 'unknown' } + if (!listing) { + return { origin: 'unknown' } + } + if (!params.waitForListing) { + return { origin: 'unknown', listingInProgress: true } + } + entry = await listing + if (!entry) { + return { origin: 'unknown' } + } } return resultFromEntry( entry, diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 89722959799..c7c2a671654 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -8,7 +8,11 @@ import { import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { journalDispatchRowApplies } from './journal-dispatch-settlement' import type { JournalReducerState } from './journal-reducer' -import { notePersonTurnAccepted, placeHandedOverMessage } from './journal-submission-fold' +import { + notePersonTurnAccepted, + placeHandedOverMessage, + placeRejectedMessage +} from './journal-submission-fold' import type { JournalRow } from './journal-row-schema' export function applyJournalDispatchRow( @@ -34,6 +38,8 @@ export function applyJournalDispatchRow( if (row.state === 'pending') { submission.handedOverAt = row.ts placeHandedOverMessage(state, submission, row) + } else if (row.state === 'rejected') { + placeRejectedMessage(state, submission, row) } if (row.recovered) { submission.recovered = row.recovered diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index c05d22a8745..68ba188e402 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -3,6 +3,7 @@ import type { JournalReducerState } from './journal-reducer' import { journalLifecycleBatchRowBuilder } from './journal-row-builders' import type { JournalLifecycleBatchInput } from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' +import { journalQueuedRejectionRowBuilders } from './journal-pending-submission-recovery' const SETTLEMENT_ALREADY_APPLIED = new Error('journal_settlement_already_applied') @@ -12,10 +13,38 @@ export class JournalLifecycleBatchAppender { state: () => JournalReducerState cursor: () => AgentJournalCursor enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise + enqueueRows: ( + plan: () => readonly ((seq: number, ts: number) => JournalRow)[] + ) => Promise } ) {} append(input: JournalLifecycleBatchInput): Promise { + const { rejectsQueued } = input + if (rejectsQueued) { + // Planned on the lane: the sends queued then, and this batch unless it already landed. With + // none left (a Stop withdrew them first) it failed no one, so nothing is written. + return this.deps + .enqueueRows(() => { + const rejections = journalQueuedRejectionRowBuilders( + this.deps.state, + input.fence, + rejectsQueued + ) + return rejections.length === 0 || this.wasApplied(input.settlementId) + ? rejections + : [ + ...rejections, + journalLifecycleBatchRowBuilder( + this.deps.state, + input.settlementId, + input.mutations, + input + ) + ] + }) + .then(() => this.deps.cursor()) + } if (this.wasApplied(input.settlementId)) { return Promise.resolve(this.deps.cursor()) } diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index 964ea42f931..b52e3648b72 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -2,6 +2,9 @@ import type { AgentJournalDispatchRejection } from '../../../shared/agent-sessio import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' +import type { JournalReducerState } from './journal-reducer' +import { journalDispatchRowBuilder } from './journal-row-builders' +import type { JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' /** Settles every submission a process fact left unanswerable. Doubt is never @@ -88,3 +91,21 @@ export async function rejectJournalQueuedSubmissions( ) return queued.map((entry) => entry.clientMessageId) } + +/** Rows rejecting every submission still queued, read from `state` when called: for an append + * that must carry them with what follows, in one transaction. */ +export function journalQueuedRejectionRowBuilders( + state: () => JournalReducerState, + fence: number, + rejection: AgentJournalDispatchRejection +): ((seq: number, ts: number) => JournalRow)[] { + return [...state().submissions.values()].filter(isQueuedAgentJournalSubmission).map((entry) => + journalDispatchRowBuilder(state, { + clientMessageId: entry.clientMessageId, + state: 'rejected', + ...rejection, + fence, + recovered: true + }) + ) +} diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index a66d43697f6..0828740ca6f 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -13,7 +13,7 @@ import { import type { JournalHostDatabase } from './journal-host-database' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' -import type { JournalRowTransactionHook } from './journal-row-writer' +import type { JournalOperationReceipt, JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' import { @@ -101,14 +101,18 @@ export class JournalQueuedMessages { return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp) } - /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused. */ - insert(input: { - messageId: string - body: AgentJournalMessageItem - fingerprint: string - hostInstance: string - carriedFrom?: string - }): Promise { + /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused. + * `receipt`: the send's ledger answer, committed with the draft only when this inserts it. */ + insert( + input: { + messageId: string + body: AgentJournalMessageItem + fingerprint: string + hostInstance: string + carriedFrom?: string + }, + receipt?: JournalOperationReceipt + ): Promise { const { sessionId } = this.deps let inserted = false return this.transact( @@ -121,14 +125,17 @@ export class JournalQueuedMessages { } inserted = true const { epoch, lastSequence } = this.deps.state() - return insertQueuedMessage(db, { + const row = insertQueuedMessage(db, { ...input, sessionId, queuedAt: { epoch, sequence: lastSequence }, now: this.deps.now() }) + receipt?.write(db) + return row }, - () => inserted + () => inserted, + receipt?.committed ) } @@ -206,15 +213,17 @@ export class JournalQueuedMessages { } /** One standalone draft-table transaction on the journal's queue; one that - * changed rows bumps the revision and notifies after COMMIT. */ + * changed rows bumps the revision and notifies after COMMIT, `adopted` first. */ private transact( run: (db: Database.Database) => JournalWriteResult, - changed: (result: T) => boolean + changed: (result: T) => boolean, + adopted?: () => void ): Promise { return this.deps.serialize(() => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) const result = this.deps.database().transaction(run) if (changed(result)) { + adopted?.() this.changeRevision++ this.deps.committed() } diff --git a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts new file mode 100644 index 00000000000..d218a356fae --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts @@ -0,0 +1,129 @@ +// A failed start's row and the queued messages it failed land in ONE append, the messages first: +// no reader meets one without the other, and the messages sit above the row that says why. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from './journal-store' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener +} from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-start', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} + +const START_FAILED = agentSessionFailureWords(agentSessionFailureFact('providerStartFailed'), { + surface: 'rejection' +}) +const ERROR_ROW = { provider: 'orca', clientMessageId: 'start-failure:gen-1' } as const + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-rejection-batch-')) +}) + +afterEach(async () => { + await closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) +}) + +async function openWithQueued(...ids: string[]): Promise { + const journal = await journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => (clock += 1), + mintEpoch: () => 'epoch-1' + }) + for (const id of ids) { + await journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: `fp-${id}`, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, + fence: 0, + handoverRecorded: true + }) + } + return journal +} + +function startFailureBatch(mutations = 1) { + return { + settlementId: 'start-failure:gen-1', + fence: 0, + recovered: true as const, + mutations: Array.from({ length: mutations }, () => ({ + kind: 'item' as const, + identity: ERROR_ROW, + body: { kind: 'status' as const, tone: 'error' as const, text: 'Claude did not start.' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })), + rejectsQueued: START_FAILED + } +} + +it('writes the rejections first and the row after them, and draws the messages above it', async () => { + const journal = await openWithQueued('first', 'second') + const before = journal.cursor().sequence + + await journal.appendLifecycleBatch(startFailureBatch()) + + expect(journal.cursor().sequence).toBe(before + 3) + expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual([ + 'rejected', + 'rejected' + ]) + const order = journal.snapshot().items.map((item) => item.itemId) + expect(order).toEqual([ + agentJournalSubmissionKey('first'), + agentJournalSubmissionKey('second'), + 'orca:start-failure%3Agen-1' + ]) +}) + +it('writes neither when the row cannot be written', async () => { + const journal = await openWithQueued('first') + const before = journal.cursor().sequence + + // An empty batch breaks the row's bound, so the transaction rolls back as a whole. + await expect(journal.appendLifecycleBatch(startFailureBatch(0))).rejects.toThrow( + 'journal_lifecycle_batch_mutation_bound_exceeded' + ) + + expect(journal.cursor().sequence).toBe(before) + expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual(['pending']) +}) + +// A Stop that reaches the lane first takes the message back; the failed start then failed no one. +it('writes nothing when a Stop withdrew every queued message first', async () => { + const journal = await openWithQueued('first') + const withdrawal = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + + await Promise.all([ + journal.rejectQueuedSubmissions(0, withdrawal), + journal.appendLifecycleBatch(startFailureBatch()) + ]) + + expect(journal.submissions()[0]?.rejection).toEqual({ kind: 'cancelled' }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalSubmissionKey('first') + ]) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts index 45681f32871..60d068ebfd8 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -551,7 +551,7 @@ describe('submission and dispatch state machine', () => { expect(state.receipts.get('cm_1')).toBeTruthy() }) - it('keeps a refused write rejected and leaves its bubble where it was', () => { + it('keeps a refused write rejected, at its rejection, whatever comes after', () => { const state = fold([ submission, { @@ -579,7 +579,8 @@ describe('submission and dispatch state machine', () => { submittedAt: submission.ts, reason: 'provider_write_failed: closed before enqueue' }) - expect(renderJournalState(state).items[0]?.sequence).toBe(submission.seq) + // It sits where it was rejected; the late `pending` moves nothing. + expect(renderJournalState(state).items[0]?.sequence).toBe(2) }) it('ignores a dispatch for a submission this epoch never saw', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index a7d6f150e70..e2d1b1ef3ca 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -6,9 +6,9 @@ // dropped rather than resurrecting stale content, and ordering is by the // position (sequence, then place in the row) of the write that CREATED an item // (a later revision updates the body, it does not move the bubble) — except a -// queued message, which sits where its handover put it. Producer linkage is -// likewise the creating write's: a revision naming no producer keeps it, one -// naming any replaces it. +// queued message, which sits where its handover put it, and a rejected one, which +// sits where it was rejected. Producer linkage is likewise the creating write's: a +// revision naming no producer keeps it, one naming any replaces it. import type { AgentJournalAcceptanceReceipt, diff --git a/src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts b/src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts new file mode 100644 index 00000000000..ce5b407d3e8 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts @@ -0,0 +1,158 @@ +// A rejected message sits where it was rejected, in no turn: queued, handed over or sent directly. +// One in doubt stays where it was: it may have reached the agent. + +import { describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' +import { projectJournalBatch } from '../agent-session-wire/agent-session-journal-batch' +import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' +import { applyJournalRow, createJournalReducerState, renderJournalState } from './journal-reducer' +import { buildJournalSubmissionRow, journalRowBase } from './journal-row-builders' +import type { JournalRow } from './journal-row-schema' + +function journal() { + const state = createJournalReducerState('session-1', 'epoch-1') + let seq = 0 + const push = (next: JournalRow): JournalRow => { + applyJournalRow(state, next) + return next + } + return { + state, + submission(clientMessageId: string, handoverRecorded = true) { + seq += 1 + return push( + buildJournalSubmissionRow({ + state, + clientMessageId, + payloadFingerprint: `fp-${clientMessageId}`, + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: clientMessageId }] + }, + seq, + fence: 1, + ts: 1_000 + seq, + ...(handoverRecorded ? { handoverRecorded: true } : {}) + }) + ) + }, + dispatch( + clientMessageId: string, + state_: 'pending' | 'rejected' | 'unknown', + turnScope: AgentJournalTurnScope = AGENT_JOURNAL_THREAD_SCOPE + ) { + seq += 1 + return push({ + kind: 'dispatch', + clientMessageId, + state: state_, + providerItemId: null, + reason: + state_ === 'rejected' + ? DISPATCH_REJECTED_HOST_RESTARTED + : state_ === 'unknown' + ? DISPATCH_DOUBT_HOST_RESTARTED + : null, + ...(state_ === 'rejected' ? { rejection: { kind: 'hostRestarted' } } : {}), + ...journalRowBase(state.epoch, seq, 1, 1_000 + seq), + turnScope + }) + }, + /** Other work between the send and its settlement, as a turn running ahead of it would write. */ + advance(rows: number) { + seq += rows + state.lastSequence = seq + } + } +} + +function placed(state: ReturnType['state'], clientMessageId: string) { + const item = state.items.get(agentJournalSubmissionKey(clientMessageId)) + return item && { sequence: item.sequence, observedAt: item.observedAt, scope: item.turnScope } +} + +const IN_TURN: AgentJournalTurnScope = { kind: 'turn', turnItemId: 'orca:turn-1' } + +describe('a rejected message', () => { + it('sits at the rejection, in no turn, when it was queued', () => { + const { state, submission, dispatch, advance } = journal() + submission('waiting') + advance(300) + dispatch('waiting', 'rejected') + + expect(placed(state, 'waiting')).toEqual({ + sequence: 302, + observedAt: 1_302, + scope: AGENT_JOURNAL_THREAD_SCOPE + }) + }) + + it('reaches a subscriber at the tail, so the newest page holds it', () => { + const { state, submission, dispatch, advance } = journal() + submission('waiting') + advance(300) + const rejection = dispatch('waiting', 'rejected') + + const projected = projectJournalBatch({ + rows: [rejection], + snapshot: renderJournalState(state), + afterSequence: 301 + }) + expect(projected.ok && projected.batch.items.map((item) => item.sequence)).toEqual([302]) + expect(renderJournalState(state).items.at(-1)?.itemId).toBe( + agentJournalSubmissionKey('waiting') + ) + }) + + it('sits at the rejection, out of the turn it was handed into, when it was a steer', () => { + const { state, submission, dispatch, advance } = journal() + submission('steer') + advance(10) + dispatch('steer', 'pending', IN_TURN) + expect(placed(state, 'steer')?.scope).toEqual(IN_TURN) + advance(10) + dispatch('steer', 'rejected') + + expect(placed(state, 'steer')).toEqual({ + sequence: 23, + observedAt: 1_023, + scope: AGENT_JOURNAL_THREAD_SCOPE + }) + }) + + it('sits at the rejection when it was sent directly', () => { + const { state, submission, dispatch, advance } = journal() + submission('direct', false) + advance(10) + dispatch('direct', 'rejected') + + expect(placed(state, 'direct')?.sequence).toBe(12) + }) +}) + +describe('a message in doubt', () => { + it('stays where it was handed over, a plain bubble in its turn: it may have reached the agent', () => { + const { state, submission, dispatch, advance } = journal() + submission('steer') + advance(10) + dispatch('steer', 'pending', IN_TURN) + advance(10) + dispatch('steer', 'unknown') + + expect(placed(state, 'steer')).toEqual({ sequence: 12, observedAt: 1_012, scope: IN_TURN }) + const { items, submissions } = renderJournalState(state) + const drawn = projectStructuredAgentSessionMessages(items, [], submissions, { + rejectedInPlace: true + }).find((message) => message.id === agentJournalSubmissionKey('steer')) + expect(drawn).toBeDefined() + expect(drawn?.unsent).toBeUndefined() + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts index c0824a2e4ca..3671ce46cfc 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts @@ -93,4 +93,17 @@ describe('journal row writer', () => { kind: 'item' }) }) + + it('writes several rows as one: a failure on the last leaves none', async () => { + const { writer, committedRows } = writerHarness() + // Sequence 2 is taken, so the second of the two rows violates the primary key. + insertTestJournalRow(database.db, SESSION_ID, row(2, 1)) + + await expect(writer.enqueueRows(() => [row, row])).rejects.toThrow() + + expect(committedRows).toHaveLength(0) + expect(readTestJournalRows(database.db, SESSION_ID, EPOCH).map((stored) => stored.seq)).toEqual( + [2] + ) + }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index b07fda9459b..dde1a66741d 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -11,6 +11,14 @@ import type { JournalWriteBody } from './journal-write-queue' * nothing can interleave inside the transaction. */ export type JournalRowTransactionHook = (db: Database.Database, row: JournalRow) => void +/** An operation's ledger answer, committed with the journal write that makes it true: `write` runs + * inside that transaction on the same connection, `committed` synchronously right after its + * COMMIT and never after a rollback. */ +export type JournalOperationReceipt = { + write: (db: Database.Database) => void + committed: () => void +} + export type JournalRowWriterDeps = { sessionId: string now: () => number @@ -35,7 +43,8 @@ export class JournalRowWriter { enqueue( build: (seq: number, ts: number) => JournalRow, - hook?: JournalRowTransactionHook + hook?: JournalRowTransactionHook, + receipt?: JournalOperationReceipt ): Promise { return this.deps.serialize(() => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) @@ -46,6 +55,7 @@ export class JournalRowWriter { this.deps.database().transaction((db) => { insertJournalRow(db, this.deps.sessionId, row) hook?.(db, row) + receipt?.write(db) this.runBookkeeping(db, row) }) } catch (error) { @@ -54,19 +64,58 @@ export class JournalRowWriter { } // COMMIT landed, so the row is durable: adopt it before anything that can // fail. Rejecting here instead would leave the next append reusing a - // sequence the table already holds. + // sequence the table already holds. The ledger first: it cannot throw, the fold can. + receipt?.committed() this.deps.commit(row) return row }) } + /** Several rows in ONE transaction, in order, planned once the lane is this append's: none is + * durable unless all are, so no reader ever meets some without the rest. */ + enqueueRows( + plan: () => readonly ((seq: number, ts: number) => JournalRow)[] + ): Promise { + return this.deps.serialize(() => { + assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) + const first = this.deps.nextSequence() + const ts = this.deps.now() + const rows = plan().map((build, index) => build(first + index, ts)) + if (rows.length === 0) { + return rows + } + for (const row of rows) { + assertJournalFence(row.fence, this.deps.highestFence()) + } + try { + this.deps.database().transaction((db) => { + for (const row of rows) { + insertJournalRow(db, this.deps.sessionId, row) + this.runBookkeeping(db, row) + } + }) + } catch (error) { + this.deps.rolledBack?.() + throw error + } + for (const row of rows) { + this.deps.commit(row) + } + return rows + }) + } + /** Assign the next sequence, make the row durable, and fold it through the SAME reducer * replay uses — all inside one serialized step — answering where the row landed. */ append( build: (seq: number, ts: number) => JournalRow, - hook?: JournalRowTransactionHook + hook?: JournalRowTransactionHook, + receipt?: JournalOperationReceipt ): Promise { - return this.enqueue(build, hook).then((row) => ({ epoch: row.epoch, sequence: row.seq })) + return this.enqueue(build, hook, receipt).then((row) => ({ + epoch: row.epoch, + sequence: row.seq + })) } private runBookkeeping(db: Database.Database, row: JournalRow): void { diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 65ea665879b..ba879696469 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -92,6 +92,20 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal wroteBeforeOpen: (sequence) => host.journal().wroteBeforeOpen(sequence), committed: host.notifyCommitted }) + const rowWriter = new JournalRowWriter({ + sessionId: host.identity.sessionId, + now: host.now, + serialize: host.serialize, + database: host.database, + readOnly: host.readOnly, + highestFence: () => host.state().highestFence, + nextSequence: () => host.state().lastSequence + 1, + commit: host.commit, + // Every rejection is a dispatch row through this one writer; the draft + // returned-transition rides it so no path can bypass the hook. + inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), + rolledBack: () => queuedMessages.invalidate() + }) return { epochController, queuedMessages, @@ -102,20 +116,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal restoreJournalStore(host, { epochController }).then(() => queuedMessages.repairAndPruneAtOpen() ), - rowWriter: new JournalRowWriter({ - sessionId: host.identity.sessionId, - now: host.now, - serialize: host.serialize, - database: host.database, - readOnly: host.readOnly, - highestFence: () => host.state().highestFence, - nextSequence: () => host.state().lastSequence + 1, - commit: host.commit, - // Every rejection is a dispatch row through this one writer; the draft - // returned-transition rides it so no path can bypass the hook. - inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), - rolledBack: () => queuedMessages.invalidate() - }), + rowWriter, itemAppender: new JournalItemAppender({ state: host.state, enqueue: host.enqueue @@ -123,7 +124,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal lifecycleBatchAppender: new JournalLifecycleBatchAppender({ state: host.state, cursor: host.cursor, - enqueue: host.enqueue + enqueue: host.enqueue, + enqueueRows: (plan) => rowWriter.enqueueRows(plan) }) } } diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 85e6b99f902..cb86a7a8e5d 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -70,6 +70,10 @@ export type JournalLifecycleBatchInput = { mutations: readonly JournalLifecycleMutationInput[] fence: number recovered?: true + /** Rejects the sends still queued with this first, in the same append: a failed start's row + * follows the messages it failed, and no reader meets one without the other. With none still + * queued, the batch is not written either. */ + rejectsQueued?: AgentJournalDispatchRejection } export type JournalSubmissionInput = { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 5a4880155de..2349524ccfe 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -63,7 +63,7 @@ import { journalStopEventRowBuilder } from './journal-stop-and-resume-rows' import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-schema' -import type { JournalRowWriter } from './journal-row-writer' +import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer' import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' import { createJournalStoreCollaborators } from './journal-store-collaborators' @@ -340,11 +340,14 @@ export class AgentSessionJournal { input: JournalSubmissionInput, /** Present: this submission is a queued draft's conversion, and the draft's * state transition commits in the SAME transaction — exactly-once consume. */ - consume?: JournalSubmissionConsume + consume?: JournalSubmissionConsume, + /** The send's ledger answer, committed with this row. */ + receipt?: JournalOperationReceipt ): Promise { return this.rowWriter.append( journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input, consume), - consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume) + consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume), + receipt ) } diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index 2c9c6cfcd04..80e4e2c93e9 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -69,6 +69,28 @@ export function placeHandedOverMessage( }) } +/** A rejected message — queued, handed over, or sent directly — joins the conversation where it was + * rejected, in no turn: what happened before the rejection happened before it, and the newest page + * holds a recent one. Only a rejection: one in doubt may have reached the agent, so it stays. */ +export function placeRejectedMessage( + state: JournalReducerState, + submission: AgentJournalSubmission, + row: Extract +): void { + const itemId = agentJournalSubmissionKey(submission.clientMessageId) + const item = state.items.get(itemId) + if (row.state !== 'rejected' || !item) { + return + } + const { sequenceIndex: _placed, ...rest } = item + state.items.set(itemId, { + ...rest, + sequence: row.seq, + observedAt: row.ts, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) +} + export function acceptSubmissionFromProviderItem( state: JournalReducerState, providerItemId: string, diff --git a/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts index 2b8e4b80a76..4674f6de929 100644 --- a/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts @@ -163,7 +163,9 @@ describe('stated turn scope', () => { const expected = [agentJournalItemKey(row('result')), agentJournalSubmissionKey('held')] const onPhone = () => { const { items, submissions } = renderJournalState(state) - return projectStructuredAgentSessionMessages(items, [], submissions) + return projectStructuredAgentSessionMessages(items, [], submissions, { + rejectedInPlace: false + }) } // Still waiting: drawn after everything the agent did, the command's result included. expect(drawn(onPhone())).toEqual(expected) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index ddef0ed0bb6..9c920b45eb3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -32,6 +32,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { createStoppedClaudeDeadline } from './structured-agent-session-stop-deadline.test-fixture' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { HOST_TEST_NOW as NOW, @@ -133,6 +134,8 @@ beforeEach(async () => { }) afterEach(async () => { + vi.restoreAllMocks() + vi.useRealTimers() await adapter.closeAll() await host.flushAllStreamedEvents() await rm(root, { recursive: true, force: true }) @@ -214,6 +217,18 @@ function stop(turnId?: string) { return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) } +/** Resolves once everything queued on the session's lane so far has run: a Stop's second step. */ +const laneDrained = (): Promise => host['tasks'].serialize(SESSION, async () => {}) + +const stopAcrossGrace = createStoppedClaudeDeadline({ + host: () => host, + adapter: () => adapter, + claude: () => claude, + sessionId: SESSION, + stop, + laneDrained +}) + /** How many person's Stop events the journal holds when the child's close begins. */ function stopEventsAtClose(connection: FakeConnection): () => number | undefined { let atClose: number | undefined @@ -231,11 +246,6 @@ function stopEventsAtClose(connection: FakeConnection): () => number | undefined return () => atClose } -/** Resolves once everything queued on the session's lane so far has run: a Stop's second step. */ -function laneDrained(): Promise { - return host['tasks'].serialize(SESSION, async () => {}) -} - function wrote(connection: FakeConnection, text: string): boolean { return connection.sent.some((message) => JSON.stringify(message).includes(text)) } @@ -369,14 +379,15 @@ it('ends the child once the grace runs out when Claude says nothing after a Stop claude.routes.interrupt = () => ({ still_queued: [], cancelled: [] }) const clientMessageId = await sendUnechoed(connection) - const asked = Date.now() - await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await expect(stopAcrossGrace(connection, 'request-end')).resolves.toMatchObject({ + ok: true, + value: { cancelled: true } + }) await laneDrained() // As before the wait: the send Claude never answered is doubt once its child ends. expect(connection.closed).toBe(true) expect(eventsAtClose()).toBe(1) - expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS + 1_500) expect(await dispatch(clientMessageId)).toMatchObject({ state: 'unknown' }) }, 15_000) @@ -447,13 +458,14 @@ it('ends the child within the grace when Claude never answers the interrupt', as const eventsAtClose = stopEventsAtClose(connection) await openTurn(connection) - const asked = Date.now() - await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await expect(stopAcrossGrace(connection, 'interrupt')).resolves.toMatchObject({ + ok: true, + value: { cancelled: true } + }) await laneDrained() expect(connection.closed).toBe(true) expect(eventsAtClose()).toBe(1) - expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS + 1_500) expect(await turnOutcome()).toBe('cancellation') expect(await statusTexts()).toEqual(['Cancellation requested.']) }, 15_000) @@ -672,7 +684,15 @@ it.each([ await eventually(() => expect(wrote(connection, 'Follow-up.')).toBe(true)) // As the phone sends it: the turn it last saw working. - await expect(stop(ended)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await expect( + _answer === 'fails' + ? stop(ended) + : stopAcrossGrace( + connection, + interrupt === NEVER_ANSWERS ? 'interrupt' : 'request-end', + ended + ) + ).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) await laneDrained() expect(connection.calls.some((call) => call.subtype === 'interrupt')).toBe(true) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index c084d2b2e07..2dac4cacb7d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -241,16 +241,16 @@ describe('a start that never finishes (P2-15)', () => { providerChildPhase: 'starting' as const })) Object.assign(rig.host.deps.adapter, { awaitStarted: () => started.promise }) - const reject = AgentSessionJournal.prototype.rejectQueuedSubmissions - vi.spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions').mockImplementation(function ( + // The loop rejects the queued messages in the same append as its row. + const append = AgentSessionJournal.prototype.appendLifecycleBatch + vi.spyOn(AgentSessionJournal.prototype, 'appendLifecycleBatch').mockImplementation(function ( this: AgentSessionJournal, ...args ) { - // Not the open's sweep of an earlier process's leftovers. - if (args[1].rejection.kind !== 'hostRestarted') { - order.push(`rejected: ${args[1].reason}`) + if (args[0].rejectsQueued) { + order.push(`rejected: ${args[0].rejectsQueued.reason}`) } - return reject.apply(this, args) + return append.apply(this, args) }) const reader = collectSubscriber() const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 3fbb96ddaf8..1133342270d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -61,7 +61,8 @@ export function sendStructuredAgentSessionTurn( * prompt never set it. */ userSend?: true beforeRun?: () => void - } + }, + arrival?: Parameters[2] ): Promise> { const plan = sendPlan(params) return mutateStructuredAgentSession( @@ -80,7 +81,7 @@ export function sendStructuredAgentSessionTurn( (await plan.run(ctx)) ) }, - sendPreparation(context, params.envelope) + sendPreparation(context, params.envelope, arrival) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts index 959a02919a2..a96388ac4ca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts @@ -84,6 +84,8 @@ export function hostTestDrawnRowIds( state: 'dispatching' as const })) return projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages(snapshot.items, outbox, snapshot.submissions) + projectStructuredAgentSessionMessages(snapshot.items, outbox, snapshot.submissions, { + rejectedInPlace: true + }) ).map(({ id }) => id) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 5bb072f2594..3ed04e1f2fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -4,16 +4,24 @@ // own admission rules by sitting next to the call site. // // Admission is two-phase for a call that brings a `prepareSession`. The ledger's -// answer comes first and places nothing; a call it will admit may then give the -// session an owner, and only after that are the row placed and the lease -// checked — against the lease as it stands once the owner is there. +// answer comes first and places nothing. An id it refuses is answered then, with +// nothing opened; so is a recorded id that settled refused. Any other recorded id +// is answered after the plan's own preparation for a replay (a send's only opens +// the conversation), from the journal, with no admit write. A call the ledger +// admits, or a replay that proves nothing landed, may then give the session an +// owner, and only after that are the row placed and the lease checked — against +// the lease as it stands once the owner is there. import { admitAgentSessionMutation, agentSessionFingerprintConflict, + agentSessionLedgerRefusal, computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionOperationDecision } from '../../../shared/agent-session-operation-ledger' +import type { + AgentSessionOperationDecision, + AgentSessionOperationRow +} from '../../../shared/agent-session-operation-ledger' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { refuse, @@ -36,7 +44,10 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { MutationPlan } from './structured-agent-session-mutation-plans' import { runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement' -import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' +import { + agentSessionOperationOutcomeUnknown, + resolveAgentSessionReplayOutcome +} from './structured-agent-session-replay-outcome' import type { AgentSessionTurnContext } from './structured-agent-session-turns' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -103,12 +114,27 @@ export async function admitAndRunAgentSessionMutation( if (!ledger) { return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) } - if (ledger.decision.decision !== 'refused') { - const prepared = await request.prepareSession(ledger.decision.decision, ledger.record) - if (!prepared.ok) { - return prepared + if (ledger.decision.decision === 'refused') { + // Nothing to read, prepare or write: a closed chat or a store that takes no write answers alike. + return refuseAgentSessionMutation(agentSessionLedgerRefusal(envelope, ledger.decision)) + } + if (ledger.decision.decision === 'replay') { + const answered = await answerRecordedOperation( + request, + request.prepareSession, + ledger.decision.row, + ledger.record, + hostFingerprint + ) + if (answered !== 'rerun') { + return answered } } + const record = request.store.getRecord(envelope.sessionId) ?? ledger.record + const prepared = await request.prepareSession('admit', record) + if (!prepared.ok) { + return prepared + } } const journal = request.journal() if (!journal) { @@ -151,18 +177,9 @@ export async function admitAndRunAgentSessionMutation( const fence = record.lease.runtimeFence const context = turnContext(request, journal, fence) if (admission.decision === 'replay') { - const replay = resolveAgentSessionReplayOutcome({ - operationId: envelope.clientOperationId, - outcome: admission.row.outcome, - reconstruct: () => plan.replay(context, admission.row.outcome), - rerunWhenReplayMissing: plan.rerunWhenReplayMissing?.(context), - recoverUnknownFromDurableState: plan.recoverUnknownFromDurableState - }) - if (replay.decision === 'refuse') { - return refuseAgentSessionMutation(replay.refusal) - } - if (replay.decision === 'replay') { - return { ok: true, replayed: true, fence, cursor: journal.cursor(), value: replay.value } + const replayed = replayRecordedOperation(request, context, admission.row) + if (replayed !== 'rerun') { + return replayed } // Nothing durable landed, so this id is about to run for the first time. A // refused call leaves its ledger row behind, and replaying past the lease @@ -197,6 +214,85 @@ export async function admitAndRunAgentSessionMutation( : refuseAgentSessionMutation(outcome.refusal) } +/** + * A resend of a recorded id, answered with no admit write: a refusal its first run recorded, with + * nothing opened; otherwise, after the plan's own preparation for a replay, from the conversation + * its run wrote to. `rerun` when nothing durable landed, so the call runs as a first run. + */ +async function answerRecordedOperation( + request: AgentSessionMutationRequest, + prepareSession: NonNullable['prepareSession']>, + row: AgentSessionOperationRow, + record: AgentSessionRecord, + hostFingerprint: string +): Promise | 'rerun'> { + const { plan, envelope } = request + if (row.outcome.status === 'failed') { + const replay = resolveAgentSessionReplayOutcome({ + operationId: envelope.clientOperationId, + outcome: row.outcome, + reconstruct: () => null + }) + if (replay.decision === 'refuse') { + return refuseAgentSessionMutation(replay.refusal) + } + } + const prepared = await prepareSession('replay', record) + if (!prepared.ok) { + return prepared + } + const journal = request.journal() + // Read again after the open: the row as it stands, against the record the open left. + const current = request.store.evaluateMutationOperation({ + callerKey: request.callerKey, + envelope, + hostFingerprint, + now: request.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) + }) + if (!journal || !current) { + return refuseAgentSessionMutation( + agentSessionOperationOutcomeUnknown(envelope.clientOperationId) + ) + } + if (current.decision.decision === 'refused') { + return refuseAgentSessionMutation(agentSessionLedgerRefusal(envelope, current.decision)) + } + if (current.decision.decision === 'admit') { + // The row is gone since: the first-run path decides it from scratch. + return 'rerun' + } + const context = turnContext(request, journal, current.record.lease.runtimeFence) + return replayRecordedOperation(request, context, current.decision.row) +} + +/** The recorded answer from the journal, or `rerun` when the plan says nothing durable landed. */ +function replayRecordedOperation( + { plan, envelope }: AgentSessionMutationRequest, + context: AgentSessionTurnContext, + row: AgentSessionOperationRow +): AgentSessionMutationResult | 'rerun' { + const replay = resolveAgentSessionReplayOutcome({ + operationId: envelope.clientOperationId, + outcome: row.outcome, + reconstruct: () => plan.replay(context, row.outcome), + rerunWhenReplayMissing: plan.rerunWhenReplayMissing?.(context), + recoverUnknownFromDurableState: plan.recoverUnknownFromDurableState + }) + if (replay.decision === 'refuse') { + return refuseAgentSessionMutation(replay.refusal) + } + return replay.decision === 'replay' + ? { + ok: true, + replayed: true, + fence: context.fence, + cursor: context.journal.cursor(), + value: replay.value + } + : 'rerun' +} + /** The committed ledger's admission, placing nothing: a failed commit left memory as it was. */ function admitWithoutLedgerRow( { store, logger }: Pick, 'store' | 'logger'>, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index a81ea5270c8..6a2a6268ae9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -3,7 +3,9 @@ // // The replay half matters more than it looks. The ledger records only that an // operation happened, so the durable answer usually comes back out of the -// journal. Send is fail-closed: admission alone cannot prove non-delivery. +// journal. Send is fail-closed: admission alone cannot prove non-delivery, so +// its success commits with the row that accepts it, and a row still pending is +// one that wrote nothing. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' @@ -51,13 +53,23 @@ export type MutationPlan = { conversationWrite?: true /** Still runs, decided from the committed ledger, when its ledger row cannot be written. */ runsWithoutLedgerRow?: true - markUnknownBeforeRun?: boolean run: (ctx: AgentSessionTurnContext) => Promise> replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null rerunWhenReplayMissing?: (ctx: AgentSessionTurnContext) => boolean recoverUnknownFromDurableState?: boolean - settledOutcome?: (value: TValue) => AgentSessionOperationOutcome -} +} & ( + | { + /** Its success is the row its run writes, so it commits in that row's transaction + * (`AgentSessionTurnContext.operationReceipt`): a row left pending wrote nothing. That + * success is fixed before the value exists, so it records no `settledOutcome`. */ + settlesWithWrite: true + settledOutcome?: never + } + | { + settlesWithWrite?: never + settledOutcome?: (value: TValue) => AgentSessionOperationOutcome + } +) export function sendPlan(params: { envelope: AgentSessionMutationEnvelope @@ -74,7 +86,7 @@ export function sendPlan(params: { method: 'agentSession.send', operationIdScope: 'global', conversationWrite: true, - markUnknownBeforeRun: true, + settlesWithWrite: true, // `delivery` joins the OPERATION fingerprint only; the submission row keeps // the body-only fingerprint the reducer's echo-aliasing recomputes. fields: { body: params.body, ...(params.delivery ? { delivery: params.delivery } : {}) }, @@ -104,7 +116,9 @@ export function sendPlan(params: { if (submission) { return { clientMessageId, submission } } - if (outcome.status === 'failed') { + // A pending row wrote nothing, so the send runs for the first time. Succeeded: accepted, + // then a new epoch dropped its row. Unknown: only builds before this one wrote that. + if (outcome.status === 'failed' || outcome.status === 'pending') { return null } const resolvedAt = ctx.now() @@ -141,7 +155,7 @@ export function conversationCommandPlan(params: { return { method: 'agentSession.conversationCommand', conversationWrite: true, - markUnknownBeforeRun: true, + settlesWithWrite: true, fields: { command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND }, recoverUnknownFromDurableState: true, run: async (ctx) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index 203e31f4ea0..c7901edc1c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -42,27 +42,18 @@ async function context(): Promise { } } -/** Longer than any bookkeeping bound: a refusal must already be answered by then. */ -const SETTLED_WAIT_MS = 2_000 - afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() }) -it('returns a pre-dispatch refusal without waiting on redundant uncertainty persistence', async () => { +it('rethrows a throw from a plan answered by its write, writing nothing and leaving the row pending', async () => { const ctx = await context() const { store } = hostTestState() - const stalled = Promise.withResolvers() - const refusing = Promise.withResolvers() - const writes = vi - .spyOn(store, 'recordOperationOutcome') - .mockResolvedValueOnce() - .mockImplementation(() => stalled.promise) + const writes = vi.spyOn(store, 'recordOperationOutcome') const refusal = new AgentSessionPreDispatchError('agent_session_restart_work_superseded') - let returned = false vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const result = runSettledAgentSessionMutation({ + const result = await runSettledAgentSessionMutation({ store, operationCallerKey: 'test', envelope: envelope('agentSession.send', {}), @@ -70,42 +61,30 @@ it('returns a pre-dispatch refusal without waiting on redundant uncertainty pers plan: { method: 'agentSession.send', fields: {}, - markUnknownBeforeRun: true, + settlesWithWrite: true, run: async () => { - refusing.resolve() throw refusal }, replay: () => null } - }).catch((error: unknown) => { - returned = true - return error - }) - try { - await refusing.promise - await vi.advanceTimersByTimeAsync(SETTLED_WAIT_MS) - expect(returned).toBe(true) - expect(writes).toHaveBeenCalledOnce() - expect(hostTestState().dispatch).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - } finally { - stalled.resolve() - expect(await result).toBe(refusal) - } + }).catch((error: unknown) => error) + expect(result).toBe(refusal) + expect(writes).not.toHaveBeenCalled() + expect(hostTestState().dispatch).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) }) -it.each([1, 2])( - 'preserves a proven refusal through %s failed bookkeeping writes', - async (failures) => { +it.each([ + { plan: 'answered by its write', settlesWithWrite: true as const, failures: 1 }, + { plan: 'settled after its run', settlesWithWrite: undefined, failures: 2 } +])( + 'preserves a proven refusal of a plan $plan through $failures failed bookkeeping writes', + async ({ settlesWithWrite, failures }) => { const ctx = await context() const { store, dispatch } = hostTestState() const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) - let writes = 0 - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async () => { - writes += 1 - if (writes > 1 && writes <= failures + 1) { - throw new Error('private unbounded disk detail') - } + const writes = vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async () => { + throw new Error('private unbounded disk detail') }) const refusal = { ok: false as const, @@ -120,12 +99,13 @@ it.each([1, 2])( plan: { method: 'agentSession.send', fields: {}, - markUnknownBeforeRun: true, + ...(settlesWithWrite ? { settlesWithWrite } : {}), run, replay: () => null } }) expect(result).toEqual(refusal) + expect(writes).toHaveBeenCalledTimes(failures) expect(run).toHaveBeenCalledOnce() expect(dispatch).not.toHaveBeenCalled() expect(JSON.stringify(warning.mock.calls)).not.toContain('private unbounded disk detail') @@ -160,7 +140,7 @@ it('accepts without touching the provider', async () => { it('refuses a superseded send at acceptance, recording and dispatching nothing', async () => { const ctx = await context() const { store } = hostTestState() - vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue() + const writes = vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue() const beforeRun = vi.fn(() => { throw new AgentSessionPreDispatchError('agent_session_restart_work_superseded') }) @@ -175,6 +155,7 @@ it('refuses a superseded send at acceptance, recording and dispatching nothing', plan: sendPlan({ envelope: operation, body, beforeRun }) }).catch((error: unknown) => error) expect(result).toBeInstanceOf(AgentSessionPreDispatchError) + expect(writes).not.toHaveBeenCalled() expect(ctx.journal.submissions()).toEqual([]) expect(hostTestState().dispatch).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts index 24de6ff708b..175afeaec35 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -1,4 +1,5 @@ import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -18,20 +19,35 @@ export async function runSettledAgentSessionMutation(input: { plan: MutationPlan context: AgentSessionTurnContext }): Promise> { + const operation = { + callerKey: input.operationCallerKey, + operationId: input.envelope.clientOperationId + } const settle = ( outcome: Parameters[0]['outcome'] - ) => - input.store.recordOperationOutcome({ - callerKey: input.operationCallerKey, - operationId: input.envelope.clientOperationId, - outcome - }) + ) => input.store.recordOperationOutcome({ ...operation, outcome }) + const receipt = input.plan.settlesWithWrite + ? observedReceipt( + input.store.operationOutcomeReceipt({ + ...operation, + outcome: { status: 'succeeded', sessionId: input.envelope.sessionId } + }) + ) + : undefined + const context = receipt ? { ...input.context, operationReceipt: receipt } : input.context let outcome: TurnOutcome | undefined try { - if (input.plan.markUnknownBeforeRun) { - await settle({ status: 'unknown' }) + const ran = await input.plan.run(context) + if (receipt?.wasCommitted() && !ran.ok) { + // The row says accepted, so a refusal past it (a fold that failed after COMMIT) is a fault. + throw new Error( + `operation ${operation.operationId} was accepted, then refused: ${ran.refusal.code}` + ) } - outcome = await input.plan.run(input.context) + if (receipt?.wasCommitted()) { + return ran + } + outcome = ran await settle( outcome.ok ? (input.plan.settledOutcome?.(outcome.value) ?? { @@ -48,29 +64,42 @@ export async function runSettledAgentSessionMutation(input: { ) return outcome } catch (error) { - // The pre-run uncertainty is already durable; refusing before dispatch adds no new uncertainty. - if (input.plan.markUnknownBeforeRun && error instanceof AgentSessionPreDispatchError) { - throw error - } const { logger, sessionId } = input.context - try { - await settle({ status: 'unknown' }) - } catch { - // Bookkeeping must not replace the operation's proof of whether dispatch began. - logger.warn('recording an operation as unknown failed', { - scope: 'operation-unknown-settlement', - sessionId, - operationId: input.envelope.clientOperationId - }) + // Its success commits with its write, so a throw leaves the row pending: nothing was written. + if (!input.plan.settlesWithWrite) { + try { + await settle({ status: 'unknown' }) + } catch { + // Bookkeeping must not replace the operation's proof of whether dispatch began. + logger.warn('recording an operation as unknown failed', { + scope: 'operation-unknown-settlement', + sessionId, + operationId: operation.operationId + }) + } } if (outcome && !outcome.ok) { logger.warn('recording a refused operation failed', { scope: 'operation-refused-settlement', sessionId, - operationId: input.envelope.clientOperationId + operationId: operation.operationId }) return outcome } throw error } } + +function observedReceipt( + receipt: JournalOperationReceipt +): JournalOperationReceipt & { wasCommitted: () => boolean } { + let committed = false + return { + write: receipt.write, + committed: () => { + receipt.committed() + committed = true + }, + wasCommitted: () => committed + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts new file mode 100644 index 00000000000..5657fcfb4d8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts @@ -0,0 +1,48 @@ +// A chat's options at rest come from the host catalog without waiting on a listing. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' +import { + AgentModelCatalogStore, + type AgentModelCatalogSuccess +} from '../agent-model-catalog/agent-model-catalog-store' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read' + +const SESSION = 'session-1' + +function restingRecord(): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read and the catalog key touch only these fields. + return { + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/homes/a' }, + location: { wslDistro: null }, + options: {} + } as unknown as AgentSessionRecord +} + +describe('options at rest', () => { + it('answers while the first catalog listing is still running', async () => { + const record = restingRecord() + const probe = vi.fn(() => new Promise(() => {})) + const modelCatalog = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + getRecord: () => record, + resolveAccountHome: async () => ({ variable: 'CODEX_HOME', path: '/homes/a' }), + probes: { codex: probe } + }) + const resting = { child: null, params: { provider: 'codex' } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting read touches only these members. + const context = { + deps: { adapter: {}, store: { getRecord: () => record }, modelCatalog }, + serialize: (_sessionId: string, task: () => Promise) => task(), + openConversation: async () => resting, + conversation: async () => resting + } as unknown as StructuredAgentSessionMutationContext + + const result = await readStructuredAgentSessionOptions(context, SESSION) + expect(probe).toHaveBeenCalledTimes(1) + expect(result.models).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts index 2be4a8ea12b..144a43302b7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -114,8 +114,10 @@ describe('/clear', () => { try { const cleared = command('clear') await eventually(() => expect(committing).toHaveBeenCalledOnce()) - expect(await rig.send('sent while clearing', 'queue-if-active').result).toEqual(WAIT_REFUSAL) + // Judged on arrival, answered on its turn: behind the clear. + const sent = rig.send('sent while clearing', 'queue-if-active').result release?.() + expect(await sent).toEqual(WAIT_REFUSAL) const done = await cleared const replacementId = done.ok ? done.value.replacementSessionId : undefined if (!replacementId) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index ac1fdbb0617..176fd8e11fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -23,6 +23,7 @@ import { isUnsettledQueuedMessage } from '../agent-session-journal/queued-messag import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -185,11 +186,7 @@ export async function maybeQueueStructuredAgentSessionSend( context: { deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } } }, - ctx: { - sessionId: string - journal: AgentSessionJournal - fence: number - }, + ctx: Pick, params: { envelope: { clientOperationId: string } body: AgentJournalMessageItem @@ -231,12 +228,15 @@ export async function maybeQueueStructuredAgentSessionSend( } // The insert notifies through the journal's commit listener: publication and // the drain re-derive with no call here to forget. - const row = await ctx.journal.queuedMessages.insert({ - messageId: clientMessageId, - body: params.body, - fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance() - }) + const row = await ctx.journal.queuedMessages.insert( + { + messageId: clientMessageId, + body: params.body, + fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), + hostInstance: structuredAgentSessionHostInstance() + }, + ctx.operationReceipt + ) return { ok: true, value: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts index 00fa33a30fb..cea36c6546a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts @@ -13,6 +13,16 @@ export type AgentSessionReplayOutcomeDecision = | { decision: 'rerun' } | { decision: 'refuse'; refusal: AgentSessionWireRefusal } +/** A recorded id this host cannot answer from what it holds, so it neither ran it again nor + * refused it: the caller resends under the same id. */ +export function agentSessionOperationOutcomeUnknown(operationId: string): AgentSessionWireRefusal { + return refuse( + 'agent_session_operation_unknown', + { reason: 'outcomeUnknown' }, + `The outcome of operation ${operationId} is unknown; it was not run again.` + ) +} + export function resolveAgentSessionReplayOutcome(input: { operationId: string outcome: AgentSessionOperationOutcome @@ -41,14 +51,7 @@ export function resolveAgentSessionReplayOutcome(input: { if (input.rerunWhenReplayMissing) { return { decision: 'rerun' } } - return { - decision: 'refuse', - refusal: refuse( - 'agent_session_operation_unknown', - { reason: 'outcomeUnknown' }, - `The outcome of operation ${operationId} is unknown; it was not run again.` - ) - } + return { decision: 'refuse', refusal: agentSessionOperationOutcomeUnknown(operationId) } } const recorded = input.reconstruct() if (recorded) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts new file mode 100644 index 00000000000..74e4cfb4815 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts @@ -0,0 +1,241 @@ +// What a resend of a send id gets: the answer its record holds, never a refusal made before the +// host looked the id up, and never a made-up record. + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { persistRewindRecord } from './structured-rewind-recovery' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { createQueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock +let acquire: Mock + +beforeEach(() => { + ;({ root, store, host, dispatch, acquire } = hostTestState()) +}) + +afterEach(() => vi.restoreAllMocks()) + +function hostJournal(): AgentSessionJournal { + return ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal +} + +function sendParams(text: string) { + const body = hostTestMessage(text) + return { envelope: envelope('agentSession.send', { body }), body } +} + +async function deliveredOnce(): Promise { + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) +} + +describe('a resent send id', () => { + it('is answered from a refused row without opening the chat again', async () => { + await attach() + vi.spyOn(hostJournal(), 'appendSubmission').mockRejectedValueOnce(new Error('disk full')) + const params = sendParams('refused once') + const first = await host.send(CALLER, params) + expect(first).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + await host.close(SESSION, 'evict') + expect(host.hasSession(SESSION)).toBe(false) + + const resent = await host.send(CALLER, params) + + expect(resent).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'journalWriteFailed' } + } + }) + // The answer came from the ledger alone: the closed chat was not opened to give it. + expect(host.hasSession(SESSION)).toBe(false) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('answers unknown, never a refusal, when the chat holding its answer cannot be opened', async () => { + await attach() + const params = sendParams('recorded, then the chat would not open') + await host.send(CALLER, params) + await deliveredOnce() + await host.close(SESSION, 'evict') + const connection = openTestJournalHostDatabase(root).db + const prepare = connection.prepare.bind(connection) + vi.spyOn(connection, 'prepare').mockImplementation((sql: string) => { + if (sql.includes('journal_')) { + throw Object.assign(new Error('database disk image is malformed'), { + code: 'ERR_SQLITE_ERROR', + errcode: 11 + }) + } + return prepare(sql) + }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown', details: { reason: 'outcomeUnknown' } } + }) + // A new id meets the same chat as a first run, and is refused for what it is. + await expect(host.send(CALLER, sendParams('a new message'))).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_journal_unreadable' } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('is answered from its record while a /clear is in flight; a new id is refused', async () => { + await attach() + const params = sendParams('sent before the clear') + expect(await host.send(CALLER, params)).toMatchObject({ ok: true, replayed: false }) + + const clearing = host.conversationCommand(CALLER, { + command: 'clear', + envelope: envelope('agentSession.conversationCommand', { command: 'clear' }) + }) + const resent = host.send(CALLER, params) + const fresh = host.send(CALLER, sendParams('typed during the clear')) + await clearing + + await expect(resent).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + await expect(fresh).resolves.toMatchObject({ + ok: false, + refusal: { details: { reason: 'conversationCommandInFlight' } } + }) + }) + + it('waits for an original still being accepted and answers with its submission', async () => { + await attach() + const journal = hostJournal() + const append = journal.appendSubmission.bind(journal) + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(journal, 'appendSubmission').mockImplementationOnce(async (...args) => { + await held + return append(...args) + }) + const params = sendParams('resent while the first is mid-write') + + const original = host.send(CALLER, params) + const resent = host.send(CALLER, params) + await vi.waitFor(() => expect(journal.appendSubmission).toHaveBeenCalledTimes(1)) + release() + + const [first, second] = await Promise.all([original, resent]) + expect(first).toMatchObject({ ok: true, replayed: false }) + expect(second).toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + if (!second.ok || !('submission' in second.value)) { + throw new Error('expected the submission arm') + } + expect(second.value.submission.reason).not.toBe(DISPATCH_DOUBT_SUBMISSION_MISSING) + await deliveredOnce() + expect(journal.submissions()).toHaveLength(1) + expect( + store + .listOperationRows() + .filter((row) => row.operationId === params.envelope.clientOperationId) + ).toHaveLength(1) + }) + + it('is answered from the chat while a rewind is in doubt, starting no agent', async () => { + await attach() + const params = sendParams('sent before the rewind') + await host.send(CALLER, params) + await deliveredOnce() + await host.close(SESSION, 'evict') + // Only the provider can settle this rewind, so a send's first run would start it. + await persistRewindRecord(store, SESSION, store.getRecord(SESSION)!.lease.runtimeFence, { + operationId: 'rewind-op', + callerKey: CALLER.callerKey, + itemId: 'orca:rewound', + providerItemId: `codex:${THREAD}:turn-1:0`, + expectedEpoch: 'epoch-before', + phase: 'prepared', + retained: [] + }) + acquire.mockClear() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + expect(acquire).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('prepared') + }) + + it('is answered from a store a newer Orca wrote, which takes no write', async () => { + await attach() + const params = sendParams('sent, then a newer Orca wrote the store') + await host.send(CALLER, params) + await deliveredOnce() + await host.close(SESSION, 'evict') + Object.defineProperty(openTestJournalHostDatabase(root), 'readOnly', { value: true }) + expect(store.readOnly).toBe(true) + + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) +}) + +describe('a send queued behind a running turn', () => { + it('commits its accepted row with its draft, so a failed settlement loses no answer', async () => { + const rig = await createQueuedMessageTestRig() + try { + await rig.workingSend() + const settle = vi + .spyOn(rig.store, 'recordOperationOutcome') + .mockRejectedValue(new Error('operation settlement failed')) + const queued = rig.send('queued behind the turn', 'queue-if-active') + + await expect(queued.result).resolves.toMatchObject({ + ok: true, + value: { queued: { messageId: queued.id, state: 'waiting' } } + }) + expect( + rig.store.listOperationRows().find((row) => row.operationId === queued.id) + ).toMatchObject({ outcome: { status: 'succeeded' } }) + expect(settle).not.toHaveBeenCalled() + } finally { + await rig.dispose() + } + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts new file mode 100644 index 00000000000..e286c8a58ae --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts @@ -0,0 +1,157 @@ +// A send id the ledger refuses is answered with that refusal and nothing else: no chat opened, no +// write. A /clear in flight refuses only a send's first run, judged when the send arrived. + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' + +const EXPIRED = { + ok: false, + refusal: { + code: 'agent_session_operation_expired', + details: { reason: 'operationExpired' } + } +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock + +beforeEach(() => { + ;({ root, store, host, dispatch } = hostTestState()) +}) + +afterEach(() => vi.restoreAllMocks()) + +function hostJournal(): AgentSessionJournal { + return ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal +} + +function sendParams(text: string, clientOperationId?: string) { + const body = hostTestMessage(text) + return { + envelope: envelope( + 'agentSession.send', + { body }, + clientOperationId ? { clientOperationId } : {} + ), + body + } +} + +/** An id minted more than a day ago, which no ledger row holds any more. */ +function expiredParams(text: string) { + return sendParams( + text, + `${NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 60_000}-${'e'.repeat(32)}` + ) +} + +function clear() { + return host.conversationCommand(CALLER, { + command: 'clear', + envelope: envelope('agentSession.conversationCommand', { command: 'clear' }) + }) +} + +describe('an expired send id', () => { + it('is answered expired while its chat is closed, not as a chat this host lacks', async () => { + await attach() + await host.close(SESSION, 'evict') + + await expect(host.send(CALLER, expiredParams('long gone'))).resolves.toMatchObject(EXPIRED) + expect(host.hasSession(SESSION)).toBe(false) + }) + + it('is answered expired by a store a newer Orca wrote', async () => { + await attach() + const database = openTestJournalHostDatabase(root) + Object.defineProperty(database, 'readOnly', { value: true }) + expect(store.readOnly).toBe(true) + try { + await expect(host.send(CALLER, expiredParams('long gone'))).resolves.toMatchObject(EXPIRED) + } finally { + // Teardown stops the live child, which writes. + Object.defineProperty(database, 'readOnly', { value: false }) + } + }) + + it('is answered expired when its row lapses while the chat opens for the resend', async () => { + await attach() + const params = sendParams('recorded, then it lapsed') + await host.send(CALLER, params) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) + const evaluate = store.evaluateMutationOperation + // The second read, after the open, sees the clock past the row's whole retention. + vi.spyOn(store, 'evaluateMutationOperation') + .mockImplementationOnce(evaluate) + .mockImplementationOnce((args) => + evaluate({ ...args, now: args.now + 3 * AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS }) + ) + + await expect(host.send(CALLER, params)).resolves.toMatchObject(EXPIRED) + expect(dispatch).toHaveBeenCalledTimes(1) + }) +}) + +describe('a /clear in flight', () => { + it('answers a resend from the attempt queued ahead of it, delivering once', async () => { + await attach() + const journal = hostJournal() + const append = journal.appendSubmission.bind(journal) + const held = Promise.withResolvers() + // An earlier send holds the session's queue, so attempt 1 waits there when the clear arrives. + vi.spyOn(journal, 'appendSubmission').mockImplementationOnce(async (...args) => { + await held.promise + return append(...args) + }) + const earlier = host.send(CALLER, sendParams('holds the queue')) + const params = sendParams('queued ahead of the clear') + const id = params.envelope.clientOperationId + const attempt = host.send(CALLER, params) + await vi.waitFor(() => expect(journal.appendSubmission).toHaveBeenCalledTimes(1)) + + const clearing = clear() + const resent = host.send(CALLER, params) + held.resolve() + const [first, second] = await Promise.all([attempt, resent, earlier, clearing]) + + expect(first).toMatchObject({ ok: true, replayed: false }) + expect(second).toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: id } } + }) + expect(journal.submissions().filter((entry) => entry.clientMessageId === id)).toHaveLength(1) + expect( + dispatch.mock.calls.filter(([input]) => input.clientMessageId === id).length + ).toBeLessThanOrEqual(1) + }) + + it('answers an expired id expired', async () => { + await attach() + const clearing = clear() + const expired = host.send(CALLER, expiredParams('typed long ago')) + await clearing + + await expect(expired).resolves.toMatchObject(EXPIRED) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index f1fe0a40b42..b1cbc83a48e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -5,10 +5,10 @@ import { AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE } from '../../../shared/agent-session-restart-continuation' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' -import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring' import { interruptedRestart, - statusNotes + statusNotes, + throwAfterContinuationAccepted } from './structured-agent-session-restart-interruption-test-harness' import { CALLER, envelope } from './structured-agent-session-host-test-harness' import { @@ -66,19 +66,10 @@ it('says so in the chat when the agent cannot start for the continuation', async // A send that throws after Orca may have taken it cannot be proven undelivered: filed unconfirmed, // and it stays on record while the agent that may be carrying on keeps running. it('keeps an unconfirmed failure while the agent the continuation started keeps running', async () => { - const { host, store } = await interruptedRestart() + const { host } = await interruptedRestart() vi.spyOn(console, 'warn').mockImplementation(() => {}) await host.restartResume.list() - const settle = store.recordOperationOutcome.bind(store) - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if ( - input.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER && - input.outcome.status === 'succeeded' - ) { - throw new Error('operation outcome could not be persisted') - } - return settle(input) - }) + throwAfterContinuationAccepted() const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 94746daf3ec..fe97834a189 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -13,6 +13,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' @@ -160,6 +161,21 @@ export async function interruptedRestart( return { ...hostTestState(), host, store, log, closeSession, marker, clock } } +/** The continuation's submission commits, then its send throws: a send Orca may have taken. */ +export function throwAfterContinuationAccepted(): void { + const append = AgentSessionJournal.prototype.appendSubmission + vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockImplementation(async function ( + this: AgentSessionJournal, + ...args: Parameters + ) { + const cursor = await append.apply(this, args) + if (args[0].origin === 'host') { + throw new Error('the accepted continuation could not be answered') + } + return cursor + }) +} + export async function statusNotes(host: StructuredAgentSessionHost) { return (await host.journalSnapshot(SESSION)).items.flatMap((item) => item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index a46551b4500..4019582e1fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -18,7 +18,8 @@ import { interruptedRestart, startAgent, statusNotes, - supersededRefusal + supersededRefusal, + throwAfterContinuationAccepted } from './structured-agent-session-restart-interruption-test-harness' import { attach, @@ -141,38 +142,30 @@ it('replays the same logical continuation through the durable send ledger', asyn }) // A send that throws after Orca may have taken it is not proof it was not delivered. -it.each([false, true])( - 'keeps a continuation unconfirmed when its acceptance cannot be recorded (uncertainty write fails: %s)', - async (uncertaintyFails) => { - const { host, store } = await interruptedRestart() - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) - expect(await host.restartResume.list()).toHaveLength(1) - const settle = store.recordOperationOutcome.bind(store) - const recording = vi.spyOn(store, 'recordOperationOutcome') - recording.mockImplementation(async (input) => { - // Only the continuation's own record: the start it makes records its attach as usual. - if ( - input.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER && - (input.outcome.status === 'succeeded' || uncertaintyFails) - ) { - throw new Error('operation outcome could not be persisted') - } - return settle(input) - }) +it('keeps a continuation unconfirmed when its send throws after acceptance', async () => { + const { host, store } = await interruptedRestart() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(await host.restartResume.list()).toHaveLength(1) + throwAfterContinuationAccepted() - const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + const result = await host.restartResume + .continueAfterRestart([SESSION], 'modal') + .finally(() => vi.restoreAllMocks()) - expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'unknown' }]) - // Filed as unconfirmed, with a warning in the chat. - expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) - expect(await statusNotes(host)).toContainEqual({ - text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, - tone: 'warning' - }) - recording.mockRestore() - warning.mockRestore() - } -) + expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'unknown' }]) + // Filed as unconfirmed, with a warning in the chat. + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) + expect(await statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, + tone: 'warning' + }) + // Its acceptance committed with its submission: a resend replays it. + expect( + store + .listOperationRows() + .find((row) => row.callerKey === STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER) + ).toMatchObject({ outcome: { status: 'succeeded' } }) +}) // The continuation is accepted, then its start fails: the message is rejected with the cause and // the failure is filed, and nothing is stopped because nothing started. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index cf746e29b74..01f4868d380 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -274,10 +274,12 @@ describe('a send with no live owner', () => { expect(acquire).toHaveBeenCalledOnce() }) - it('restarts nothing for a send the ledger holds but the journal never saw', async () => { - const params = sendParams('claimed, then the host died') - // The row was claimed and the host went down before the journal write: on replay, admission - // reconstructs an unknown-outcome submission and never needs an owner. + /** A row admitted for this send, then the host died before the journal write, left as `outcome` + * says: `pending` by this build, `unknown` by a build that marked it before running. */ + async function admittedThenHostDied( + params: ReturnType, + outcome: 'pending' | 'unknown' + ) { await store.admitMutationOperation({ callerKey: CALLER.callerKey, envelope: params.envelope, @@ -285,11 +287,13 @@ describe('a send with no live owner', () => { now: NOW, operationIdScope: 'global' }) - await store.recordOperationOutcome({ - callerKey: CALLER.callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'unknown' } - }) + if (outcome === 'unknown') { + await store.recordOperationOutcome({ + callerKey: CALLER.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'unknown' } + }) + } await host.handleAdapterEvent({ type: 'ended', sessionId: SESSION, @@ -300,16 +304,31 @@ describe('a send with no live owner', () => { }) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') acquire.mockClear() - - const result = await host.send(CALLER, { + return { ...params, envelope: { ...params.envelope, expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } - }) + } + } - expect(result).toMatchObject({ + it('runs a send admitted but never run for the first time, restarting the owner once', async () => { + const resent = await admittedThenHostDied(sendParams('admitted, then the host died'), 'pending') + + await expect(host.send(CALLER, resent)).resolves.toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'pending' } } + }) + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) + expect(acquire).toHaveBeenCalledOnce() + }) + + it("restarts nothing for an older build's unknown row the journal never saw", async () => { + const resent = await admittedThenHostDied(sendParams('marked unknown, then died'), 'unknown') + + await expect(host.send(CALLER, resent)).resolves.toMatchObject({ ok: true, replayed: true, value: { submission: { dispatchState: 'unknown', recovered: true } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index 77413f45634..e74947054e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -22,7 +22,9 @@ import { AGENT_SESSION_NOT_ATTACHED, type AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' +import { agentSessionOperationOutcomeUnknown } from './structured-agent-session-replay-outcome' import { rewindRefusal } from './structured-rewind-refusal' +import { conversationCommandInFlight } from './structured-conversation-command-admission' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -132,17 +134,29 @@ export function openWithAgent( } /** A rewind still in doubt once the conversation is open is one only its provider can settle — - * the open settles every other — so a send starts the agent, whose attach recovers it. */ + * the open settles every other — so a send starts the agent, whose attach recovers it. A resend + * of a recorded id needs only the conversation, its answer's source: it starts nothing, and an + * open that fails leaves that answer unknown, never refused. `clearInFlight`: a /clear was running + * when this send arrived, which refuses only its first run. */ export function sendPreparation( context: Pick, - envelope: AgentSessionMutationEnvelope -): () => Promise { - return async () => { + envelope: AgentSessionMutationEnvelope, + arrival: { clearInFlight?: boolean } = {} +): (ledger: 'admit' | 'replay') => Promise { + return async (ledger) => { + if (ledger === 'admit' && arrival.clearInFlight) { + return { ok: false, refusal: conversationCommandInFlight() } + } const opened = await openConversationForWrite( context.openConversation, envelope, context.deps.logger ) + if (ledger === 'replay') { + return opened.ok + ? opened + : { ok: false, refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) } + } const phase = context.deps.store.getRecord(envelope.sessionId)?.rewind?.phase return opened.ok && (phase === 'prepared' || phase === 'provider-succeeded') ? context.ensureAgent(envelope.sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index f0163b22791..f382eda54e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -303,23 +303,20 @@ describe('send', () => { expect(journal.submissions()).toHaveLength(1) }) - it('reconstructs an accepted send after the ledger settlement is lost', async () => { + it('answers a send whose ledger settlement fails, its row committed with the submission', async () => { await attach() - const persist = store.recordOperationOutcome.bind(store) - let failSettlement = true - vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { - if (failSettlement && input.outcome.status === 'succeeded') { - failSettlement = false - throw new Error('operation settlement failed') - } - return persist(input) - }) - const body = hostTestMessage('accepted before settlement failed') + vi.spyOn(store, 'recordOperationOutcome').mockRejectedValue( + new Error('operation settlement failed') + ) + const body = hostTestMessage('accepted while settlement fails') const params = { envelope: envelope('agentSession.send', { body }), body } + const clientMessageId = params.envelope.clientOperationId - await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') - // The submission was recorded before the ledger write failed, so it is still delivered. - await delivered(params.envelope.clientOperationId) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: false }) + expect( + store.listOperationRows().find((row) => row.operationId === clientMessageId) + ).toMatchObject({ outcome: { status: 'succeeded' } }) + await delivered(clientMessageId) await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true, @@ -328,51 +325,54 @@ describe('send', () => { expect(dispatch).toHaveBeenCalledTimes(1) }) - it('never reruns an admission-only send after the caller changes', async () => { + it('runs a send that threw before writing for the first time when it is resent, once', async () => { await attach() - const settlement = vi - .spyOn(store, 'recordOperationOutcome') - .mockRejectedValue(new Error('operation settlement failed')) const body = hostTestMessage('first delivery after caller recovery') const params = { envelope: envelope('agentSession.send', { body }), body } + const clientMessageId = params.envelope.clientOperationId + const beforeRun = () => { + throw new Error('host fault before the write') + } - await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') - expect(dispatch).not.toHaveBeenCalled() - settlement.mockRestore() + await expect(host.send(CALLER, { ...params, beforeRun })).rejects.toThrow( + 'host fault before the write' + ) + expect(hostJournal().submissions()).toHaveLength(0) + // Its success would have committed with its write, so a row still pending wrote nothing. + expect( + store.listOperationRows().find((row) => row.operationId === clientMessageId) + ).toMatchObject({ outcome: { status: 'pending' } }) await expect(host.send({ callerKey: 'client-after-recovery' }, params)).resolves.toMatchObject({ ok: true, - replayed: true, - value: { - submission: { - dispatchState: 'unknown', - reason: DISPATCH_DOUBT_SUBMISSION_MISSING - } - } + replayed: false, + value: { submission: { clientMessageId, dispatchState: 'pending' } } }) - expect(dispatch).not.toHaveBeenCalled() + await delivered(clientMessageId) expect( - store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) - ).toMatchObject({ callerKey: CALLER.callerKey, outcome: { status: 'pending' } }) + store.listOperationRows().find((row) => row.operationId === clientMessageId) + ).toMatchObject({ callerKey: CALLER.callerKey, outcome: { status: 'succeeded' } }) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) + expect(dispatch).toHaveBeenCalledTimes(1) }) - it('never redelivers after admission survives without its journal submission', async () => { + it("answers an older build's unknown row a new epoch emptied as recorded, never redelivering", async () => { await attach() - const persist = store.recordOperationOutcome.bind(store) - const settlement = vi - .spyOn(store, 'recordOperationOutcome') - .mockImplementation(async (input) => { - if (input.outcome.status === 'succeeded') { - throw new Error('operation settlement failed') - } - return persist(input) - }) const body = hostTestMessage('delivered before epoch recovery') const params = { envelope: envelope('agentSession.send', { body }), body } - - await expect(host.send(CALLER, params)).rejects.toThrow('operation settlement failed') - settlement.mockRestore() + await host.send(CALLER, params) await delivered(params.envelope.clientOperationId) + // What a build that marked a send unknown before running it leaves behind. + await store.recordOperationOutcome({ + callerKey: CALLER.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'pending' } + }) + await store.recordOperationOutcome({ + callerKey: CALLER.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'unknown' } + }) const journal = hostJournal() await journal.rollEpoch('schema_unreadable', store.getRecord(SESSION)?.lease.runtimeFence ?? 1) expect(journal.submissions()).toHaveLength(0) @@ -394,33 +394,22 @@ describe('send', () => { expect(journal.submissions()).toHaveLength(0) }) - it('fails closed when a legacy pending row survives without its submission', async () => { + it('answers an accepted send a new epoch dropped as recorded, never by running it again', async () => { await attach() - const body = hostTestMessage('legacy pending send after caller recovery') + const body = hostTestMessage('accepted, then the epoch was replaced') const params = { envelope: envelope('agentSession.send', { body }), body } - await host.send(CALLER, params) await delivered(params.envelope.clientOperationId) - expect(dispatch).toHaveBeenCalledTimes(1) - await store.recordOperationOutcome({ - callerKey: CALLER.callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'pending' } - }) + await hostJournal().rollEpoch( + 'schema_unreadable', + store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + ) - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal - await journal.rollEpoch('schema_unreadable', store.getRecord(SESSION)?.lease.runtimeFence ?? 1) - - await expect(host.send({ callerKey: 'client-after-recovery' }, params)).resolves.toMatchObject({ + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true, value: { - submission: { - dispatchState: 'unknown', - reason: DISPATCH_DOUBT_SUBMISSION_MISSING - } + submission: { dispatchState: 'unknown', reason: DISPATCH_DOUBT_SUBMISSION_MISSING } } }) expect(dispatch).toHaveBeenCalledTimes(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 12fa05710f0..9206f95fde0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -42,9 +42,9 @@ export function hasStructuredAgentSessionStartFailureRow( } /** - * A start the delivery loop needed and did not get: the start's row, and every queued message - * rejected with the same words. Writes nothing when nothing is still queued: a start whose - * messages Stop withdrew did not fail anyone. + * A start the delivery loop needed and did not get: every queued message rejected with the same + * words, then the start's row. Writes nothing when nothing is still queued: a start whose messages + * Stop withdrew did not fail anyone. */ export async function recordStructuredAgentSessionStartFailure( session: Pick & { fence: number }, @@ -59,11 +59,8 @@ export async function recordStructuredAgentSessionStartFailure( settlementId: `start-failure:${startKey}`, fence: session.fence, recovered: true, - mutations: [structuredAgentSessionStartFailureRow(startKey, failure)] - }) - await session.journal.rejectQueuedSubmissions(session.fence, { - reason: failure.reason, - rejection: failure.rejection + mutations: [structuredAgentSessionStartFailureRow(startKey, failure)], + rejectsQueued: { reason: failure.reason, rejection: failure.rejection } }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts new file mode 100644 index 00000000000..4d33a998a59 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-deadline.test-fixture.ts @@ -0,0 +1,89 @@ +import { expect, vi } from 'vitest' +import { CLAUDE_STOP_GRACE_MS } from '../../claude/claude-request-end-wait' +import type { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import type { + fakeClaude, + FakeConnection +} from '../../claude/claude-structured-session-test-support' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +export function createStoppedClaudeDeadline(deps: { + host: () => Pick + adapter: () => Pick + claude: () => ReturnType + sessionId: string + stop: (turnId?: string) => ReturnType + laneDrained: () => Promise +}) { + return async ( + connection: FakeConnection, + deadline: 'interrupt' | 'request-end', + turnId?: string + ) => { + await deps.host().flushStreamedEvents(deps.sessionId) + const reachedDeadline = Promise.withResolvers< + Awaited> | undefined + >() + let deadlineSettled = false + const claude = deps.claude() + const adapter = deps.adapter() + const interrupt = claude.routes.interrupt + const requestEnd = adapter.awaitStoppedRequestEnd + const waiting = vi + .spyOn(adapter, 'awaitStoppedRequestEnd') + .mockImplementation((sessionId, at) => { + const pending = requestEnd(sessionId, at) + reachedDeadline.resolve(undefined) + return pending.then(() => { + deadlineSettled = true + }) + }) + if (deadline === 'interrupt') { + claude.routes.interrupt = (params) => { + const pending = interrupt?.(params) + if (!(pending instanceof Promise)) { + throw new Error('Expected an unanswered interrupt promise') + } + void pending.then( + () => { + deadlineSettled = true + }, + () => { + deadlineSettled = true + } + ) + reachedDeadline.resolve(undefined) + return pending + } + } + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + const asked = Date.now() + const stopping = deps.stop(turnId) + void stopping.then((result) => { + if (!result.ok || !result.value.cancelled) { + reachedDeadline.resolve(result) + } + }, reachedDeadline.reject) + const early = await reachedDeadline.promise + if (early !== undefined) { + return early + } + await vi.advanceTimersByTimeAsync(CLAUDE_STOP_GRACE_MS - 1) + expect(deadlineSettled).toBe(false) + expect(connection.closed).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(deadlineSettled).toBe(true) + const result = await stopping + await deps.laneDrained() + expect(Date.now() - asked).toBeLessThan(CLAUDE_STOP_GRACE_MS + 1_500) + return result + } finally { + waiting.mockRestore() + if (interrupt) { + claude.routes.interrupt = interrupt + } + vi.useRealTimers() + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index fa600a229dc..629d7e71790 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -24,6 +24,7 @@ import { import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter, @@ -63,6 +64,8 @@ export type AgentSessionTurnContext = { providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined /** Who a Stop's refusal row names. */ failureTextContext?: AgentSessionFailureWordsContext + /** The operation's success, committed with the row that accepts it (`MutationPlan.settlesWithWrite`). */ + operationReceipt?: JournalOperationReceipt now: () => number } @@ -142,7 +145,11 @@ export async function performSend( } } try { - await ctx.journal.appendSubmission({ ...input, fence: ctx.fence, handoverRecorded: true }) + await ctx.journal.appendSubmission( + { ...input, fence: ctx.fence, handoverRecorded: true }, + undefined, + ctx.operationReceipt + ) } catch (error) { // Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. So // does a chat holding a newer Orca's rows, which only an update writes past, and a refusal the diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts index 3c7f71dbe1f..338bb494a4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts @@ -17,13 +17,15 @@ export class StructuredConversationCommandController { private readonly context: () => StructuredAgentSessionMutationContext, private readonly host: Pick ) {} + /** Whether a clear is in flight is read as the send arrives; it refuses only a first run, so an + * id with a recorded answer by the send's turn gets that answer, behind the clear. */ send = ( caller: StructuredAgentSessionCaller, params: Parameters[2] ): ReturnType => - this.pending.has(params.envelope.sessionId) - ? Promise.resolve({ ok: false, refusal: conversationCommandInFlight() }) - : sendStructuredAgentSessionTurn(this.context(), caller, params) + sendStructuredAgentSessionTurn(this.context(), caller, params, { + clearInFlight: this.pending.has(params.envelope.sessionId) + }) run = (caller: StructuredAgentSessionCaller, params: ConversationCommandParams) => { if (params.command === 'compact') { diff --git a/src/main/native-chat/transcript-opencode-subscribe.test.ts b/src/main/native-chat/transcript-opencode-subscribe.test.ts index b8e0fb01e78..cce4b243cf8 100644 --- a/src/main/native-chat/transcript-opencode-subscribe.test.ts +++ b/src/main/native-chat/transcript-opencode-subscribe.test.ts @@ -82,9 +82,21 @@ function watchFixture( db.prepare('DELETE FROM session_message WHERE id = ?').run(String(index)) } } - for (let index = 1; index <= messageCount; index++) { - insert(index, hiddenFirst && index === 1 ? '' : undefined) + const batch = (mutate: () => void) => { + db.exec('BEGIN') + try { + mutate() + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } } + batch(() => { + for (let index = 1; index <= messageCount; index++) { + insert(index, hiddenFirst && index === 1 ? '' : undefined) + } + }) let displayed: NativeChatMessage[] = [] const onReplace = vi.fn((messages: NativeChatMessage[]) => { displayed = messages @@ -115,7 +127,17 @@ function watchFixture( } ) fixtures.push({ db, root, stop: subscription.unsubscribe }) - return { db, insert, update, remove, displayed: () => displayed, onReplace, onAppend, readPage } + return { + db, + insert, + update, + remove, + batch, + displayed: () => displayed, + onReplace, + onAppend, + readPage + } } describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (version) => { @@ -123,8 +145,10 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio const f = watchFixture(version, false, 1000) await vi.advanceTimersByTimeAsync(0) expect(f.displayed()).toHaveLength(300) - f.remove(701) - f.insert(1001) + f.batch(() => { + f.remove(701) + f.insert(1001) + }) await vi.advanceTimersByTimeAsync(10) expect(f.displayed()).toHaveLength(300) expect(f.displayed()[0]?.blocks).toEqual([{ type: 'text', text: 'message 702' }]) @@ -170,9 +194,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio it('replaces an empty session and permits a reused provider cursor to append', async () => { const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) - for (let index = 1; index <= 300; index++) { - f.remove(index) - } + f.batch(() => { + for (let index = 1; index <= 300; index++) { + f.remove(index) + } + }) await vi.advanceTimersByTimeAsync(10) expect(f.displayed()).toEqual([]) f.insert(1, 'after revert') @@ -194,9 +220,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio it('continues checking previously appended history and bounds every read', async () => { const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) - for (let index = 301; index <= 450; index++) { - f.insert(index) - } + f.batch(() => { + for (let index = 301; index <= 450; index++) { + f.insert(index) + } + }) await vi.advanceTimersByTimeAsync(10) f.update(1, 'edited after append') await vi.advanceTimersByTimeAsync(10) @@ -210,9 +238,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio it('holds the frontier and retries when a burst cannot bridge within the read cap', async () => { const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) - for (let index = 301; index <= 2800; index++) { - f.insert(index) - } + f.batch(() => { + for (let index = 301; index <= 2800; index++) { + f.insert(index) + } + }) await vi.advanceTimersByTimeAsync(10) expect(f.displayed()).toHaveLength(300) expect(f.onReplace).not.toHaveBeenCalled() @@ -220,10 +250,12 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio await vi.advanceTimersByTimeAsync(10) expect(f.readPage.mock.calls.length).toBeGreaterThan(reads) expect(f.readPage.mock.calls.every(([args]) => args.limit <= 2400)).toBe(true) - for (let index = 301; index <= 2800; index++) { - f.remove(index) - } - f.insert(301, 'after discarded burst') + f.batch(() => { + for (let index = 301; index <= 2800; index++) { + f.remove(index) + } + f.insert(301, 'after discarded burst') + }) await vi.advanceTimersByTimeAsync(10) expect(f.displayed()).toHaveLength(301) expect(f.displayed().at(-1)?.blocks).toEqual([{ type: 'text', text: 'after discarded burst' }]) @@ -233,9 +265,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) for (let start = 301; start <= 2400; start += 300) { - for (let index = start; index < start + 300; index++) { - f.insert(index) - } + f.batch(() => { + for (let index = start; index < start + 300; index++) { + f.insert(index) + } + }) await vi.advanceTimersByTimeAsync(10) } f.onReplace.mockClear() @@ -253,9 +287,11 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio it('keeps appending after a bridged burst fills the cap', async () => { const f = watchFixture(version) await vi.advanceTimersByTimeAsync(0) - for (let index = 301; index <= 2600; index++) { - f.insert(index) - } + f.batch(() => { + for (let index = 301; index <= 2600; index++) { + f.insert(index) + } + }) await vi.advanceTimersByTimeAsync(10) expect(f.onReplace).toHaveBeenCalledOnce() expect(f.displayed()).toHaveLength(2400) @@ -272,13 +308,15 @@ describe.each(['v1', 'v2'] as const)('OpenCode %s watch reconciliation', (versio async (operation) => { const f = watchFixture(version, false, 2500, 2400) await vi.advanceTimersByTimeAsync(0) - if (operation === 'delete') { - f.remove(101) - } else { - f.update(101, operation === 'hide' ? '' : 'edited first tail row') - } - f.insert(2501) - f.insert(2502) + f.batch(() => { + if (operation === 'delete') { + f.remove(101) + } else { + f.update(101, operation === 'hide' ? '' : 'edited first tail row') + } + f.insert(2501) + f.insert(2502) + }) await vi.advanceTimersByTimeAsync(10) expect(f.onReplace).toHaveBeenCalledOnce() expect(f.onAppend).not.toHaveBeenCalled() diff --git a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts index cca4712e971..b99ee05ff97 100644 --- a/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-tui-ownership.test.ts @@ -31,6 +31,8 @@ type PluginModule = { default?: { setup?: (ctx: unknown) => Promise<(() => Promise) | undefined> } } +const { setTimeout: realSetTimeout, clearTimeout: realClearTimeout } = globalThis + const PANE_A = 'tabA:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' const PANE_B = 'tabB:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' const SES_A = 'ses_f161fd85fffeieT0zYt80ZKorS' @@ -85,6 +87,8 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { let allPosts: Post[] let failPosts: boolean let postDelayMs: number + const cleanups = new Set<() => Promise>() + const delayedPosts = new Set<() => void>() beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-tui-adapter-')) @@ -105,7 +109,15 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { globalThis.fetch = vi.fn(async (_input, init) => { const body = JSON.parse(String(init?.body)) if (postDelayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, postDelayMs)) + await new Promise((resolve) => { + const finish = (): void => { + clearTimeout(timer) + delayedPosts.delete(finish) + resolve() + } + const timer = setTimeout(finish, postDelayMs) + delayedPosts.add(finish) + }) } if (failPosts) { return new Response('{}', { status: 500 }) @@ -122,17 +134,47 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { }) }) - afterEach(() => { - globalThis.fetch = savedFetch - process.argv = savedArgv - for (const key of ENV_KEYS) { - if (savedEnv[key] === undefined) { - delete process.env[key] - } else { - process.env[key] = savedEnv[key] + afterEach(async () => { + let deadline: ReturnType | undefined + try { + postDelayMs = 0 + for (const finish of delayedPosts) { + finish() + } + const closing = Promise.allSettled([...cleanups].map((cleanup) => cleanup())) + expect( + await Promise.race([ + closing.then((results) => results.every((result) => result.status === 'fulfilled')), + new Promise((resolve) => { + deadline = realSetTimeout(() => resolve(false), 2000) + }) + ]) + ).toBe(true) + } finally { + realClearTimeout(deadline) + try { + if (vi.isFakeTimers()) { + expect(vi.getTimerCount()).toBe(0) + } + } finally { + if (vi.isFakeTimers()) { + vi.clearAllTimers() + } + vi.useRealTimers() + cleanups.clear() + delayedPosts.clear() + globalThis.fetch = savedFetch + process.argv = savedArgv + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) } } - rmSync(tempDir, { recursive: true, force: true }) }) async function loadPlugin(dir = tempDir): Promise { @@ -140,7 +182,35 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const pluginPath = join(dir, `orca-opencode-status-${Math.random().toString(36).slice(2)}.mjs`) writeFileSync(pluginPath, _internals.getOpenCodePluginSource()) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the generated module's default export is exercised below and fails the test if absent. - return (await import(pathToFileURL(pluginPath).href)) as PluginModule + const plugin = (await import(pathToFileURL(pluginPath).href)) as PluginModule + if (!vi.isFakeTimers()) { + vi.useFakeTimers({ + toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] + }) + } + const setup = plugin.default?.setup + return { + default: { + setup: async (ctx) => { + const cleanup = await setup?.(ctx) + if (!cleanup) { + return + } + const close = async (): Promise => { + try { + await cleanup() + if (vi.isFakeTimers()) { + expect(vi.getTimerCount()).toBe(0) + } + } finally { + cleanups.delete(close) + } + } + cleanups.add(close) + return close + } + } + } } const summary = (list: Post[]): string[] => @@ -156,14 +226,40 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const tui = fakeTui() const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx) await script(tui) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts.slice(start)).at(-1)).toBe(`SessionIdle:${ownSession}`) }) await cleanup?.() return posts.slice(start) } - const tick = (ms = 20): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + const tick = async (ms = 20): Promise => { + await (vi.isFakeTimers() + ? vi.advanceTimersByTimeAsync(ms) + : new Promise((resolve) => setTimeout(resolve, ms))) + } + const waitFor = async ( + check: () => void, + { timeout = 1000 }: { timeout?: number } = {} + ): Promise => { + if (!vi.isFakeTimers()) { + await vi.waitFor(check, { timeout }) + return + } + const deadline = Date.now() + timeout + await tick(0) + for (;;) { + try { + check() + return + } catch (error) { + if (Date.now() >= deadline) { + throw error + } + await tick(Math.min(50, deadline - Date.now())) + } + } + } const pump = async (tui: ReturnType, events: BusEvent[]): Promise => { for (const event of events) { tui.emit(event) @@ -237,7 +333,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { await pump(tui, b.start) expect(posts).toHaveLength(0) tui.navigate(SES_B) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts)).toContain(`SessionBusy:${SES_B}`) }) await pump(tui, b.finish) @@ -284,9 +380,10 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) await pump(tui, turn(SES_A, 'A').start) tui.loseEnd(SES_A) - await vi.waitFor(() => { - expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) - }) + await tick(99) + expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`) + await tick(1) + expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) await cleanup?.() }) @@ -301,7 +398,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { for (const event of [...a.start, ...a.finish]) { tui.emit(event) } - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) }) await tick(250) @@ -319,15 +416,15 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) const first = turn(SES_A, 'A') await pump(tui, [...first.start, ...first.finish]) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) }) tui.loseStart(SES_A) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`) }) tui.loseEnd(SES_A) - await vi.waitFor(() => { + await waitFor(() => { expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) }) await cleanup?.() @@ -360,7 +457,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) await pump(tui, [...turn(SES_A, 'root').start, ...childStart(SES_A)]) await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }]) - await vi.waitFor(() => { + await waitFor(() => { expect(posts.at(-1)?.payload).toMatchObject({ hook_event_name: 'SessionBusy', root_state: 'done', @@ -388,9 +485,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) await pump(tui, turn(SES_A, 'root').start) await pump(tui, [{ type: 'session.execution.failed', data: { sessionID: SES_A } }]) - await vi.waitFor(() => - expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError') - ) + await waitFor(() => expect(posts.at(-1)?.payload?.root_turn_error_name).toBe('UnknownError')) await first?.() const before = posts.length const second = await start(tui) @@ -414,7 +509,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { { type: finishType, data: { sessionID: SES_A, reason: 'user' } } ]) const second = await start(tui) - await vi.waitFor(() => expect(posts.at(-1)?.payload?.root_turn_error_name).toBeUndefined()) + await waitFor(() => expect(posts.at(-1)?.payload?.root_turn_error_name).toBeUndefined()) expect(posts.at(-1)?.payload).toMatchObject({ hook_event_name: 'SessionIdle', root_state: 'done' @@ -430,13 +525,13 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) const a = turn(SES_A, 'A') await pump(tui, a.start) - await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + await waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) const beforeReload = posts.length await firstGeneration?.() expect(posts).toHaveLength(beforeReload) await pump(tui, a.finish) const secondGeneration = await start(tui) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await secondGeneration?.() expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionIdle:${SES_A}`]) }) @@ -454,7 +549,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { await tick(150) expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) await pump(tui, a.finish) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await secondGeneration?.() }) @@ -468,7 +563,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { await firstGeneration?.() failPosts = false const secondGeneration = await start(tui) - await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + await waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) await secondGeneration?.() }) @@ -483,7 +578,11 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { for (const event of [...a.start, ...b.start, ...b.finish, ...a.finish]) { tui.emit(event) } - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`), { + await tick(119) + expect(allPosts).toEqual([]) + await tick(1) + expect(summary(allPosts)).toEqual(['SessionStart:undefined']) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`), { timeout: 3000 }) await tick(300) @@ -505,11 +604,11 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { it('lands one start boundary in a freshly started TUI, and none on a reload after Done', async () => { const tui = fakeTui() const firstGeneration = await start(tui) - await vi.waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) + await waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) tui.navigate(SES_A) const a = turn(SES_A, 'A') await pump(tui, [...a.start, ...a.finish]) - await vi.waitFor(() => expect(summary(allPosts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(allPosts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await firstGeneration?.() const secondGeneration = await start(tui) await tick(250) @@ -524,7 +623,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { it('lands the start boundary only once across reloads of an idle pane', async () => { const tui = fakeTui() const firstGeneration = await start(tui) - await vi.waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) + await waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined'])) await firstGeneration?.() const secondGeneration = await start(tui) await tick(150) @@ -536,7 +635,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const tui = fakeTui() tui.navigate(SES_B) const cleanup = await start(tui) - await vi.waitFor(() => expect(summary(allPosts)).toEqual([`SessionStart:${SES_B}`])) + await waitFor(() => expect(summary(allPosts)).toEqual([`SessionStart:${SES_B}`])) await cleanup?.() }) @@ -545,7 +644,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) tui.loseStart(SES_A) const cleanup = await start(tui) - await vi.waitFor(() => expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`])) + await waitFor(() => expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`])) await tick(150) await cleanup?.() expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`]) @@ -571,7 +670,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`]) expect(posts.at(-1)?.payload).toMatchObject({ root_state: 'done' }) tui.loseEnd(SES_CHILD) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await cleanup?.() expect(posts.every((post) => post.payload?.sessionID === SES_A)).toBe(true) }) @@ -603,14 +702,19 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const cleanup = await start(tui) tui.navigate(SES_A) const a = turn(SES_A, 'A') - await pump(tui, [...a.start, ...childStart(SES_A), permission(SES_CHILD)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await pump(tui, [...a.start, ...childStart(SES_A)]) + await tick(60) + await pump(tui, [permission(SES_CHILD)]) + await tick(479) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) + await tick(1) + expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`) await pump(tui, [ { type: 'permission.replied', data: { sessionID: SES_CHILD, requestID: 'per_1' } }, { type: 'session.execution.succeeded', data: { sessionID: SES_CHILD } }, ...a.finish ]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await cleanup?.() expect(statuses(posts)).toEqual([ `SessionBusy:${SES_A}`, @@ -627,7 +731,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) const a = turn(SES_A, 'A spawns a background task') await pump(tui, [...a.start, ...childStart(SES_A), permission(SES_CHILD)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) await pump(tui, a.finish) await tick(300) expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`) @@ -639,11 +743,11 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const cleanup = await start(tui) tui.navigate(SES_A) await pump(tui, [...turn(SES_A, 'A').start, permission(SES_A)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) // The user browses away; the reply lands while this TUI is disconnected. tui.navigate(SES_B) await pump(tui, [{ type: 'server.connected', data: {} }]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`)) await cleanup?.() }) @@ -652,7 +756,7 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { const cleanup = await start(tui) tui.navigate(SES_A) await pump(tui, [...turn(SES_A, 'A').start, permission(SES_A)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) const release = tui.holdPermissionFetch() tui.serverPermissions.set(SES_A, [...(tui.permissions.get(SES_A) ?? [])]) await pump(tui, [ @@ -668,26 +772,22 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { // Why: an Orca restart moves the hook endpoint while the pane's level stays the same. it('re-posts the current level once the hook endpoint moves', async () => { - vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) - try { - const tui = fakeTui() - const cleanup = await start(tui) - tui.navigate(SES_A) - await pump(tui, turn(SES_A, 'A').start) - vi.advanceTimersByTime(200) - await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) - process.env.ORCA_AGENT_HOOK_PORT = '59998' - vi.advanceTimersByTime(5000) - await vi.waitFor(() => - expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`]) - ) - vi.advanceTimersByTime(5000) - await tick(50) - expect(statuses(posts)).toHaveLength(2) - await cleanup?.() - } finally { - vi.useRealTimers() - } + const tui = fakeTui() + const cleanup = await start(tui) + tui.navigate(SES_A) + await pump(tui, turn(SES_A, 'A').start) + await tick(200) + await waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])) + process.env.ORCA_AGENT_HOOK_PORT = '59998' + await tick(4699) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]) + await tick(1) + expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`]) + await tick(300) + await tick(5000) + await tick(50) + expect(statuses(posts)).toHaveLength(2) + await cleanup?.() }) it('does not pin Needs input on a request the data kept after its turn ended', async () => { @@ -696,9 +796,9 @@ describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => { tui.navigate(SES_A) const a = turn(SES_A, 'A') await pump(tui, [...a.start, permission(SES_A)]) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)) await pump(tui, a.finish) - await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) + await waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)) await tick(250) expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`) await cleanup?.() diff --git a/src/main/opencode/hook-service-overlay-confinement.test.ts b/src/main/opencode/hook-service-overlay-confinement.test.ts new file mode 100644 index 00000000000..46a5bee5884 --- /dev/null +++ b/src/main/opencode/hook-service-overlay-confinement.test.ts @@ -0,0 +1,98 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { safeRemoveTree } from '../pty/overlay-mirror' +import { OpenCodeHookService } from './hook-service' +import { OPENCODE_OVERLAY_MANIFEST_FILE } from './opencode-overlay-manifest' + +let root: string +let source: string +let service: OpenCodeHookService + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-overlay-confinement-')) + source = join(root, 'source') + mkdirSync(join(source, 'plugins'), { recursive: true }) + writeFileSync(join(source, 'plugins', 'user.js'), 'export default {}') + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'xdg')) + setAppEnvironment({ + getPath: () => join(root, 'profile'), + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + service = new OpenCodeHookService({ + pluginFileName: 'orca-test.js', + legacyHooksDir: 'legacy-test', + overlayDir: 'overlay-test', + pluginSource: () => 'export default {}' + }) +}) + +afterEach(() => { + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) +}) + +it.each(['overlay-root', 'source-overlay', 'plugins'] as const)( + 'preserves outside files when the owned %s is replaced by a directory link', + (boundary) => { + const overlay = service.buildPtyEnv('pane-1', source).OPENCODE_CONFIG_DIR + if (!overlay) { + throw new Error('Expected an isolated overlay') + } + const outside = join(root, 'outside') + const externalOverlay = boundary === 'overlay-root' ? join(outside, basename(overlay)) : outside + mkdirSync(join(externalOverlay, 'plugins'), { recursive: true }) + const sentinel = join(externalOverlay, 'plugins', 'keep.js') + writeFileSync(sentinel, 'export const keep = true') + writeFileSync( + join(externalOverlay, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: ['keep.js'] }) + ) + const replaced = + boundary === 'overlay-root' + ? dirname(overlay) + : boundary === 'source-overlay' + ? overlay + : join(overlay, 'plugins') + const target = boundary === 'plugins' ? join(outside, 'plugins') : outside + if (boundary === 'plugins') { + writeFileSync( + join(overlay, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: ['keep.js'] }) + ) + } + safeRemoveTree(replaced) + symlinkSync(target, replaced, process.platform === 'win32' ? 'junction' : 'dir') + const before = readdirSync(outside, { recursive: true }).toSorted() + + const result = service.buildPtyEnv('pane-2', source) + expect(readFileSync(sentinel, 'utf8')).toBe('export const keep = true') + expect(readdirSync(outside, { recursive: true }).toSorted()).toEqual(before) + expect(result).toEqual({ OPENCODE_CONFIG_DIR: source }) + } +) + +it('still removes a stale mirrored entry from a real owned overlay', () => { + const overlay = service.buildPtyEnv('pane-1', source).OPENCODE_CONFIG_DIR + if (!overlay) { + throw new Error('Expected an isolated overlay') + } + rmSync(join(source, 'plugins', 'user.js')) + expect(service.buildPtyEnv('pane-2', source)).toEqual({ OPENCODE_CONFIG_DIR: overlay }) + expect(readdirSync(join(overlay, 'plugins'))).toEqual(['orca-test.js']) +}) diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 41f65243ab9..7a6d137ebfa 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -2,51 +2,48 @@ import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { existsSync, + lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, - statSync, - writeFileSync + statSync } from 'node:fs' -import { createHash } from 'node:crypto' -import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' +import { isSafeDescendCandidate, mirrorEntry } from '../pty/overlay-mirror' import { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } from './status-plugin-module-source' +import { + readOpenCodeOverlayManifest, + clearOpenCodeOverlayManifestEntries, + writeOpenCodeOverlayManifest, + type OpenCodeOverlayManifest +} from './opencode-overlay-manifest' import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' import { getOpenCodeLegacySharedConfigDir, OPENCODE2_LEGACY_HOOKS_DIR, OPENCODE_LEGACY_HOOKS_DIR } from './legacy-shared-config-dir' -import { - isInstalledOpenCodePluginCurrent, - isOverlayOpenCodePluginCurrent -} from '../../shared/opencode-installed-plugin' import { openCodeTuiPluginDirName, writeOpenCodeTuiPlugin } from '../../shared/opencode-tui-plugin-install' import { writeLegacyOpenCodePluginWithAclRetry } from './legacy-plugin-acl-retry' +import { + OPENCODE_OVERLAY_DIR, + ORCA_OPENCODE_PLUGIN_FILE, + sourceOverlayDirName, + toSafeDirName +} from './overlay-dir-names' +import { OpenCodeDirGcLifecycle } from './overlay-dir-gc-lifecycle' export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } -import { - writeCanonicalOpenCodePluginAtomically, - writeOverlayOpenCodePluginAtomically -} from '../../shared/opencode-plugin-atomic-write' - -const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' -const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' -const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' - -type OpenCodeOverlayManifest = { - topLevelEntries: string[] - pluginEntries: string[] -} +import { writeCanonicalOpenCodePluginAtomically } from '../../shared/opencode-plugin-atomic-write' +import { writeOpenCodePluginConfig } from './opencode-plugin-config-writer' type OpenCodeHookVariant = { pluginFileName: string @@ -55,6 +52,7 @@ type OpenCodeHookVariant = { pluginSource: () => string /** Also install the module as an OpenCode 2 TUI plugin (never for forks without one). */ installsTuiPlugin?: boolean + tuiOnlyDirectory?: string } // Why: session IDs may contain path separators and are hashed downstream; cap pathological input. @@ -62,11 +60,6 @@ function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } -function toSafeDirName(id: string): string { - // Why: 32 hex chars (128 bits) makes collisions negligible and stays filesystem-portable (no base64 padding or `/`). - return createHash('sha256').update(id).digest('hex').slice(0, 32) -} - // Why: installs the plugin into OpenCode's config discovery path so it POSTs to the shared agent-hooks server, unifying OpenCode status with Claude/Codex/Gemini. export class OpenCodeHookService { private readonly pluginSource: () => string @@ -74,6 +67,8 @@ export class OpenCodeHookService { private readonly legacyHooksDir: string private readonly overlayDir: string private readonly installsTuiPlugin: boolean + private readonly tuiOnlyDirectory: string | undefined + readonly configDirGc: OpenCodeDirGcLifecycle constructor(variant?: OpenCodeHookVariant | (() => string)) { const config: OpenCodeHookVariant = @@ -93,15 +88,39 @@ export class OpenCodeHookService { }) this.pluginSource = config.pluginSource this.installsTuiPlugin = config.installsTuiPlugin === true + this.tuiOnlyDirectory = config.tuiOnlyDirectory this.pluginFileName = config.pluginFileName this.legacyHooksDir = config.legacyHooksDir this.overlayDir = config.overlayDir + this.configDirGc = new OpenCodeDirGcLifecycle(() => this.getOverlayRoot(), this.pluginFileName) } clearPty(_ptyId: string): void { // Why: no-op — config dirs are app/source-scoped now, and recursive delete on the main-process hot path could freeze on Windows. } + installIntoSourceOverlay( + directory: string, + sourceConfigDir: string, + owner: OpenCodeHookService + ): 'unmatched' | 'installed' | 'failed' { + if (directory !== owner.getSourceOverlayDir(sourceConfigDir)) { + return 'unmatched' + } + try { + for (const path of [owner.getOverlayRoot(), directory, join(directory, 'plugins')]) { + if (!isSafeDescendCandidate(lstatSync(path))) { + return 'failed' + } + } + this.writePluginIntoOverlay(directory) + owner.configDirGc.reference(directory) + return 'installed' + } catch { + return 'failed' + } + } + buildPtyEnv(ptyId: string, existingConfigDir?: string | undefined): Record { if (!isUsableId(ptyId)) { // Why: on a bad id, still preserve a user-set OPENCODE_CONFIG_DIR; only the Orca status plugin is forfeited. @@ -118,14 +137,23 @@ export class OpenCodeHookService { return {} } } - if (!existsSync(existingConfigDir)) { + if (!existsSync(existingConfigDir) && !this.tuiOnlyDirectory) { return { OPENCODE_CONFIG_DIR: existingConfigDir } } const overlayDir = this.getSourceOverlayDir(existingConfigDir) try { - mkdirSync(overlayDir, { recursive: true }) - this.mirrorUserConfig(existingConfigDir, overlayDir) + // Owned directories must stay real; a replaced parent redirects both cleanup and writes. + for (const directory of [this.getOverlayRoot(), overlayDir, join(overlayDir, 'plugins')]) { + mkdirSync(directory, { recursive: true }) + if (!isSafeDescendCandidate(lstatSync(directory))) { + return { OPENCODE_CONFIG_DIR: existingConfigDir } + } + } + if (existsSync(existingConfigDir)) { + this.mirrorUserConfig(existingConfigDir, overlayDir) + } this.writePluginIntoOverlay(overlayDir) + this.configDirGc.reference(overlayDir) return { OPENCODE_CONFIG_DIR: overlayDir } } catch { return { OPENCODE_CONFIG_DIR: existingConfigDir } @@ -135,6 +163,9 @@ export class OpenCodeHookService { // Why: pre-1.4.209 Orca left a server()-only plugin here that OpenCode 2 rejects. Only helps // processes that load it later; a running OpenCode 2 service keeps its cached module until restarted. refreshLegacySharedPlugin(): void { + if (this.tuiOnlyDirectory) { + return + } const pluginsDir = join(this.getSharedConfigDir(), 'plugins') const pluginPath = join(pluginsDir, this.pluginFileName) try { @@ -188,7 +219,7 @@ export class OpenCodeHookService { } private getSourceOverlayDir(sourceConfigDir: string): string { - return join(this.getOverlayRoot(), toSafeDirName(`source:${sourceConfigDir}`)) + return join(this.getOverlayRoot(), sourceOverlayDirName(sourceConfigDir)) } private getSharedConfigDir(): string { @@ -198,48 +229,17 @@ export class OpenCodeHookService { ) } - private readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { - try { - const parsed = JSON.parse( - readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') - ) as Partial - return { - topLevelEntries: Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries : [], - pluginEntries: Array.isArray(parsed.pluginEntries) ? parsed.pluginEntries : [] - } - } catch { - return { topLevelEntries: [], pluginEntries: [] } - } - } - - private writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void { - writeFileSync( - join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), - `${JSON.stringify(manifest, null, 2)}\n` - ) - } - - private clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void { - for (const entryName of manifest.topLevelEntries) { - safeRemoveTree(join(overlayDir, entryName)) - } - - const overlayPluginsDir = join(overlayDir, 'plugins') - for (const entryName of manifest.pluginEntries) { - if (entryName === this.pluginFileName) { - continue - } - safeRemoveTree(join(overlayPluginsDir, entryName)) - } - } - // Why: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file. private mirrorUserConfig(sourceDir: string, overlayDir: string): void { - const previousManifest = this.readOverlayManifest(overlayDir) + const previousManifest = readOpenCodeOverlayManifest(overlayDir) // Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (node_modules) survive. - this.clearManifestEntries(overlayDir, previousManifest) + clearOpenCodeOverlayManifestEntries(overlayDir, previousManifest, this.pluginFileName) - const nextManifest: OpenCodeOverlayManifest = { topLevelEntries: [], pluginEntries: [] } + const nextManifest: OpenCodeOverlayManifest = { + topLevelEntries: [], + pluginEntries: [], + sourceConfigDir: sourceDir + } for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { const sourcePath = join(sourceDir, entry.name) @@ -266,6 +266,7 @@ export class OpenCodeHookService { // Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file. if ( pluginEntry.name === this.pluginFileName || + pluginEntry.name === this.tuiOnlyDirectory || (this.installsTuiPlugin && pluginEntry.name === openCodeTuiPluginDirName(this.pluginFileName)) ) { @@ -285,30 +286,26 @@ export class OpenCodeHookService { nextManifest.topLevelEntries.push(entry.name) } - this.writeOverlayManifest(overlayDir, nextManifest) + writeOpenCodeOverlayManifest(overlayDir, nextManifest) } - // Atomic replacement detaches mirrored links without touching user plugins. private writePluginIntoOverlay(overlayDir: string): void { - const pluginsDir = join(overlayDir, 'plugins') - mkdirSync(pluginsDir, { recursive: true }) - const pluginPath = join(pluginsDir, this.pluginFileName) - const source = this.pluginSource() - this.writeTuiPlugin(pluginsDir, source, 'overlay') - if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) { - writeOverlayOpenCodePluginAtomically(pluginPath, source) - } + this.writePluginToDirectory(overlayDir, 'overlay') } private writePluginToConfigDir(configDir: string): void { - const pluginsDir = join(configDir, 'plugins') - mkdirSync(pluginsDir, { recursive: true }) - const pluginPath = join(pluginsDir, this.pluginFileName) - const source = this.pluginSource() - this.writeTuiPlugin(pluginsDir, source) - if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { - writeCanonicalOpenCodePluginAtomically(pluginPath, source) - } + this.writePluginToDirectory(configDir, 'canonical') + } + + private writePluginToDirectory(configDir: string, ownership: 'canonical' | 'overlay'): void { + writeOpenCodePluginConfig({ + configDir, + ownership, + pluginFileName: this.pluginFileName, + getSource: () => this.pluginSource(), + installsTuiPlugin: this.installsTuiPlugin, + tuiOnlyDirectory: this.tuiOnlyDirectory + }) } private writeTuiPlugin( diff --git a/src/main/opencode/opencode-overlay-manifest.test.ts b/src/main/opencode/opencode-overlay-manifest.test.ts new file mode 100644 index 00000000000..65c2c765bbb --- /dev/null +++ b/src/main/opencode/opencode-overlay-manifest.test.ts @@ -0,0 +1,61 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { + readOpenCodeOverlayManifest, + OPENCODE_OVERLAY_MANIFEST_FILE +} from './opencode-overlay-manifest' + +it('accepts only string manifest entries and tolerates invalid persisted shapes', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-overlay-manifest-')) + try { + const path = join(root, OPENCODE_OVERLAY_MANIFEST_FILE) + for (const text of ['null', '1', '{']) { + writeFileSync(path, text) + expect(readOpenCodeOverlayManifest(root)).toEqual({ topLevelEntries: [], pluginEntries: [] }) + } + writeFileSync( + path, + JSON.stringify({ + topLevelEntries: ['valid', 1, null], + pluginEntries: [{ bad: true }, 'plugin.js'] + }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ + topLevelEntries: ['valid'], + pluginEntries: ['plugin.js'] + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it('keeps source ownership optional for older manifests and rejects non-string metadata', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-overlay-source-manifest-')) + try { + const file = join(root, OPENCODE_OVERLAY_MANIFEST_FILE) + for (const source of [undefined, null, 42, {}]) { + writeFileSync( + file, + JSON.stringify({ topLevelEntries: [], pluginEntries: [], sourceConfigDir: source }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ topLevelEntries: [], pluginEntries: [] }) + } + writeFileSync( + file, + JSON.stringify({ + topLevelEntries: ['opencode.json'], + pluginEntries: [], + sourceConfigDir: '/owned/source' + }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ + topLevelEntries: ['opencode.json'], + pluginEntries: [], + sourceConfigDir: '/owned/source' + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/opencode/opencode-overlay-manifest.ts b/src/main/opencode/opencode-overlay-manifest.ts new file mode 100644 index 00000000000..a256c14f8ca --- /dev/null +++ b/src/main/opencode/opencode-overlay-manifest.ts @@ -0,0 +1,64 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { safeRemoveOverlay } from '../pty/overlay-mirror' + +export const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' +export type OpenCodeOverlayManifest = { + topLevelEntries: string[] + pluginEntries: string[] + sourceConfigDir?: string +} + +export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { + const empty = { topLevelEntries: [], pluginEntries: [] } + try { + const parsed: unknown = JSON.parse( + readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') + ) + if (!parsed || typeof parsed !== 'object') { + return empty + } + return { + ...('sourceConfigDir' in parsed && typeof parsed.sourceConfigDir === 'string' + ? { sourceConfigDir: parsed.sourceConfigDir } + : {}), + topLevelEntries: + 'topLevelEntries' in parsed && Array.isArray(parsed.topLevelEntries) + ? parsed.topLevelEntries.filter((entry): entry is string => typeof entry === 'string') + : [], + pluginEntries: + 'pluginEntries' in parsed && Array.isArray(parsed.pluginEntries) + ? parsed.pluginEntries.filter((entry): entry is string => typeof entry === 'string') + : [] + } + } catch { + return empty + } +} + +export function writeOpenCodeOverlayManifest( + overlayDir: string, + manifest: OpenCodeOverlayManifest +): void { + writeFileSync( + join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), + `${JSON.stringify(manifest, null, 2)}\n` + ) +} + +export function clearOpenCodeOverlayManifestEntries( + overlayDir: string, + manifest: OpenCodeOverlayManifest, + pluginFileName: string +): void { + for (const entryName of manifest.topLevelEntries) { + safeRemoveOverlay(join(overlayDir, entryName), overlayDir) + } + const overlayPluginsDir = join(overlayDir, 'plugins') + for (const entryName of manifest.pluginEntries) { + if (entryName === pluginFileName) { + continue + } + safeRemoveOverlay(join(overlayPluginsDir, entryName), overlayPluginsDir) + } +} diff --git a/src/main/opencode/opencode-plugin-config-writer.ts b/src/main/opencode/opencode-plugin-config-writer.ts new file mode 100644 index 00000000000..98cfe1e7088 --- /dev/null +++ b/src/main/opencode/opencode-plugin-config-writer.ts @@ -0,0 +1,45 @@ +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { + isInstalledOpenCodePluginCurrent, + isOverlayOpenCodePluginCurrent +} from '../../shared/opencode-installed-plugin' +import { + writeCanonicalOpenCodePluginAtomically, + writeOverlayOpenCodePluginAtomically +} from '../../shared/opencode-plugin-atomic-write' +import { + writeOpenCodeTuiPlugin, + writeOpenCodeTuiPluginDirectory +} from '../../shared/opencode-tui-plugin-install' + +export function writeOpenCodePluginConfig(options: { + configDir: string + pluginFileName: string + getSource: () => string + installsTuiPlugin: boolean + tuiOnlyDirectory: string | undefined + ownership: 'canonical' | 'overlay' +}): void { + const pluginsDir = join(options.configDir, 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + const pluginPath = join(pluginsDir, options.pluginFileName) + const source = options.getSource() + if (options.tuiOnlyDirectory) { + writeOpenCodeTuiPluginDirectory(pluginsDir, options.tuiOnlyDirectory, source, options.ownership) + return + } + if (options.installsTuiPlugin) { + writeOpenCodeTuiPlugin(pluginsDir, options.pluginFileName, source, options.ownership) + } + const overlay = options.ownership === 'overlay' + const current = overlay + ? isOverlayOpenCodePluginCurrent(pluginPath, source) + : isInstalledOpenCodePluginCurrent(pluginPath, source) + if (!current) { + const write = overlay + ? writeOverlayOpenCodePluginAtomically + : writeCanonicalOpenCodePluginAtomically + write(pluginPath, source) + } +} diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts index f7813ddc969..854882d287f 100644 --- a/src/main/opencode/opencode-pty-launch.test.ts +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -1,6 +1,12 @@ +import { createHash } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../../shared/opencode-startup-prompt' import { buildLocalPtySpawnEnvironment, enforceLocalPtySpawnEnvironmentOverrides @@ -28,20 +34,195 @@ const plan: LocalPtyLaunchPlan = { launchWslDistro: null } +const hookServer = vi.hoisted(() => { + const server: { endpointFilePath: string | null } = { endpointFilePath: '/private/endpoint.env' } + return server +}) +vi.mock('../agent-hooks/server', () => ({ agentHookServer: hookServer })) +const reserve = vi.hoisted(() => vi.fn(() => true)) +vi.mock('./opencode-startup-prompt-owner', () => ({ reserveOpenCodeStartupPrompt: reserve })) + +async function prepare(options: Parameters[0]) { + return (await prepareOpenCodePtyLaunch(options)).env +} + const probe = vi.hoisted(() => vi.fn()) +const install = vi.hoisted(() => vi.fn()) +vi.mock('./opencode-startup-prompt-installer', () => ({ + installOpenCodeStartupPromptForLaunch: install +})) vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) -beforeEach(() => probe.mockReset()) +beforeEach(() => { + probe.mockReset() + reserve.mockReset().mockReturnValue(true) + install.mockReset() + hookServer.endpointFilePath = '/private/endpoint.env' +}) afterEach(() => vi.unstubAllEnvs()) describe('execution-host OpenCode launch preparation', () => { + it('retains fresh owned source provenance through the final provider deletion pass', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + install.mockImplementation((env) => { + env.OPENCODE_CONFIG_DIR = '/private/owned-overlay' + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = '/private/real-source' + return true + }) + const envToDelete = ['OPENCODE_CONFIG_DIR', 'ORCA_OPENCODE_SOURCE_CONFIG_DIR'] + const env = await prepare({ + command: 'opencode --prompt task', + isFreshLaunch: true, + envToDelete, + env: { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + ORCA_OPENCODE_STARTUP_PROMPT_SHA256: createHash('sha256').update('task').digest('hex'), + ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'task', + ORCA_OPENCODE_STARTUP_PROMPT_SHELL: 'posix' + } + }) + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'source-proof', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv.OPENCODE_CONFIG_DIR).toBe('/private/owned-overlay') + expect(finalEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/private/real-source') + }) + it('removes only the automatic verified v2 prompt argument, retaining explicit run and manual flags', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + const env = { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + const options = { env, envToDelete: [], isFreshLaunch: true } + expect( + ( + await prepareOpenCodePtyLaunch({ + ...options, + command: "opencode --standalone --prompt 'task'" + }) + ).command + ).toBe('opencode --standalone') + expect( + (await prepareOpenCodePtyLaunch({ ...options, command: "opencode run --prompt 'task'" })) + .command + ).toBe("opencode run --prompt 'task'") + expect( + (await prepareOpenCodePtyLaunch({ ...options, env: {}, command: "opencode --prompt 'task'" })) + .command + ).toBe("opencode --prompt 'task'") + }) + it.each(['inherited', 'explicit', 'deleted'] as const)( + 'passes the %s config environment used by the execution-host version probe to the prompt installer', + async (selection) => { + vi.stubEnv('XDG_CONFIG_HOME', '/ambient/config') + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + await prepareOpenCodePtyLaunch({ + command: 'opencode --prompt task', + envToDelete: selection === 'deleted' ? ['XDG_CONFIG_HOME'] : [], + isFreshLaunch: true, + env: { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix', + ...(selection === 'explicit' ? { XDG_CONFIG_HOME: '/selected/config' } : {}) + } + }) + expect(install).toHaveBeenCalledTimes(1) + const resolved = install.mock.calls[0][2] + expect(resolved).toEqual(probe.mock.calls[0][0].env) + expect(resolved.XDG_CONFIG_HOME).toBe( + selection === 'deleted' + ? undefined + : selection === 'explicit' + ? '/selected/config' + : '/ambient/config' + ) + } + ) + + it.each(['endpoint', 'installer', 'capacity', 'identity'])( + 'keeps the editable brief when automatic preparation lacks %s', + async (failure) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + if (failure === 'endpoint') { + hookServer.endpointFilePath = null + } + if (failure === 'installer') { + install.mockImplementation((env) => { + delete env.ORCA_OPENCODE_STARTUP_PROMPT_NONCE + }) + } + if (failure === 'capacity') { + reserve.mockReturnValue(false) + } + const original = "opencode --standalone --prompt 'task'" + const result = await prepareOpenCodePtyLaunch({ + command: original, + envToDelete: [], + isFreshLaunch: true, + env: { + ...(failure === 'identity' ? {} : { ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch' }), + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + }) + expect(result.command).toBe(original) + expect(result.env).not.toHaveProperty('ORCA_OPENCODE_STARTUP_PROMPT_NONCE') + } + ) + it.each(['1.1.23', '2.0.16', '2.0.17', 'unknown'])( + 'gates native intent against the executing %s capability', + async (version) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities(version)) + const envToDelete: string[] = [] + const fingerprint = createHash('sha256').update('task').digest('hex') + const env = await prepare({ + command: 'opencode --prompt task', + env: { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint, + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + }, + envToDelete, + isFreshLaunch: true + }) + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBe( + version === '2.0.16' ? fingerprint : undefined + ) + expect(envToDelete.includes(OPENCODE_STARTUP_PROMPT_SHA256_ENV)).toBe(version !== '2.0.16') + } + ) + + it('refuses remote automatic intent without execution-owned driving input', async () => { + const fingerprint = 'b'.repeat(64) + const envToDelete: string[] = [] + const env = await prepare({ + command: 'opencode --prompt task', + connectionId: 'remote', + isFreshLaunch: true, + env: { [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint }, + envToDelete + }) + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBeUndefined() + expect(envToDelete).toContain(OPENCODE_STARTUP_PROMPT_SHA256_ENV) + expect(probe).not.toHaveBeenCalled() + }) it('keeps deleted credentials and config absent from the probe and final provider environment', async () => { vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key') vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config') vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') probe.mockResolvedValue(getOpenCodeCliCapabilities(null)) const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR'] - const env = await prepareOpenCodePtyLaunch({ + const env = await prepare({ command: 'opencode', env: {}, envToDelete, @@ -67,7 +248,7 @@ describe('execution-host OpenCode launch preparation', () => { vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API'] - const env = await prepareOpenCodePtyLaunch({ + const env = await prepare({ command: 'opencode', env: {}, envToDelete, @@ -94,7 +275,7 @@ describe('execution-host OpenCode launch preparation', () => { KEEP: '1', ORCA_OPENCODE_PLUGIN_API: 'stale' } - const result = await prepareOpenCodePtyLaunch({ + const result = await prepare({ command: 'opencode --prompt test', agent: 'opencode', env, @@ -117,7 +298,7 @@ describe('execution-host OpenCode launch preparation', () => { it('creates a launch environment for a known binary without caller env', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) expect( - await prepareOpenCodePtyLaunch({ + await prepare({ command: 'opencode', env: undefined, envToDelete: [], @@ -129,7 +310,7 @@ describe('execution-host OpenCode launch preparation', () => { it('forwards WSL plugin selection through WSLENV after a guest probe', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23')) const env = { KEEP: '1' } - const result = await prepareOpenCodePtyLaunch({ + const result = await prepare({ command: 'opencode', agent: 'opencode', env, @@ -148,9 +329,7 @@ describe('execution-host OpenCode launch preparation', () => { 'never probes the client for an attach or SSH launch', async (route) => { const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } - expect( - await prepareOpenCodePtyLaunch({ command: 'opencode', env, envToDelete: [], ...route }) - ).toEqual({}) + expect(await prepare({ command: 'opencode', env, envToDelete: [], ...route })).toEqual({}) expect(probe).not.toHaveBeenCalled() expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) } diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts index 5aa5adab072..bf7d8bced1f 100644 --- a/src/main/opencode/opencode-pty-launch.ts +++ b/src/main/opencode/opencode-pty-launch.ts @@ -1,7 +1,29 @@ import { addWslEnvKeys } from '../../shared/wsl-env' import type { TuiAgent } from '../../shared/tui-agent' +import { randomUUID, createHash } from 'node:crypto' +import { agentHookServer } from '../agent-hooks/server' +import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' +import { isOpenCodeRunCommand } from '../../shared/opencode-headless-command' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../../shared/opencode-startup-prompt' + +const intentKeys = [ + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +] + import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' +import { reserveOpenCodeStartupPrompt } from './opencode-startup-prompt-owner' +import { installOpenCodeStartupPromptForLaunch } from './opencode-startup-prompt-installer' export async function prepareOpenCodePtyLaunch(options: { command: string | undefined @@ -12,17 +34,29 @@ export async function prepareOpenCodePtyLaunch(options: { connectionId?: string | null isFreshLaunch: boolean wsl?: { distro?: string } -}): Promise | undefined> { +}): Promise<{ env: Record | undefined; command: string | undefined }> { + let command = options.command const env = options.env ? { ...options.env } : undefined + const requestedPrompt = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV] + const body = env?.[OPENCODE_STARTUP_PROMPT_BODY_ENV] + const shell = env?.[OPENCODE_STARTUP_PROMPT_SHELL_ENV] if (env) { delete env.ORCA_OPENCODE_PLUGIN_API + for (const key of intentKeys) { + delete env[key] + } } // Providers merge their own ambient environment after this preparation. if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) { options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API') } + for (const key of intentKeys) { + if (!options.envToDelete.includes(key)) { + options.envToDelete.push(key) + } + } if (options.connectionId || !options.isFreshLaunch) { - return env + return { env, command } } const probeEnv: Record = {} for (const [key, value] of Object.entries({ ...process.env, ...env })) { @@ -36,12 +70,70 @@ export async function prepareOpenCodePtyLaunch(options: { env: probeEnv }) if (!capabilities || capabilities.pluginApi === 'unknown') { - return env + return { env, command } + } + const launchEnv: Record = { + ...env, + ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } - const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) + if ( + capabilities.promptMode === 'prefill' && + !options.wsl && + launchEnv.ORCA_AGENT_LAUNCH_TOKEN && + requestedPrompt && + body && + command && + (shell === 'posix' || shell === 'powershell' || shell === 'cmd') && + createHash('sha256').update(body).digest('hex') === requestedPrompt + ) { + const parsed = tokenizeStartupCommand(command, shell) + const last = parsed.ok ? parsed.tokens.length - 1 : -1 + if ( + parsed.ok && + !isOpenCodeRunCommand(parsed.tokens, shell) && + parsed.tokens.filter((token) => token === '--prompt').length === 1 && + parsed.tokens[last - 1] === '--prompt' && + parsed.tokens[last] === body && + !parsed.spans[last].divergesFromShell && + !parsed.spans[last - 1].divergesFromShell + ) { + const endpoint = agentHookServer.endpointFilePath + if (endpoint) { + launchEnv[OPENCODE_STARTUP_PROMPT_SHA256_ENV] = requestedPrompt + launchEnv[OPENCODE_STARTUP_PROMPT_BODY_ENV] = body + launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] = randomUUID() + launchEnv[OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV] = endpoint + if (installOpenCodeStartupPromptForLaunch(launchEnv, false, probeEnv)) { + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + ]) { + const deletion = options.envToDelete.indexOf(key) + if (launchEnv[key] && deletion !== -1) { + options.envToDelete.splice(deletion, 1) + } + } + } + const nonce = launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (nonce && reserveOpenCodeStartupPrompt(nonce, requestedPrompt)) { + command = command.slice(0, parsed.spans[last - 1].start).trimEnd() + } else { + for (const key of intentKeys) { + delete launchEnv[key] + } + } + for (const key of intentKeys) { + if (launchEnv[key]) { + options.envToDelete.splice(options.envToDelete.indexOf(key), 1) + } + } + } + } + } if (options.wsl) { addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) } - return launchEnv + return { env: launchEnv, command } } diff --git a/src/main/opencode/opencode-startup-prompt-claims.test.ts b/src/main/opencode/opencode-startup-prompt-claims.test.ts new file mode 100644 index 00000000000..95f2f51fcbd --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-claims.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalRunFacts } from '../runtime/terminal-run-facts' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' + +describe('execution-owned startup prompt claims', () => { + it('consumes each nonce once and checks owner facts at claim time', () => { + const claims = new OpenCodeStartupPromptClaims() + let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null } + claims.register('first', 'hash', () => facts) + facts.firstUserInputAt = 1 + expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false) + facts.firstUserInputAt = null + expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false) + claims.register('second', 'hash', () => facts) + expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(true) + expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(false) + claims.register('missing', 'hash', () => facts) + facts = null + expect(claims.claim({ nonce: 'missing', digest: 'hash' })).toBe(false) + }) + + it('refuses reattachment, mismatched hashes, expired claims and malformed bodies', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + claims.register('reattach', 'hash', () => ({ freshSpawn: false, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'reattach', digest: 'hash' })).toBe(false) + claims.register('mismatch', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'mismatch', digest: 'other' })).toBe(false) + expect(claims.claim({ nonce: 'mismatch', digest: 'hash' })).toBe(false) + claims.register('expired', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + now = 20000 + expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false) + for (const body of [null, 1, {}, { nonce: 1 }, { nonce: 'absent' }]) { + expect(claims.claim(body)).toBe(false) + } + }) + + it('keeps pending admission bounded and never recreates canceled or consumed claims', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + claims.register('waiting', 'hash', () => 'pending') + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending') + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending') + expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe(true) + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe(true) + expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.register('canceled', 'hash', () => 'pending') + claims.cancel('canceled') + expect(claims.admit('canceled', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.register('expired', 'hash', () => 'pending') + now = 20000 + expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false) + expect(claims.admit('expired', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.clear() + }) + + it('bounds pending claims and reclaims expired capacity without timers', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + for (let index = 0; index < 129; index++) { + claims.register(String(index), 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + } + expect(claims.claim({ nonce: '128', digest: 'hash' })).toBe(false) + now = 20000 + claims.register('new', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'new', digest: 'hash' })).toBe(true) + }) + it('starts a fresh bounded claim window after delayed spawn admission', () => { + vi.useFakeTimers() + try { + const claims = new OpenCodeStartupPromptClaims() + claims.register('slow-spawn', 'hash', () => 'pending') + vi.advanceTimersByTime(18000) + const cleanup = vi.fn() + expect( + claims.admit('slow-spawn', () => ({ freshSpawn: true, firstUserInputAt: null }), cleanup) + ).toBe(true) + vi.advanceTimersByTime(8000) + expect(claims.claim({ nonce: 'slow-spawn', digest: 'hash' })).toBe(true) + expect(cleanup).toHaveBeenCalledTimes(1) + claims.clear() + } finally { + vi.useRealTimers() + } + }) + + it('replays only the same operation while execution facts remain authorized', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null } + const cleanup = vi.fn() + claims.register('retry', 'hash', () => facts, cleanup) + const body = { nonce: 'retry', digest: 'hash', requestId: 'stable-operation' } + expect(claims.claim(body)).toBe(true) + expect(claims.claim(body)).toBe(true) + expect(claims.claim({ ...body, requestId: 'another-operation' })).toBe(false) + expect(claims.claim({ nonce: 'retry', digest: 'hash' })).toBe(false) + expect(cleanup).not.toHaveBeenCalled() + expect(claims.claim(body)).toBe(true) + facts.firstUserInputAt = 1 + expect(claims.claim(body)).toBe(false) + expect(cleanup).toHaveBeenCalledTimes(1) + facts = { freshSpawn: true, firstUserInputAt: null } + expect(claims.claim(body)).toBe(false) + claims.register('expires', 'hash', () => facts, cleanup) + expect(claims.claim({ ...body, nonce: 'expires' })).toBe(true) + now = 20000 + expect(claims.claim({ ...body, nonce: 'expires' })).toBe(false) + expect(cleanup).toHaveBeenCalledTimes(2) + }) + + it('does not renew admission, retain unbounded IDs or replay retired owners', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null } + claims.register('bound', 'hash', () => 'pending') + now = 18000 + expect(claims.admit('bound', () => facts)).toBe(true) + now = 37000 + expect(claims.admit('bound', () => facts)).toBe(false) + expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'x'.repeat(129) })).toBe(false) + expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'operation' })).toBe(true) + facts = null + expect(claims.claim({ nonce: 'bound', digest: 'hash', requestId: 'operation' })).toBe(false) + claims.register('clear', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'clear', digest: 'hash', requestId: 'operation' })).toBe(true) + claims.clear() + expect(claims.claim({ nonce: 'clear', digest: 'hash', requestId: 'operation' })).toBe(false) + }) + + it.each([null, 1, '', 'with spaces', 'x'.repeat(129)])( + 'rejects malformed operation ID %j without consuming valid authorization', + (requestId) => { + const claims = new OpenCodeStartupPromptClaims() + claims.register('valid', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'valid', digest: 'hash', requestId })).toBe(false) + expect(claims.claim({ nonce: 'valid', digest: 'hash', requestId: 'valid-operation' })).toBe( + true + ) + claims.clear() + } + ) + + it('expires the renewed window without permitting repeated admission to extend it', () => { + vi.useFakeTimers() + try { + const claims = new OpenCodeStartupPromptClaims() + claims.register('bounded', 'hash', () => 'pending') + vi.advanceTimersByTime(18000) + const owner = () => ({ freshSpawn: true, firstUserInputAt: null }) + const cleanup = vi.fn() + expect(claims.admit('bounded', owner, cleanup)).toBe(true) + expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(true) + vi.advanceTimersByTime(19999) + expect(claims.admit('bounded', owner)).toBe(false) + expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(true) + vi.advanceTimersByTime(1) + expect(cleanup).toHaveBeenCalledTimes(1) + expect(claims.claim({ nonce: 'bounded', digest: 'hash', requestId: 'operation' })).toBe(false) + claims.clear() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-claims.ts b/src/main/opencode/opencode-startup-prompt-claims.ts new file mode 100644 index 00000000000..3a8dca6053f --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-claims.ts @@ -0,0 +1,120 @@ +import type { TerminalRunFacts } from '../runtime/terminal-run-facts' + +type PromptClaim = { + digest: string + expiresAt: number + admitted: boolean + grantedRequestId?: string + readOwner: () => TerminalRunFacts | 'pending' | null + cleanup: () => void + expiry: ReturnType +} + +/** Authorizes one startup operation against current execution-owner facts. */ +export class OpenCodeStartupPromptClaims { + private readonly pending = new Map() + + constructor(private readonly now: () => number = Date.now) {} + + register( + nonce: string, + digest: string, + readOwner: PromptClaim['readOwner'], + cleanup = () => {} + ): boolean { + const now = this.now() + for (const [key, claim] of this.pending) { + if (claim.expiresAt <= now) { + this.cancel(key) + } + } + if (this.pending.size >= 128 || this.pending.has(nonce)) { + return false + } + const expiry = setTimeout(() => this.cancel(nonce), 20000) + expiry.unref?.() + this.pending.set(nonce, { + digest, + readOwner, + expiresAt: now + 20000, + admitted: false, + cleanup, + expiry + }) + return true + } + + admit(nonce: string, readOwner: PromptClaim['readOwner'], cleanup = () => {}): boolean { + const pending = this.pending.get(nonce) + if (!pending || pending.expiresAt <= this.now()) { + this.cancel(nonce) + return false + } + if (pending.admitted || pending.grantedRequestId !== undefined) { + return false + } + clearTimeout(pending.expiry) + pending.expiresAt = this.now() + 20000 + pending.expiry = setTimeout(() => this.cancel(nonce), 20000) + pending.expiry.unref?.() + pending.admitted = true + pending.readOwner = readOwner + pending.cleanup = cleanup + return true + } + + cancel(nonce: string): void { + const pending = this.pending.get(nonce) + this.pending.delete(nonce) + if (pending) { + clearTimeout(pending.expiry) + } + pending?.cleanup() + } + + clear(): void { + for (const nonce of this.pending.keys()) { + this.cancel(nonce) + } + } + + claim(body: unknown): boolean | 'pending' { + if (!body || typeof body !== 'object' || !('nonce' in body) || typeof body.nonce !== 'string') { + return false + } + const pending = this.pending.get(body.nonce) + if ( + !pending || + pending.expiresAt <= this.now() || + !('digest' in body) || + body.digest !== pending.digest + ) { + this.cancel(body.nonce) + return false + } + const requestId = 'requestId' in body ? body.requestId : undefined + if ( + requestId !== undefined && + (typeof requestId !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(requestId)) + ) { + return false + } + if (pending.grantedRequestId !== undefined && pending.grantedRequestId !== requestId) { + return false + } + const owner = pending.readOwner() + if (owner === 'pending') { + return 'pending' + } + if (owner?.freshSpawn !== true || owner.firstUserInputAt !== null) { + this.cancel(body.nonce) + return false + } + if (requestId === undefined) { + this.cancel(body.nonce) + } else { + pending.grantedRequestId = requestId + } + return true + } +} diff --git a/src/main/opencode/opencode-startup-prompt-installer.test.ts b/src/main/opencode/opencode-startup-prompt-installer.test.ts new file mode 100644 index 00000000000..69ff1aa14af --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-installer.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setAppEnvironment } from '../../shared/app-environment' +import { + createOpenCodeStartupPromptInstaller, + installOpenCodeStartupPromptForLaunch +} from './opencode-startup-prompt-installer' +import { + captureOpenCodeSourceConfig, + applyOpenCodeStatusPluginEnv +} from '../ipc/pty/host-env/opencode-config' + +let root: string +let originalXdg: string | undefined +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'opencode-prompt-install-')) + originalXdg = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = join(root, 'config') + setAppEnvironment({ + getPath: () => join(root, 'profile'), + getAppPath: () => root, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +}) +afterEach(() => { + vi.unstubAllEnvs() + if (originalXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = originalXdg + } + rmSync(root, { recursive: true, force: true }) +}) +describe('OpenCode startup prompt installer', () => { + it('refuses a replaced status overlay instead of writing through its symlink or losing status hooks', () => { + const source = join(root, 'safe-source') + const foreign = join(root, 'foreign') + mkdirSync(source) + mkdirSync(foreign) + writeFileSync(join(foreign, 'untouched.txt'), 'foreign bytes') + const env: Record = { + OPENCODE_CONFIG_DIR: source, + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'replaced-overlay' + } + const config = captureOpenCodeSourceConfig(env, join(root, 'profile')) + applyOpenCodeStatusPluginEnv( + 'pane', + env, + config, + { + launchAgent: 'opencode', + agentStatusHooksEnabled: true + }, + 'opencode' + ) + rmSync(env.OPENCODE_CONFIG_DIR, { recursive: true }) + symlinkSync(foreign, env.OPENCODE_CONFIG_DIR, 'junction') + expect(installOpenCodeStartupPromptForLaunch(env)).toBe(false) + expect(env.ORCA_OPENCODE_STARTUP_PROMPT_NONCE).toBeUndefined() + expect(readFileSync(join(foreign, 'untouched.txt'), 'utf8')).toBe('foreign bytes') + expect(existsSync(join(foreign, 'plugins'))).toBe(false) + }) + it.each(['opencode', 'opencode2'] as const)( + 'keeps real source provenance and composes the %s status and prompt in one final overlay', + (agent) => { + const source = join(root, 'real-source') + mkdirSync(join(source, 'plugins'), { recursive: true }) + writeFileSync(join(source, 'plugins', 'user.js'), 'user bytes') + writeFileSync(join(source, 'opencode.json'), '{"model":"selected/model"}') + const env: Record = { + OPENCODE_CONFIG_DIR: source, + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'source-provenance' + } + expect(installOpenCodeStartupPromptForLaunch(env, false)).toBe(true) + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source) + const captured = captureOpenCodeSourceConfig(env, join(root, 'profile')) + expect(captured.directory).toBe(source) + applyOpenCodeStatusPluginEnv( + 'pane', + env, + captured, + { + launchAgent: agent, + agentStatusHooksEnabled: true + }, + `${agent} --standalone` + ) + const statusOverlay = env.OPENCODE_CONFIG_DIR + expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true) + expect(env.OPENCODE_CONFIG_DIR).toBe(statusOverlay) + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source) + expect( + readFileSync(join(statusOverlay, 'plugins', `orca-${agent}-status.js`), 'utf8') + ).toContain('ORCA_STATUS_AGENT') + expect( + existsSync(join(statusOverlay, 'plugins', 'orca-opencode-startup-prompt', 'tui.js')) + ).toBe(true) + expect(readFileSync(join(statusOverlay, 'plugins', 'user.js'), 'utf8')).toBe('user bytes') + const nested: Record = { + ...env, + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'nested-source-provenance' + } + expect(installOpenCodeStartupPromptForLaunch(nested)).toBe(true) + expect(nested.OPENCODE_CONFIG_DIR).toBe(statusOverlay) + expect(nested.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(source) + expect(readFileSync(join(source, 'plugins', 'user.js'), 'utf8')).toBe('user bytes') + expect(existsSync(join(source, 'plugins', `orca-${agent}-status.js`))).toBe(false) + } + ) + it.each(['inherited', 'explicit'] as const)( + 'preserves status and user plugins from the %s XDG config when launch env is sparse', + (selection) => { + const configHome = join(root, selection === 'explicit' ? 'selected-config' : 'config') + const config = join(configHome, 'opencode') + mkdirSync(join(config, 'plugins'), { recursive: true }) + writeFileSync(join(config, 'plugins', 'orca-opencode-status.js'), 'status entry') + writeFileSync(join(config, 'plugins', 'user.js'), 'user entry') + writeFileSync(join(config, 'opencode.json'), '{"model":"selected/model"}') + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'sparse-launch', + ...(selection === 'explicit' ? { XDG_CONFIG_HOME: configHome } : {}) + } + expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true) + expect( + readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-status.js'), 'utf8') + ).toBe('status entry') + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe( + 'user entry' + ) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toContain( + 'selected/model' + ) + } + ) + + it("uses the execution owner's resolved environment instead of reintroducing a deleted ambient XDG home", () => { + const selected = join(root, 'resolved-config') + mkdirSync(join(selected, 'opencode', 'plugins'), { recursive: true }) + writeFileSync(join(selected, 'opencode', 'plugins', 'user.js'), 'resolved entry') + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'resolved-launch' + } + expect(installOpenCodeStartupPromptForLaunch(env, false, { XDG_CONFIG_HOME: selected })).toBe( + true + ) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe( + 'resolved entry' + ) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + }) + + it.each(['inherited', 'explicit'] as const)( + 'preserves the %s OPENCODE_CONFIG_DIR ahead of the XDG default', + (selection) => { + const ambient = join(root, 'ambient-source') + const selected = join(root, 'selected-source') + for (const config of [ambient, selected]) { + mkdirSync(join(config, 'plugins'), { recursive: true }) + writeFileSync(join(config, 'plugins', 'user.js'), config) + } + vi.stubEnv('OPENCODE_CONFIG_DIR', ambient) + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'custom-config-launch', + ...(selection === 'explicit' ? { OPENCODE_CONFIG_DIR: selected } : {}) + } + expect(installOpenCodeStartupPromptForLaunch(env)).toBe(true) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'user.js'), 'utf8')).toBe( + selection === 'explicit' ? selected : ambient + ) + } + ) + + it('keeps a missing user config untouched and installs the launch into an owned overlay', () => { + const source = join(root, 'missing-user-config') + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'private-launch', + OPENCODE_CONFIG_DIR: source, + OPENCODE_CONFIG_CONTENT: '{"model":"opencode/model"}' + } + installOpenCodeStartupPromptForLaunch(env) + expect(existsSync(source)).toBe(false) + expect(env.OPENCODE_CONFIG_DIR).toContain( + join(root, 'profile', 'opencode-startup-prompt-overlays') + ) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(env.OPENCODE_CONFIG_DIR) + expect(env.OPENCODE_CONFIG_CONTENT).toBe('{"model":"opencode/model"}') + expect( + existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-startup-prompt', 'tui.js')) + ).toBe(true) + expect(existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-status.js'))).toBe( + false + ) + }) + it('installs only a TUI entry without installing status hooks or a v1/server entry', () => { + const service = createOpenCodeStartupPromptInstaller(() => 'prompt source') + expect(service.buildPtyEnv('pane')).toEqual({}) + const plugins = join(root, 'config', 'opencode', 'plugins') + expect(readFileSync(join(plugins, 'orca-opencode-startup-prompt', 'tui.js'), 'utf8')).toBe( + 'prompt source' + ) + expect(existsSync(join(plugins, 'orca-opencode-startup-prompt.js'))).toBe(false) + expect(existsSync(join(plugins, 'orca-opencode-status.js'))).toBe(false) + expect(existsSync(join(root, 'profile', 'opencode-startup-prompt-hooks'))).toBe(false) + }) + it('preserves user config and plugins across source-scoped overlay refreshes', () => { + const config = join(root, 'custom') + mkdirSync(join(config, 'plugins'), { recursive: true }) + writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}') + writeFileSync(join(config, 'plugins', 'user.js'), 'user source') + mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt')) + writeFileSync( + join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), + 'user collision' + ) + let source = 'first prompt source' + const service = createOpenCodeStartupPromptInstaller(() => source) + const first = service.buildPtyEnv('pane-a', config).OPENCODE_CONFIG_DIR + expect(first).toBeDefined() + source = 'next prompt source' + expect(service.buildPtyEnv('pane-b', config).OPENCODE_CONFIG_DIR).toBe(first) + if (!first) { + throw new Error('Missing overlay') + } + expect( + readFileSync(join(first, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8') + ).toBe(source) + expect(readFileSync(join(first, 'opencode.json'), 'utf8')).toContain('user/model') + expect(readFileSync(join(first, 'plugins', 'user.js'), 'utf8')).toBe('user source') + expect( + readFileSync(join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8') + ).toBe('user collision') + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-installer.ts b/src/main/opencode/opencode-startup-prompt-installer.ts new file mode 100644 index 00000000000..6ae79cc58d2 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-installer.ts @@ -0,0 +1,82 @@ +import { OpenCodeHookService, openCodeHookService, openCode2HookService } from './hook-service' +import { OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY } from '../../shared/opencode-startup-prompt-install' +import { join } from 'node:path' +import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' +import { isOverlayOpenCodePluginCurrent } from '../../shared/opencode-installed-plugin' +import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' +import { resolveOpenCodeSourceConfigDir } from '../ipc/pty/host-env/pi-agent' +import { + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV +} from '../../shared/opencode-startup-prompt' + +export function createOpenCodeStartupPromptInstaller(source: () => string): OpenCodeHookService { + return new OpenCodeHookService({ + pluginFileName: `${OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY}.js`, + legacyHooksDir: 'opencode-startup-prompt-hooks', + overlayDir: 'opencode-startup-prompt-overlays', + pluginSource: source, + tuiOnlyDirectory: OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY + }) +} + +const installer = createOpenCodeStartupPromptInstaller(getOpenCodeStartupPromptSource) + +export function installOpenCodeStartupPromptForLaunch( + env: Record, + restoreAfterShellStartup = true, + sourceEnvironment: NodeJS.ProcessEnv = { ...process.env, ...env } +): boolean { + const nonce = env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (!nonce || env.ORCA_OPENCODE_PLUGIN_API !== 'v2') { + return false + } + const source = + resolveOpenCodeSourceConfigDir(env, sourceEnvironment) || + resolveOpenCodeConfigDirectory(sourceEnvironment) + const statusOwner = + env.ORCA_OPENCODE_AGENT === 'opencode2' ? openCode2HookService : openCodeHookService + const existing = + env.OPENCODE_CONFIG_DIR && env.OPENCODE_CONFIG_DIR === env.ORCA_OPENCODE_CONFIG_DIR + ? installer.installIntoSourceOverlay(env.OPENCODE_CONFIG_DIR, source, statusOwner) + : 'unmatched' + const overlay = + existing === 'installed' + ? env.OPENCODE_CONFIG_DIR + : existing === 'failed' + ? undefined + : installer.buildPtyEnv(nonce, source).OPENCODE_CONFIG_DIR + if ( + !overlay || + !isOverlayOpenCodePluginCurrent( + join(overlay, 'plugins', OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, 'tui.js'), + getOpenCodeStartupPromptSource() + ) + ) { + for (const key of [ + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV + ]) { + delete env[key] + } + return false + } + env.OPENCODE_CONFIG_DIR = overlay + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = source + if (restoreAfterShellStartup || existing === 'installed') { + env.ORCA_OPENCODE_CONFIG_DIR = overlay + } else { + delete env.ORCA_OPENCODE_CONFIG_DIR + } + return true +} + +export function ensureOpenCodeStartupPromptForLaunch(env: Record): void { + if (env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] && !installOpenCodeStartupPromptForLaunch(env)) { + throw new Error('Cannot prepare OpenCode startup prompt; launch was canceled.') + } +} diff --git a/src/main/opencode/opencode-startup-prompt-owner.test.ts b/src/main/opencode/opencode-startup-prompt-owner.test.ts new file mode 100644 index 00000000000..40aa8eabe93 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-owner.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TerminalRunFactsRegister } from '../runtime/terminal-run-facts' +import { + reserveOpenCodeStartupPrompt, + commitPtyWithOpenCodePromptIntent +} from './opencode-startup-prompt-owner' + +const control = vi.hoisted(() => ({ + claim: (_body: unknown): boolean | 'pending' => false, + clear: () => {} +})) +const ownership = vi.hoisted(() => ({ + ptyOwnership: new Map(), + ptyIncarnationById: new Map() +})) +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { + setStartupPromptClaimListener: (claim: typeof control.claim, clear: () => void) => { + control.claim = claim + control.clear = clear + } + } +})) +vi.mock('../ipc/pty/provider/ownership-state', () => ownership) +beforeEach(() => { + control.clear() + ownership.ptyOwnership.clear() + ownership.ptyIncarnationById.clear() +}) + +function fixture() { + const facts = new TerminalRunFactsRegister() + const result = { id: 'owned', incarnationId: 'incarnation' } + let identityReady = false + let release = () => {} + const waiting = new Promise((resolve) => { + release = resolve + }) + const runtime = { + terminalRunFacts: facts, + readOpenCodeStartupPromptOwner: () => + identityReady ? facts.read(result.id, result.incarnationId) : ('pending' as const), + isPtyStopRequested: () => false, + subscribeToPtyExit: (_ptyId: string, _listener: () => void) => () => {} + } + ownership.ptyOwnership.set(result.id, null) + ownership.ptyIncarnationById.set(result.id, result.incarnationId) + const context = { + env: { + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'nonce', + ORCA_OPENCODE_STARTUP_PROMPT_SHA256: 'digest', + ORCA_AGENT_LAUNCH_TOKEN: 'launch' + }, + deps: { runtime }, + result, + provider: { hasPty: () => true }, + args: {} + } + reserveOpenCodeStartupPrompt('nonce', 'digest') + const body = { nonce: 'nonce', digest: 'digest' } + return { + facts, + result, + context, + waiting, + release, + body, + admit: () => { + identityReady = true + } + } +} + +describe('native prompt admission after spawn commit', () => { + it('waits through delayed persistence and later runtime identity admission', async () => { + const f = fixture() + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + f.facts.recordSpawnCommit(f.result) + return f.result + }) + expect(control.claim(f.body)).toBe('pending') + f.release() + await committed + expect(control.claim(f.body)).toBe('pending') + f.admit() + expect(control.claim(f.body)).toBe(true) + expect(control.claim(f.body)).toBe(false) + }) + + it('keeps pre-commit driving input sticky even if the draft is erased', async () => { + const f = fixture() + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + f.facts.recordSpawnCommit(f.result) + return f.result + }) + f.facts.recordInput(f.result.id, 'driving', 'x') + f.facts.recordInput(f.result.id, 'driving', '\u007f') + expect(control.claim(f.body)).toBe('pending') + f.release() + await committed + f.admit() + expect(control.claim(f.body)).toBe(false) + }) + + it('cancels a failed commit without allowing later admission', async () => { + const f = fixture() + await expect( + commitPtyWithOpenCodePromptIntent(f.context, async () => { + throw new Error('persistence rejected') + }) + ).rejects.toThrow('persistence rejected') + f.admit() + expect(control.claim(f.body)).toBe(false) + }) + it.each(['incarnation', 'provider', 'stop', 'exit', 'clear'])( + 'invalidates granted replay after %s', + async (reason) => { + const f = fixture() + let exited = () => {} + const unsubscribe = vi.fn() + vi.spyOn(f.context.deps.runtime, 'subscribeToPtyExit').mockImplementation( + (_id, listener: () => void) => { + exited = listener + return unsubscribe + } + ) + await commitPtyWithOpenCodePromptIntent(f.context, async () => { + f.facts.recordSpawnCommit(f.result) + return f.result + }) + f.admit() + const body = { ...f.body, requestId: 'stable-operation' } + expect(control.claim(body)).toBe(true) + expect(control.claim(body)).toBe(true) + if (reason === 'incarnation') { + ownership.ptyIncarnationById.set(f.result.id, 'replacement') + } + if (reason === 'provider') { + vi.spyOn(f.context.provider, 'hasPty').mockReturnValue(false) + } + if (reason === 'stop') { + vi.spyOn(f.context.deps.runtime, 'isPtyStopRequested').mockReturnValue(true) + } + if (reason === 'exit') { + exited() + } + if (reason === 'clear') { + control.clear() + } + expect(control.claim(body)).toBe(false) + expect(unsubscribe).toHaveBeenCalledTimes(1) + f.facts.recordSpawnCommit(f.result) + expect(control.claim(body)).toBe(false) + } + ) + + it('retains sticky input cancellation after a lost grant response', async () => { + const f = fixture() + await commitPtyWithOpenCodePromptIntent(f.context, async () => { + f.facts.recordSpawnCommit(f.result) + return f.result + }) + f.admit() + const body = { ...f.body, requestId: 'stable-operation' } + expect(control.claim(body)).toBe(true) + f.facts.recordInput(f.result.id, 'driving', 'x') + f.facts.recordInput(f.result.id, 'driving', '\u007f') + expect(control.claim(body)).toBe(false) + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-owner.ts b/src/main/opencode/opencode-startup-prompt-owner.ts new file mode 100644 index 00000000000..be1dacba25d --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-owner.ts @@ -0,0 +1,111 @@ +import { agentHookServer } from '../agent-hooks/server' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { IPtyProvider, PtySpawnResult } from '../providers/types' +import { ptyIncarnationById, ptyOwnership } from '../ipc/pty/provider/ownership-state' +import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import { + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV +} from '../../shared/opencode-startup-prompt' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' + +type OpenCodePromptRuntime = Pick< + OrcaRuntimeService, + | 'terminalRunFacts' + | 'readOpenCodeStartupPromptOwner' + | 'isPtyStopRequested' + | 'subscribeToPtyExit' +> +const claims = new OpenCodeStartupPromptClaims() + +export function reserveOpenCodeStartupPrompt(nonce: string, digest: string): boolean { + agentHookServer.setStartupPromptClaimListener( + (body) => claims.claim(body), + () => claims.clear() + ) + return claims.register(nonce, digest, () => 'pending') +} + +export async function commitPtyWithOpenCodePromptIntent( + context: { + env?: Record + spawnEnv?: Record + deps: { runtime?: OpenCodePromptRuntime } + result: PtySpawnResult + provider: Pick + args: { connectionId?: string | null } + }, + commit: () => Promise +): Promise { + const options = { + env: context.spawnEnv ?? context.env, + runtime: context.deps.runtime, + result: context.result, + provider: context.provider, + connectionId: context.args.connectionId + } + const facts = options.runtime?.terminalRunFacts + facts?.reserveSpawnCommit(options.result) + try { + const result = await commit() + bindOpenCodeStartupPromptOwner({ ...options, result }) + return result + } catch (error) { + const nonce = options.env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (nonce) { + claims.cancel(nonce) + } + throw error + } finally { + facts?.discardSpawnCommit(options.result) + } +} + +export function bindOpenCodeStartupPromptOwner(options: { + env: Record | undefined + result: PtySpawnResult + runtime: OpenCodePromptRuntime | undefined + provider: Pick + connectionId?: string | null +}): void { + const { env, result, runtime, provider } = options + const nonce = env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + const digest = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV] + const launchToken = env?.ORCA_AGENT_LAUNCH_TOKEN + const incarnation = result.incarnationId + if ( + options.connectionId || + !runtime || + result.isReattach || + result.agentSessionEnsure?.disposition === 'adopted' || + !isPtyIncarnationId(incarnation) || + !nonce || + !digest || + !launchToken + ) { + if (nonce) { + claims.cancel(nonce) + } + return + } + let unsubscribe = () => {} + if ( + claims.admit( + nonce, + () => { + if ( + ptyOwnership.get(result.id) !== null || + ptyIncarnationById.get(result.id) !== incarnation || + provider.hasPty?.(result.id) !== true || + runtime.isPtyStopRequested(result.id) + ) { + return null + } + return runtime.readOpenCodeStartupPromptOwner(result.id, incarnation, launchToken) + }, + () => unsubscribe() + ) + ) { + unsubscribe = runtime.subscribeToPtyExit(result.id, () => claims.cancel(nonce)) + } +} diff --git a/src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts b/src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts new file mode 100644 index 00000000000..8f4759957a1 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID +} from '../runtime/agent-transcript-pane-test-harness' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +async function fixture(launchAgent?: 'opencode' | 'opencode2' | 'zcode') { + const { runtime } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: null, + data: '', + ...(launchAgent ? { launchAgent } : {}) + }) + runtime.terminalRunFacts.recordSpawnCommit({ id: TRANSCRIPT_PANE_PTY_ID, incarnationId: 'inc-1' }) + const read = ( + ptyId = TRANSCRIPT_PANE_PTY_ID, + incarnation = 'inc-1', + token = 'transcript-launch' + ) => runtime.readOpenCodeStartupPromptOwner(ptyId, incarnation, token) + return { runtime, read } +} + +describe('runtime-owned startup prompt identity', () => { + it('keeps launch admission pending before runtime identity is assigned', async () => { + const f = await fixture() + expect(f.read()).toBe('pending') + expect(f.read('missing')).toBeNull() + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'previous-incarnation')).toBeNull() + }) + + it.each(['opencode', 'opencode2'] as const)( + 'reads only the admitted %s launch', + async (agent) => { + const f = await fixture(agent) + expect(f.read()).toEqual({ freshSpawn: true, firstUserInputAt: null }) + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-1', 'another-launch')).toBeNull() + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'previous-incarnation')).toBeNull() + f.runtime.terminalRunFacts.recordInput(TRANSCRIPT_PANE_PTY_ID, 'driving', 'x', 123) + expect(f.read()).toEqual({ freshSpawn: true, firstUserInputAt: 123 }) + } + ) + + it('denies another agent even with the exact incarnation and launch token', async () => { + expect((await fixture('zcode')).read()).toBeNull() + }) + it('refuses same-ID replay after the execution owner or launch token changes', async () => { + const f = await fixture('opencode2') + const claims = new OpenCodeStartupPromptClaims() + claims.register('owned-operation', 'digest', () => f.read()) + const body = { nonce: 'owned-operation', digest: 'digest', requestId: 'stable-operation' } + expect(claims.claim(body)).toBe(true) + expect(claims.claim(body)).toBe(true) + await f.runtime.onPtyExit(TRANSCRIPT_PANE_PTY_ID, 0, 'inc-1') + f.runtime.registerPty(TRANSCRIPT_PANE_PTY_ID, 'wt-1', null, { + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + incarnationId: 'inc-2', + agentLaunchAuthority: { launchToken: 'replacement-launch', launchAgent: 'opencode2' } + }) + f.runtime.terminalRunFacts.recordSpawnCommit({ + id: TRANSCRIPT_PANE_PTY_ID, + incarnationId: 'inc-2' + }) + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-2', 'replacement-launch')).toEqual({ + freshSpawn: true, + firstUserInputAt: null + }) + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-2', 'transcript-launch')).toBeNull() + expect(claims.claim(body)).toBe(false) + claims.clear() + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts new file mode 100644 index 00000000000..2e505ee2a8f --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -0,0 +1,761 @@ +import { EventEmitter } from 'node:events' +import { createServer } from 'node:http' +import { createHash } from 'node:crypto' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV +} from '../../shared/opencode-startup-prompt' +import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' +import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' + +type PluginModule = { default: { setup: (ctx: unknown) => Promise<() => Promise> } } +const prompt = 'exact startup brief\nwith unicode é' +const digest = createHash('sha256').update(prompt).digest('hex') +let dir: string +let setup: PluginModule['default']['setup'] +let claim: ReturnType + +class Editor extends EventEmitter { + traits: { owner: string; role: string; capture: string[]; status?: string } = { + owner: 'opencode', + role: 'prompt', + capture: ['tab'] + } + plainText = '' + focused = true + insertText(text: string) { + this.replace(this.plainText + text) + } + replace(text: string) { + this.plainText = text + this.emit('line-info-change') + } +} + +type FixtureLocation = { directory: string; workspaceID?: string } + +function fixture() { + const editor = new Editor() + const input = new EventEmitter() + const memory = { settled: false, expiresAt: Date.now() + 20000 } + const route = { type: 'home' } + const agent = vi.fn((): unknown[] | undefined => [{}]) + const model = vi.fn((): unknown[] | undefined => [{}]) + const sync = vi.fn(async (_location: FixtureLocation) => {}) + const dispatch = vi.fn(() => editor.replace('')) + const ctx = { + app: { version: '2.0.16' }, + renderer: { keyInput: input, currentFocusedEditor: editor }, + storage: { memory: () => [memory, (mutate: (draft: typeof memory) => void) => mutate(memory)] }, + keymap: { dispatch }, + ui: { router: { current: () => route } }, + location: { directory: '/private' }, + data: { location: { sync, agent: { list: agent }, model: { list: model } } } + } + return { ctx, editor, input, memory, route, agent, model, sync, dispatch } +} + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'orca-opencode-prompt-')) + const path = join(dir, 'prompt.mjs') + writeFileSync(path, getOpenCodeStartupPromptSource()) + const module: PluginModule = await import(pathToFileURL(path).href) + setup = module.default.setup + vi.useFakeTimers() + vi.stubEnv(OPENCODE_STARTUP_PROMPT_SHA256_ENV, digest) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_BODY_ENV, prompt) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_NONCE_ENV, 'single-use-nonce') + const endpoint = join(dir, 'endpoint.cmd') + writeFileSync( + endpoint, + 'set ORCA_AGENT_HOOK_PORT=12345\nset ORCA_AGENT_HOOK_TOKEN=private-token\nset ORCA_AGENT_HOOK_ENV=test\nset ORCA_AGENT_HOOK_VERSION=1\n' + ) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, endpoint) + claim = vi.fn(async () => ({ ok: true, json: async () => ({ allowed: true }) })) + vi.stubGlobal('fetch', claim) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + rmSync(dir, { recursive: true, force: true }) +}) + +describe('installed-version native prompt intent plugin', () => { + it('waits for the current home location after the startup directory changes', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private/home/private-folder' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let releaseStartup = () => {} + let releaseHome = () => {} + let selectedModel = 'opencode/mimo-v2.6-flash-free' + const selections: string[] = [] + f.sync.mockImplementation( + (target) => + new Promise((resolve) => { + if (target.directory.endsWith('/private-folder')) { + releaseStartup = resolve + } else { + releaseHome = () => { + selectedModel = 'private-proof/model-a' + resolve() + } + } + }) + ) + f.dispatch.mockImplementation(() => { + selections.push(selectedModel) + f.editor.replace('') + }) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + location = { directory: '/private/home' } + releaseStartup() + await vi.advanceTimersByTimeAsync(300) + expect(claim).not.toHaveBeenCalled() + expect(selections).toEqual([]) + expect(f.sync).toHaveBeenCalledTimes(2) + expect(f.sync).toHaveBeenLastCalledWith(location) + releaseHome() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(selections).toEqual(['private-proof/model-a']) + expect(claim).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it('waits for a concrete location before starting authoritative hydration', async () => { + const f = fixture() + let location: FixtureLocation | undefined + Object.defineProperty(f.ctx, 'location', { get: () => location }) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(300) + expect(f.sync).not.toHaveBeenCalled() + expect(claim).not.toHaveBeenCalled() + location = { directory: '/private/home' } + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(f.sync).toHaveBeenCalledExactlyOnceWith(location) + } finally { + await dispose() + } + }) + + it('keeps readiness scoped to the workspace as well as the directory', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private', workspaceID: 'first' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let release = () => {} + f.sync.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + location = { directory: '/private', workspaceID: 'second' } + release() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(f.sync).toHaveBeenCalledTimes(2) + expect(f.sync).toHaveBeenLastCalledWith(location) + expect(claim).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it('refuses a granted claim after the composer location changes', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let grant = () => {} + claim.mockImplementation( + () => + new Promise((resolve) => { + grant = () => resolve({ ok: true, json: async () => ({ allowed: true }) }) + }) + ) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + location = { directory: '/private/other' } + grant() + await vi.advanceTimersByTimeAsync(500) + expect(insert).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + } finally { + await dispose() + } + }) + + it('waits for authoritative location config before claiming or selecting a model', async () => { + const f = fixture() + let configuredModel = 'unavailable-fallback' + let release = () => {} + f.sync.mockImplementation( + () => + new Promise((resolve) => { + release = () => { + configuredModel = 'configured-model' + resolve() + } + }) + ) + const selections: string[] = [] + f.dispatch.mockImplementation(() => { + selections.push(configuredModel) + f.editor.replace('') + }) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(500) + expect(claim).not.toHaveBeenCalled() + expect(insert).not.toHaveBeenCalled() + expect(selections).toEqual([]) + expect(f.sync).toHaveBeenCalledExactlyOnceWith(f.ctx.location) + release() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(selections).toEqual(['configured-model']) + expect(insert).toHaveBeenCalledExactlyOnceWith(prompt) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.sync).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it.each(['keypress', 'paste', 'edit', 'route', 'expiry', 'dispose', 'editor', 'focus'])( + 'cancels delayed location hydration on %s before any claim', + async (reason) => { + const f = fixture() + let release = () => {} + f.sync.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + expect(claim).not.toHaveBeenCalled() + if (reason === 'keypress' || reason === 'paste') { + f.input.emit(reason) + } + if (reason === 'edit') { + f.editor.replace('typed') + f.editor.replace('') + } + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'expiry') { + f.memory.expiresAt = Date.now() + } + if (reason === 'dispose') { + await dispose() + } + if (reason === 'editor') { + f.ctx.renderer.currentFocusedEditor = new Editor() + } + if (reason === 'focus') { + f.editor.focused = false + } + await vi.advanceTimersByTimeAsync(100) + release() + await vi.advanceTimersByTimeAsync(500) + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.editor.plainText).toBe('') + if (reason !== 'focus') { + expect(f.memory.settled).toBe(true) + } + } finally { + await dispose() + } + expect(f.input.listenerCount('keypress')).toBe(0) + expect(f.editor.listenerCount('line-info-change')).toBe(0) + } + ) + + it('fails closed when authoritative location sync rejects', async () => { + const f = fixture() + f.sync.mockRejectedValue(new Error('location unavailable')) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.memory.settled).toBe(true) + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.input.listenerCount('keypress')).toBe(0) + await dispose() + }) + + it('fails closed when authoritative location sync is missing', async () => { + const f = fixture() + const { sync: _sync, ...location } = f.ctx.data.location + const dispose = await setup({ ...f.ctx, data: { location } }) + await vi.advanceTimersByTimeAsync(500) + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.input.listenerCount('keypress')).toBe(0) + await dispose() + }) + + it.each(['non-ok', 'json-rejected'])('cancels unread %s claim bodies', async (reason) => { + const cancelled = vi.fn() + const response = cancelTrackingResponse(reason === 'non-ok' ? 503 : 200, cancelled) + if (reason === 'json-rejected') { + vi.spyOn(response, 'json').mockRejectedValue(new Error('decoder rejected')) + } + claim.mockResolvedValue(response) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(cancelled).toHaveBeenCalled()) + expect(f.memory.settled).toBe(false) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + expect(f.memory.settled).toBe(true) + }) + + it('consumes an allowed claim body before dispatching the prompt', async () => { + const response = Response.json({ allowed: true }) + claim.mockResolvedValue(response) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(response.bodyUsed).toBe(true) + await dispose() + }) + + it('consumes malformed JSON and retries without delivering until expiry', async () => { + const response = new Response('{invalid', { status: 200 }) + claim.mockResolvedValue(response) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => { + expect(response.bodyUsed).toBe(true) + expect(claim.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + expect(f.memory.settled).toBe(false) + f.memory.expiresAt = Date.now() + await vi.advanceTimersByTimeAsync(100) + expect(response.bodyUsed).toBe(true) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + }) + + it.each(['dialog', 'shell', 'autocomplete'])('does not populate a %s editor', async (kind) => { + const f = fixture() + if (kind === 'dialog') { + f.editor.traits.role = 'dialog' + } + if (kind === 'shell') { + f.editor.traits.status = 'SHELL' + } + if (kind === 'autocomplete') { + f.editor.traits.capture = ['escape', 'navigate', 'submit', 'tab'] + } + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.editor.plainText).toBe('') + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('retries pending admission, then submits once', async () => { + claim.mockResolvedValueOnce({ ok: true, json: async () => ({ allowed: false, pending: true }) }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + expect(f.dispatch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(300) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(claim).toHaveBeenCalledTimes(2) + await dispose() + }) + + it('cancels pending admission on input without retrying a consumed denial', async () => { + claim.mockResolvedValue({ ok: true, json: async () => ({ allowed: false, pending: true }) }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + f.input.emit('keypress', { name: 'x' }) + await vi.advanceTimersByTimeAsync(1000) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it('preserves typing that predates plugin setup without requesting owner permission', async () => { + const f = fixture() + f.editor.replace('early typing') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.editor.plainText).toBe('early typing') + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('waits for catalogs and settles before a single dispatch', async () => { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.memory.settled).toBe(true) + f.editor.replace(prompt) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await dispose() + const reloadDispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await reloadDispose() + }) + + it.each(['keypress', 'paste'])('cancels on physical %s before catalogs finish', async (event) => { + const f = fixture() + f.agent.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + f.input.emit(event) + f.agent.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + }) + + it('cancels a changed draft even when it is restored before the next tick', async () => { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + f.editor.replace('edited') + f.editor.replace('') + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it('cancels pending intent on route changes, expiration and disposal', async () => { + for (const reason of ['route', 'expiration', 'dispose']) { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'expiration') { + f.memory.expiresAt = Date.now() + } + if (reason === 'dispose') { + await dispose() + } + await vi.advanceTimersByTimeAsync(100) + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + expect(f.input.listenerCount('keypress')).toBe(0) + } + }) + + it('leaves unverified versions and mismatched drafts unsubmitted', async () => { + const f = fixture() + f.ctx.app.version = '2.0.17' + await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + f.ctx.app.version = '2.0.16' + f.editor.replace('another brief') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it.each(['canceled', 'unavailable'])( + 'fails closed when the execution owner is %s', + async (reason) => { + claim.mockImplementation(async () => { + if (reason === 'unavailable') { + throw new Error('contact lost') + } + return { ok: true, json: async () => ({ allowed: false }) } + }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + if (reason === 'unavailable') { + await vi.waitFor(() => expect(claim).toHaveBeenCalled()) + expect(f.memory.settled).toBe(false) + f.memory.expiresAt = Date.now() + await vi.advanceTimersByTimeAsync(100) + } + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + await dispose() + } + ) + + it('rechecks physical cancellation after the owner response', async () => { + const f = fixture() + claim.mockImplementation(async () => { + f.input.emit('keypress') + return { ok: true, json: async () => ({ allowed: true }) } + }) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('settles before insertion and never repeats dispatch when the draft is retained', async () => { + const f = fixture() + f.dispatch.mockImplementation(() => {}) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(1500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(insert).toHaveBeenCalledExactlyOnceWith(prompt) + expect(f.memory.settled).toBe(true) + expect(f.editor.plainText).toBe(prompt) + await dispose() + const reloadDispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await reloadDispose() + }) + + it.each(['before-grant', 'after-grant', 'timeout-after-grant'])( + 'recovers actual HTTP response loss %s exactly once', + async (phase) => { + vi.useRealTimers() + vi.unstubAllGlobals() + const claims = new OpenCodeStartupPromptClaims() + claims.register('single-use-nonce', digest, () => ({ + freshSpawn: true, + firstUserInputAt: null + })) + let requests = 0 + const bodies: unknown[] = [] + const grants: (boolean | 'pending')[] = [] + let heldResponse: ReturnType | undefined + const server = createServer(async (request, response) => { + let text = '' + for await (const chunk of request) { + text += chunk.toString() + } + const body: unknown = JSON.parse(text) + bodies.push(body) + requests++ + if (requests === 1 && phase === 'before-grant') { + response.writeHead(503).end('temporarily unavailable') + return + } + const allowed = claims.claim(body) + grants.push(allowed) + if (requests === 1 && phase === 'after-grant') { + response.destroy() + return + } + response.writeHead(200, { 'content-type': 'application/json' }) + if (requests === 1 && phase === 'timeout-after-grant') { + response.write('{"allowed":') + heldResponse = setTimeout(() => response.end('true}'), 1300) + return + } + response.end(JSON.stringify({ allowed: allowed === true, pending: allowed === 'pending' })) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('missing loopback address') + } + writeFileSync( + join(dir, 'endpoint.cmd'), + `set ORCA_AGENT_HOOK_PORT=${address.port}\nset ORCA_AGENT_HOOK_TOKEN=private-token\nset ORCA_AGENT_HOOK_ENV=test\nset ORCA_AGENT_HOOK_VERSION=1\n` + ) + const f = fixture() + const dispose = await setup(f.ctx) + try { + await vi.waitFor( + () => + expect( + f.dispatch, + JSON.stringify({ phase, requests, bodies, grants }) + ).toHaveBeenCalledTimes(1), + { timeout: 3000 } + ) + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(requests).toBe(2) + expect(grants).toEqual(phase === 'before-grant' ? [true] : [true, true]) + expect(bodies[1]).toEqual(bodies[0]) + expect(bodies[0]).toHaveProperty('requestId', expect.any(String)) + expect(f.memory.settled).toBe(true) + expect(f.editor.plainText).toBe('') + } finally { + await dispose() + claims.clear() + clearTimeout(heldResponse) + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } + } + ) + + it.each([408, 429, 503])( + 'retries transient HTTP %s with one stable operation ID', + async (status) => { + claim.mockResolvedValueOnce({ ok: false, status }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + const firstBody = JSON.parse(claim.mock.calls[0][1].body) + expect(firstBody.requestId).toMatch(/^[a-f0-9-]{36}$/) + expect(JSON.parse(claim.mock.calls[1][1].body)).toEqual(firstBody) + expect(claim).toHaveBeenCalledTimes(2) + await dispose() + } + ) + + it.each([401, 403, 404])('settles HTTP %s denial without retrying', async (status) => { + claim.mockResolvedValue({ ok: false, status }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it.each(['input', 'route', 'dispose', 'expiry', 'editor'])( + 'rejects late grants after %s changes', + async (reason) => { + const f = fixture() + let release = (_response: unknown) => {} + claim.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + if (reason === 'input') { + f.input.emit('paste') + } + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'dispose') { + await dispose() + } + if (reason === 'expiry') { + f.memory.expiresAt = Date.now() + } + if (reason === 'editor') { + f.ctx.renderer.currentFocusedEditor = new Editor() + } + release({ ok: true, json: async () => ({ allowed: true }) }) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.editor.plainText).toBe('') + await dispose() + } + ) + + it.each(['input', 'route', 'dispose'])( + 'ends delivery when %s changes during insertion', + async (reason) => { + const f = fixture() + let dispose = async () => {} + const insert = f.editor.insertText.bind(f.editor) + vi.spyOn(f.editor, 'insertText').mockImplementation((text) => { + expect(f.memory.settled).toBe(true) + insert(text) + if (reason === 'input') { + f.input.emit('keypress') + } + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'dispose') { + void dispose() + } + }) + dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + } + ) + + it.each(['allow', 'lost-response'])( + 'degrades safely against a legacy host with %s', + async (reason) => { + const claims = new OpenCodeStartupPromptClaims() + claims.register('single-use-nonce', digest, () => ({ + freshSpawn: true, + firstUserInputAt: null + })) + let lost = false + claim.mockImplementation(async (_url, init) => { + const body = JSON.parse(init.body) + const allowed = claims.claim({ nonce: body.nonce, digest: body.digest }) + if (reason === 'lost-response' && !lost) { + lost = true + throw new Error('legacy grant response lost') + } + return { ok: true, json: async () => ({ allowed }) } + }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(f.dispatch).toHaveBeenCalledTimes(reason === 'allow' ? 1 : 0) + expect(claim).toHaveBeenCalledTimes(reason === 'allow' ? 1 : 2) + expect(f.editor.plainText).toBe('') + claims.clear() + await dispose() + } + ) +}) diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts new file mode 100644 index 00000000000..4bb37861102 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -0,0 +1,152 @@ +import { cancelUnreadResponseBody } from '../lib/unread-response-body' +import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_CLAIM_PATH, + OPENCODE_STARTUP_PROMPT_BODY_ENV +} from '../../shared/opencode-startup-prompt' + +export function getOpenCodeStartupPromptSource(): string { + return String.raw` +const parseEndpoint = ${parseAgentHookEndpointFile.toString()}; +const cancelUnreadResponseBody = ${cancelUnreadResponseBody.toString()}; +async function claimStartupPrompt(nonce, digest, endpoint, requestId) { + let response; + try { + const { readFile, stat } = await import("node:fs/promises"); + if ((await stat(endpoint)).size > 4096) return false; + const coords = parseEndpoint(await readFile(endpoint, "utf8")); + const port = Number(coords.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) return false; + response = await fetch("http://127.0.0.1:" + port + "${OPENCODE_STARTUP_PROMPT_CLAIM_PATH}", { + method: "POST", headers: { "content-type": "application/json", "x-orca-agent-hook-token": coords.token }, + body: JSON.stringify({ nonce, digest, requestId }), signal: AbortSignal.timeout(1000) + }); + if (!response.ok) return response.status === 408 || response.status === 429 || response.status >= 500 ? "pending" : false; + const result = await response.json(); + return result.allowed === true ? true : result.pending === true ? "pending" : false; + } catch { + // A lost grant response can be replayed with the same operation ID until expiry. + return "pending"; + } finally { + if (response) await cancelUnreadResponseBody(response); + } +} +async function submitStartupPrompt(ctx) { + const noop = async () => {}; + const digest = process.env.${OPENCODE_STARTUP_PROMPT_SHA256_ENV}; + const nonce = process.env.${OPENCODE_STARTUP_PROMPT_NONCE_ENV}; + const endpoint = process.env.${OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV}; + const prompt = process.env.${OPENCODE_STARTUP_PROMPT_BODY_ENV}; + if (ctx?.app?.version !== "2.0.16" || !/^[a-f0-9]{64}$/.test(digest || "") || !nonce || !endpoint || !prompt) return noop; + const input = ctx.renderer?.keyInput; + if (typeof ctx.storage?.memory !== "function" || typeof input?.on !== "function" || + typeof input?.off !== "function" || typeof ctx.keymap?.dispatch !== "function" || + typeof ctx.ui?.router?.current !== "function" || + typeof ctx.data?.location?.sync !== "function" || + typeof ctx.data?.location?.agent?.list !== "function" || + typeof ctx.data?.location?.model?.list !== "function") return noop; + const [memory, setMemory] = ctx.storage.memory("startup-prompt", { + initial: { settled: false, expiresAt: Date.now() + 20000 } + }); + if (memory.settled) return noop; + let timer, editor, seen = false, disposed = false, canceled = false, createHash, requestId, claiming = false, hydrating = false, readyLocation; + // Home can change location after plugin setup. + const locationKey = (location) => typeof location?.directory === "string" && location.directory ? + JSON.stringify([location.directory, location.workspaceID]) : undefined; + const isComposer = (candidate) => candidate?.traits?.owner === "opencode" && + candidate.traits.role === "prompt" && !candidate.traits.status && + candidate.traits.capture?.length === 1 && candidate.traits.capture[0] === "tab"; + const matches = (candidate) => typeof candidate?.plainText === "string" && + createHash("sha256").update(candidate.plainText).digest("hex") === digest; + const cleanup = () => { + clearInterval(timer); + input.off("keypress", cancel); + input.off("paste", cancel); + editor?.off("line-info-change", changed); + }; + const settle = () => { + setMemory((draft) => { draft.settled = true; }); + cleanup(); + }; + const cancel = () => { canceled = true; settle(); }; + // Any edit before delivery ends this startup operation. + const changed = () => { if (seen && editor.plainText !== "") settle(); }; + input.on("keypress", cancel); + input.on("paste", cancel); + const dispose = async () => { disposed = true; settle(); }; + try { + const crypto = await import("node:crypto"); + createHash = crypto.createHash; + setMemory((draft) => { draft.requestId ??= crypto.randomUUID(); }); + requestId = memory.requestId; + if (createHash("sha256").update(prompt).digest("hex") !== digest) { await dispose(); return noop; } + if (memory.settled) return dispose; + timer = setInterval(async () => { + if (disposed || memory.settled) return; + try { + if (Date.now() >= memory.expiresAt || ctx.ui.router.current()?.type !== "home") return settle(); + const current = ctx.renderer.currentFocusedEditor; + if (!isComposer(current)) { if (seen) settle(); return; } + if (editor !== current) { + if (seen) return settle(); + editor?.off("line-info-change", changed); + editor = current; + editor?.on("line-info-change", changed); + } + if (typeof editor?.plainText !== "string" || typeof editor?.insertText !== "function") return; + if (editor.plainText !== "") return settle(); + seen = true; + const location = ctx.location; + const key = locationKey(location); + if (!key) return; + if (readyLocation !== key) { + readyLocation = undefined; + if (!hydrating) { + hydrating = true; + const ref = { directory: location.directory, workspaceID: location.workspaceID }; + void ctx.data.location.sync(ref).then(() => { + hydrating = false; + if (!disposed && !memory.settled && locationKey(ctx.location) === key) readyLocation = key; + }, settle); + } + return; + } + const agents = ctx.data.location.agent.list(location); + const models = ctx.data.location.model.list(location); + if (!editor.focused || !agents?.length || !models?.length) return; + if (claiming) return; + claiming = true; + const allowed = await claimStartupPrompt(nonce, digest, endpoint, requestId); + claiming = false; + if (allowed === "pending") return; + if (!allowed) return settle(); + if (disposed || memory.settled || Date.now() >= memory.expiresAt || + locationKey(ctx.location) !== key || + ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || + !editor.focused || !isComposer(editor) || editor.plainText !== "") return settle(); + setMemory((draft) => { draft.settled = true; }); + clearInterval(timer); + editor.off("line-info-change", changed); + try { + editor.insertText(prompt); + if (disposed || canceled || Date.now() >= memory.expiresAt || + locationKey(ctx.location) !== key || + ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || + !editor.focused || !isComposer(editor) || !matches(editor)) return; + ctx.keymap.dispatch("prompt.submit"); + } finally { cleanup(); } + } catch { settle(); } + }, 100); + timer.unref?.(); + return dispose; + } catch { + settle(); + return noop; + } +} +export default { id: "orca-opencode-startup-prompt", setup: submitStartupPrompt }; +`.trimStart() +} diff --git a/src/main/opencode/overlay-dir-gc-lifecycle.test.ts b/src/main/opencode/overlay-dir-gc-lifecycle.test.ts new file mode 100644 index 00000000000..6149c1094fe --- /dev/null +++ b/src/main/opencode/overlay-dir-gc-lifecycle.test.ts @@ -0,0 +1,30 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { OpenCodeDirGcLifecycle } from './overlay-dir-gc-lifecycle' + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +it('schedules one delayed sweep without holding the app open', async () => { + vi.useFakeTimers() + const timeout = vi.spyOn(globalThis, 'setTimeout') + const lifecycle = new OpenCodeDirGcLifecycle(() => '/unused-test-root', 'unused-plugin.js') + const run = vi.spyOn(lifecycle, 'run').mockResolvedValue({ + scanned: 0, + removed: 0, + failed: 0, + keptReferenced: 0, + keptYoung: 0, + keptSourcePresent: 0, + keptUnverifiable: 0 + }) + const inventory = vi.fn(async () => []) + lifecycle.schedule(inventory) + lifecycle.schedule(inventory) + expect(timeout.mock.results[0]?.value.hasRef()).toBe(false) + await vi.advanceTimersByTimeAsync(179999) + expect(run).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(run).toHaveBeenCalledExactlyOnceWith(inventory) +}) diff --git a/src/main/opencode/overlay-dir-gc-lifecycle.ts b/src/main/opencode/overlay-dir-gc-lifecycle.ts new file mode 100644 index 00000000000..df84b28bf18 --- /dev/null +++ b/src/main/opencode/overlay-dir-gc-lifecycle.ts @@ -0,0 +1,46 @@ +import { OPENCODE_CONFIG_DIR_ENV_KEYS } from './legacy-shared-config-dir' +import { sweepOrphanedOpenCodeDirs, type OpenCodeDirGcResult } from './overlay-dir-gc' + +type ReadLivePtyIds = () => Promise + +export class OpenCodeDirGcLifecycle { + private readonly references = new Set() + private scheduled = false + + constructor( + private readonly getRoot: () => string, + private readonly pluginFileName: string + ) {} + + reference(directory: string): void { + this.references.add(directory) + } + + schedule(readLivePtyIds: ReadLivePtyIds, delayMs = 3 * 60_000): void { + if (this.scheduled) { + return + } + this.scheduled = true + const timer = setTimeout(() => { + void this.run(readLivePtyIds).catch((error) => { + console.warn('[OpenCode] Overlay cleanup skipped:', error) + }) + }, delayMs) + timer.unref() + } + + async run(readLivePtyIds: ReadLivePtyIds): Promise { + for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { + const value = process.env[key] + if (value) { + this.references.add(value) + } + } + return sweepOrphanedOpenCodeDirs({ + overlayRoot: this.getRoot(), + pluginFileName: this.pluginFileName, + referencedConfigDirs: this.references, + readLivePtyIds + }) + } +} diff --git a/src/main/opencode/overlay-dir-gc.test.ts b/src/main/opencode/overlay-dir-gc.test.ts new file mode 100644 index 00000000000..e2472477154 --- /dev/null +++ b/src/main/opencode/overlay-dir-gc.test.ts @@ -0,0 +1,227 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { OpenCodeHookService } from './hook-service' +import { OPENCODE_OVERLAY_MANIFEST_FILE } from './opencode-overlay-manifest' +import { OPENCODE_DIR_GC_MIN_AGE_MS, sweepOrphanedOpenCodeDirs } from './overlay-dir-gc' +import { ORCA_OPENCODE_PLUGIN_FILE, sourceOverlayDirName } from './overlay-dir-names' + +let root: string +let overlays: string +const now = Date.now() + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-overlay-gc-'))) + overlays = join(root, 'opencode-config-overlays') + mkdirSync(overlays) + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'xdg')) + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + ]) { + vi.stubEnv(key, '') + } + setAppEnvironment({ + getPath: () => root, + getAppPath: () => root, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +function age(directory: string): void { + const old = new Date(now - OPENCODE_DIR_GC_MIN_AGE_MS * 2) + for (const suffix of [ + '', + OPENCODE_OVERLAY_MANIFEST_FILE, + 'plugins', + join('plugins', ORCA_OPENCODE_PLUGIN_FILE) + ]) { + utimesSync(join(directory, suffix), old, old) + } +} + +function createOverlay(name: string): { source: string; directory: string } { + const source = join(root, name) + const directory = join(overlays, sourceOverlayDirName(source)) + mkdirSync(join(directory, 'plugins'), { recursive: true }) + writeFileSync(join(directory, 'plugins', ORCA_OPENCODE_PLUGIN_FILE), 'export default {}') + writeFileSync( + join(directory, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ + topLevelEntries: [], + pluginEntries: [], + sourceConfigDir: source + }) + ) + age(directory) + return { source, directory } +} + +function sweep(extra: Partial[0]> = {}) { + return sweepOrphanedOpenCodeDirs({ + overlayRoot: overlays, + pluginFileName: ORCA_OPENCODE_PLUGIN_FILE, + referencedConfigDirs: new Set(), + readLivePtyIds: async () => [], + now, + yieldBetweenRemovals: async () => {}, + ...extra + }) +} + +it('collects only an old, owned missing source while retaining active and reusable sources', async () => { + const retired = createOverlay('retired') + const active = createOverlay('active') + const reusable = createOverlay('reusable') + mkdirSync(active.source) + mkdirSync(reusable.source) + const result = await sweep() + expect(result.removed).toBe(1) + expect(result.keptSourcePresent).toBe(2) + expect(existsSync(retired.directory)).toBe(false) + expect(existsSync(active.directory)).toBe(true) + expect(existsSync(reusable.directory)).toBe(true) +}) + +it.each([null, ['surviving-daemon-pty']] as const)( + 'keeps a retired source with an empty fresh-process reference cache when inventory is %s', + async (ids) => { + const retired = createOverlay('retired') + expect((await sweep({ readLivePtyIds: async () => ids })).removed).toBe(0) + expect(existsSync(retired.directory)).toBe(true) + } +) + +it('preserves a candidate when owning-host inventory fails', async () => { + const retired = createOverlay('retired') + expect( + ( + await sweep({ + readLivePtyIds: async () => { + throw new Error('host unavailable') + } + }) + ).keptUnverifiable + ).toBe(1) + expect(existsSync(retired.directory)).toBe(true) +}) + +it.each(['service.json', 'service-local.json'])( + 'preserves %s without guessing service process death', + async (file) => { + const retired = createOverlay('retired') + writeFileSync(join(retired.directory, file), '{}') + age(retired.directory) + const inventory = vi.fn(async () => []) + expect((await sweep({ readLivePtyIds: inventory })).keptUnverifiable).toBe(1) + expect(inventory).not.toHaveBeenCalled() + expect(existsSync(retired.directory)).toBe(true) + } +) + +it('keeps a young candidate and refreshes reference protection after an asynchronous inventory', async () => { + const young = createOverlay('young') + utimesSync( + join(young.directory, 'plugins', ORCA_OPENCODE_PLUGIN_FILE), + new Date(now), + new Date(now) + ) + const retired = createOverlay('retired') + const references = new Set() + const result = await sweep({ + referencedConfigDirs: references, + readLivePtyIds: async () => { + references.add(join(retired.directory, 'plugins')) + return [] + } + }) + expect(result.keptYoung).toBe(1) + expect(result.keptReferenced).toBe(1) + expect(existsSync(retired.directory)).toBe(true) +}) + +it('does not follow a replaced overlay root or a source ancestor link', async () => { + const retired = createOverlay('retired') + const target = join(root, 'elsewhere') + mkdirSync(target) + symlinkSync(target, retired.source, process.platform === 'win32' ? 'junction' : 'dir') + expect((await sweep()).removed).toBe(0) + rmSync(overlays, { recursive: true }) + symlinkSync(target, overlays, process.platform === 'win32' ? 'junction' : 'dir') + expect((await sweep()).scanned).toBe(0) + expect(existsSync(target)).toBe(true) +}) + +it('retains unowned names, ambiguous old manifests and mismatched source hashes', async () => { + for (const name of ['shared', '123', 'human']) { + mkdirSync(join(overlays, name)) + } + const ambiguous = createOverlay('ambiguous') + writeFileSync( + join(ambiguous.directory, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: [] }) + ) + const mismatch = createOverlay('mismatch') + writeFileSync( + join(mismatch.directory, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: [], sourceConfigDir: root }) + ) + expect((await sweep()).keptUnverifiable).toBe(5) +}) + +it('counts failed deletion attempts toward the bound and yields between them', async () => { + for (let i = 0; i < 4; i += 1) { + createOverlay(`retired-${i}`) + } + const remove = vi.fn(() => { + throw new Error('busy') + }) + const yieldBetween = vi.fn(async () => {}) + const result = await sweep({ + maxRemovals: 2, + removeTree: remove, + yieldBetweenRemovals: yieldBetween + }) + expect(result.failed).toBe(2) + expect(remove).toHaveBeenCalledTimes(2) + expect(yieldBetween).toHaveBeenCalledTimes(2) +}) + +it('retains handed-out and inherited source references through clearPty', async () => { + const source = join(root, 'source') + mkdirSync(source) + writeFileSync(join(source, 'opencode.json'), '{}') + const service = new OpenCodeHookService(() => 'export default {}') + const directory = service.buildPtyEnv('pane', source).OPENCODE_CONFIG_DIR + if (!directory) { + throw new Error('Expected overlay') + } + rmSync(source, { recursive: true }) + age(directory) + service.clearPty('pane') + expect((await service.configDirGc.run(async () => [])).keptReferenced).toBe(1) + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', source) + expect((await new OpenCodeHookService().configDirGc.run(async () => [])).keptReferenced).toBe(1) +}) diff --git a/src/main/opencode/overlay-dir-gc.ts b/src/main/opencode/overlay-dir-gc.ts new file mode 100644 index 00000000000..6961c0c3c1a --- /dev/null +++ b/src/main/opencode/overlay-dir-gc.ts @@ -0,0 +1,206 @@ +import { lstat, readdir } from 'node:fs/promises' +import { join, resolve, sep } from 'node:path' +import { yieldToEventLoop } from '../../shared/event-loop-yield' +import { isSafeDescendCandidate, safeRemoveOverlay } from '../pty/overlay-mirror' +import { + OPENCODE_OVERLAY_MANIFEST_FILE, + readOpenCodeOverlayManifest +} from './opencode-overlay-manifest' +import { inspectSourceDirectory, resolveOwnedOverlaySource } from './overlay-source-ownership' + +export const OPENCODE_DIR_GC_MIN_AGE_MS = 30 * 24 * 60 * 60 * 1000 +export const OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP = 500 + +export type OpenCodeDirGcOptions = { + overlayRoot: string + pluginFileName: string + referencedConfigDirs: ReadonlySet + readLivePtyIds: () => Promise + now?: number + minAgeMs?: number + maxRemovals?: number + removeTree?: (directory: string, root: string) => void + yieldBetweenRemovals?: () => Promise +} + +export type OpenCodeDirGcResult = { + scanned: number + removed: number + failed: number + keptReferenced: number + keptYoung: number + keptSourcePresent: number + keptUnverifiable: number +} + +function normalized(path: string): string { + const absolute = resolve(path) + return process.platform === 'win32' ? absolute.toLowerCase() : absolute +} + +function isReferenced(directory: string, references: ReadonlySet): boolean { + const candidate = normalized(directory) + for (const reference of references) { + const value = normalized(reference) + if (value === candidate || value.startsWith(candidate + sep)) { + return true + } + } + return false +} + +async function oldEnough(directory: string, plugin: string, now: number, minAge: number) { + let newest = 0 + for (const path of [ + directory, + join(directory, OPENCODE_OVERLAY_MANIFEST_FILE), + join(directory, 'plugins'), + join(directory, 'plugins', plugin) + ]) { + try { + const stats = await lstat(path) + if (stats.isSymbolicLink()) { + return false + } + newest = Math.max(newest, stats.mtimeMs) + } catch { + return false + } + } + return newest > 0 && now - newest >= minAge +} + +async function hasNoServiceConfig(directory: string): Promise { + try { + const names = await readdir(directory) + // Service registration lives in a different profile; config absence proves no PID verdict. + return !names.some((name) => /^service(?:-[\w.-]+)?\.json$/i.test(name)) + } catch { + return false + } +} + +export async function sweepOrphanedOpenCodeDirs( + options: OpenCodeDirGcOptions +): Promise { + const result: OpenCodeDirGcResult = { + scanned: 0, + removed: 0, + failed: 0, + keptReferenced: 0, + keptYoung: 0, + keptSourcePresent: 0, + keptUnverifiable: 0 + } + if ((await inspectSourceDirectory(options.overlayRoot)) !== 'present') { + return result + } + const now = options.now ?? Date.now() + const minAge = options.minAgeMs ?? OPENCODE_DIR_GC_MIN_AGE_MS + const requestedLimit = options.maxRemovals ?? OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP + const limit = Number.isFinite(requestedLimit) + ? Math.min(OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP, Math.max(0, Math.floor(requestedLimit))) + : OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP + const removeTree = options.removeTree ?? safeRemoveOverlay + const yieldBetween = options.yieldBetweenRemovals ?? yieldToEventLoop + let entries + try { + entries = await readdir(options.overlayRoot, { withFileTypes: true }) + } catch { + return result + } + for (const entry of entries) { + if (result.removed + result.failed >= limit) { + break + } + result.scanned += 1 + const directory = join(options.overlayRoot, entry.name) + if (!/^[0-9a-f]{32}$/.test(entry.name) || !isSafeDescendCandidate(entry)) { + result.keptUnverifiable += 1 + continue + } + if (isReferenced(directory, options.referencedConfigDirs)) { + result.keptReferenced += 1 + continue + } + try { + const manifestStats = await lstat(join(directory, OPENCODE_OVERLAY_MANIFEST_FILE)) + if ( + !manifestStats.isFile() || + manifestStats.isSymbolicLink() || + manifestStats.size > 64 * 1024 + ) { + result.keptUnverifiable += 1 + continue + } + const source = await resolveOwnedOverlaySource( + directory, + readOpenCodeOverlayManifest(directory) + ) + if (!source) { + result.keptUnverifiable += 1 + continue + } + const presence = await inspectSourceDirectory(source) + if (presence === 'present') { + result.keptSourcePresent += 1 + continue + } + if (presence !== 'absent' || !(await hasNoServiceConfig(directory))) { + result.keptUnverifiable += 1 + continue + } + if (!(await oldEnough(directory, options.pluginFileName, now, minAge))) { + result.keptYoung += 1 + continue + } + // A restarted app's empty cache cannot establish surviving daemon terminals are gone. + const liveIds = await options.readLivePtyIds().catch(() => null) + if (liveIds === null || liveIds.length !== 0) { + result.keptUnverifiable += 1 + continue + } + if ( + isReferenced(directory, options.referencedConfigDirs) || + isReferenced(source, options.referencedConfigDirs) + ) { + result.keptReferenced += 1 + continue + } + if ( + (await inspectSourceDirectory(source)) !== 'absent' || + (await inspectSourceDirectory(options.overlayRoot)) !== 'present' || + !(await hasNoServiceConfig(directory)) + ) { + result.keptUnverifiable += 1 + continue + } + if ( + isReferenced(directory, options.referencedConfigDirs) || + isReferenced(source, options.referencedConfigDirs) + ) { + result.keptReferenced += 1 + continue + } + try { + removeTree(directory, options.overlayRoot) + try { + await lstat(directory) + result.failed += 1 + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + result.removed += 1 + } else { + result.failed += 1 + } + } + } catch { + result.failed += 1 + } + await yieldBetween() + } catch { + result.keptUnverifiable += 1 + } + } + return result +} diff --git a/src/main/opencode/overlay-dir-names.ts b/src/main/opencode/overlay-dir-names.ts new file mode 100644 index 00000000000..1e3a3a9a9f0 --- /dev/null +++ b/src/main/opencode/overlay-dir-names.ts @@ -0,0 +1,12 @@ +import { createHash } from 'node:crypto' + +export const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' +export const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' + +export function toSafeDirName(id: string): string { + return createHash('sha256').update(id).digest('hex').slice(0, 32) +} + +export function sourceOverlayDirName(sourceConfigDir: string): string { + return toSafeDirName(`source:${sourceConfigDir}`) +} diff --git a/src/main/opencode/overlay-source-ownership.test.ts b/src/main/opencode/overlay-source-ownership.test.ts new file mode 100644 index 00000000000..78c5d1c02ce --- /dev/null +++ b/src/main/opencode/overlay-source-ownership.test.ts @@ -0,0 +1,90 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import * as filesystem from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { inspectSourceDirectory, resolveOwnedOverlaySource } from './overlay-source-ownership' +import { sourceOverlayDirName } from './overlay-dir-names' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, lstat: vi.fn(actual.lstat) } +}) + +let root: string +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-overlay-source-'))) +}) +afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +it('distinguishes a missing source from an inaccessible source and a linked ancestor', async () => { + const source = join(root, 'source') + mkdirSync(source) + expect(await inspectSourceDirectory(source)).toBe('present') + expect(await inspectSourceDirectory(join(root, 'missing', 'config'))).toBe('absent') + expect(await inspectSourceDirectory('relative-config')).toBe('unverifiable') + writeFileSync(join(root, 'file'), '') + expect(await inspectSourceDirectory(join(root, 'file'))).toBe('unverifiable') + symlinkSync(source, join(root, 'link'), process.platform === 'win32' ? 'junction' : 'dir') + expect(await inspectSourceDirectory(join(root, 'link', 'missing'))).toBe('unverifiable') + const error = Object.assign(new Error('Denied'), { code: 'EACCES' }) + vi.mocked(filesystem.lstat).mockRejectedValueOnce(error) + expect(await inspectSourceDirectory(source)).toBe('unverifiable') +}) + +it('requires an absolute source whose hash matches the owned overlay', async () => { + const source = join(root, 'source') + const overlay = join(root, sourceOverlayDirName(source)) + const manifest = { topLevelEntries: [], pluginEntries: [], sourceConfigDir: source } + expect(await resolveOwnedOverlaySource(overlay, manifest)).toBe(source) + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, sourceConfigDir: join(root, 'other') }) + ).toBeUndefined() + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, sourceConfigDir: 'relative' }) + ).toBeUndefined() + expect( + await resolveOwnedOverlaySource(join(root, '123'), { + topLevelEntries: [], + pluginEntries: [], + sourceConfigDir: source + }) + ).toBeUndefined() +}) + +it.skipIf(process.platform === 'win32')( + 'recognizes an older source-scoped manifest only through its named mirrored link', + async () => { + const source = join(root, 'retired-source') + const overlay = join(root, sourceOverlayDirName(source)) + mkdirSync(overlay) + symlinkSync(join(source, 'opencode.json'), join(overlay, 'opencode.json')) + const manifest = { topLevelEntries: ['opencode.json'], pluginEntries: [] } + expect(await resolveOwnedOverlaySource(overlay, manifest)).toBe(source) + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, topLevelEntries: ['../other'] }) + ).toBeUndefined() + expect( + await resolveOwnedOverlaySource(overlay, { ...manifest, topLevelEntries: [] }) + ).toBeUndefined() + rmSync(join(overlay, 'opencode.json')) + writeFileSync(join(overlay, 'opencode.json'), '{}') + expect(await resolveOwnedOverlaySource(overlay, manifest)).toBeUndefined() + } +) + +it.skipIf(process.platform === 'win32')('rejects arbitrary legacy source links', async () => { + const source = join(root, 'source') + const overlay = join(root, sourceOverlayDirName(source)) + mkdirSync(overlay) + symlinkSync(join(root, 'other', 'opencode.json'), join(overlay, 'opencode.json')) + expect( + await resolveOwnedOverlaySource(overlay, { + topLevelEntries: ['opencode.json'], + pluginEntries: [] + }) + ).toBeUndefined() +}) diff --git a/src/main/opencode/overlay-source-ownership.ts b/src/main/opencode/overlay-source-ownership.ts new file mode 100644 index 00000000000..4f71cd191da --- /dev/null +++ b/src/main/opencode/overlay-source-ownership.ts @@ -0,0 +1,63 @@ +import { lstat, readlink } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path' +import { isSafeDescendCandidate } from '../pty/overlay-mirror' +import type { OpenCodeOverlayManifest } from './opencode-overlay-manifest' +import { sourceOverlayDirName } from './overlay-dir-names' + +export type SourceDirectoryPresence = 'present' | 'absent' | 'unverifiable' + +function isAbsent(error: unknown): boolean { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' +} + +export async function inspectSourceDirectory(path: string): Promise { + if (!isAbsolute(path)) { + return 'unverifiable' + } + const absolute = resolve(path) + let current = parse(absolute).root + const segments = relative(current, absolute).split(sep).filter(Boolean) + for (const segment of ['', ...segments]) { + current = join(current, segment) + try { + if (!isSafeDescendCandidate(await lstat(current))) { + return 'unverifiable' + } + } catch (error) { + return isAbsent(error) ? 'absent' : 'unverifiable' + } + } + return 'present' +} + +export async function resolveOwnedOverlaySource( + directory: string, + manifest: OpenCodeOverlayManifest +): Promise { + const matches = (source: string): boolean => + isAbsolute(source) && sourceOverlayDirName(source) === basename(directory) + if (manifest.sourceConfigDir !== undefined) { + return matches(manifest.sourceConfigDir) ? manifest.sourceConfigDir : undefined + } + // Older manifests prove a source only through a named, source-hash-matching mirror. + let source: string | undefined + for (const entry of manifest.topLevelEntries) { + if (!entry || basename(entry) !== entry || entry === '.' || entry === '..') { + return undefined + } + try { + const target = await readlink(join(directory, entry)) + const candidate = dirname(target) + if (!isAbsolute(target) || basename(target) !== entry || !matches(candidate)) { + return undefined + } + if (source !== undefined && source !== candidate) { + return undefined + } + source = candidate + } catch { + return undefined + } + } + return source +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index ad9f51e7ad6..a71307a8df5 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -151,6 +151,7 @@ async function startOrcadRuntime( | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} + let removeStatusHookSettingsListener = (): void => {} registerCleanup(async () => { try { await rpc?.stop() @@ -167,6 +168,7 @@ async function startOrcadRuntime( // orcad restart goes back to killing every running terminal. await stopOrcadDaemon() } finally { + removeStatusHookSettingsListener() uninstallObservedStatusIdentity() uninstallHookStatusRepublish() agentHookServer.stop() @@ -194,9 +196,17 @@ async function startOrcadRuntime( uninstallObservedStatusIdentity = agentHookServer.subscribeEnrichedStatus((enriched) => observedStatusCapture.observe(enriched) ) - if (isAgentStatusHooksEnabled(profileStore.getSettings())) { - await agentHookServer.start({ env: 'production', userDataPath: runtimeUserDataPath }) - } + await agentHookServer.start({ + env: 'production', + userDataPath: runtimeUserDataPath, + statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings()) + }) + + removeStatusHookSettingsListener = profileStore.onSettingsChanged((updates, settings) => { + if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) + } + }) // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 530d06f4ee8..41bba43d4cf 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -1,7 +1,8 @@ import { mkdtempSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { ProfilePreferences } from '../persistence/loading-store/profile-preferences' import { DeviceRegistry } from '../runtime/device-registry' import { RuntimeMobileNotificationController } from '../runtime/runtime-mobile-notification-controller' import { PushUnregisterOutbox } from '../runtime/push/push-unregister-outbox' @@ -14,6 +15,9 @@ const state = vi.hoisted(() => ({ controller: null as RuntimeMobileNotificationController | null, registry: null as DeviceRegistry | null, rpcStarted: false, + onSettingsChanged: vi.fn(), + removeSettingsListener: vi.fn(), + startDaemon: vi.fn(async () => {}), browserProvider: vi.fn(async () => null), register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })), send: vi.fn(async () => ({ ok: true, results: [] })) @@ -26,7 +30,7 @@ vi.mock('./orcad-app-paths', () => ({ vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: state.browserProvider })) vi.mock('./orcad-instance-lock', () => ({ acquireOrcadInstanceLock: () => ({ release() {} }) })) vi.mock('./orcad-daemon-supervision', () => ({ - startOrcadDaemon: async () => {}, + startOrcadDaemon: state.startDaemon, stopOrcadDaemon: async () => {} })) vi.mock('./orcad-health', () => ({ collectOrcadHealth: async () => ({}) })) @@ -44,6 +48,7 @@ vi.mock('./orcad-profile-state-startup', () => ({ createOrcadProfileStateStartup: async () => ({ store: { getSettings: () => ({}), + onSettingsChanged: state.onSettingsChanged, flushFinalOrThrowAsync: async () => {}, freezeWritesAsync: async () => {} }, @@ -124,6 +129,10 @@ vi.mock('../runtime/push/push-gateway-client', () => ({ } })) +beforeEach(() => { + state.onSettingsChanged.mockReturnValue(state.removeSettingsListener) +}) + afterEach(() => { rmSync(state.root, { recursive: true, force: true }) vi.clearAllMocks() @@ -176,6 +185,11 @@ it('starts push after RPC identity is available and stops dispatch on shutdown', expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() expect(state.controller.getListenerCount()).toBe(0) + expect(state.rpcStarted).toBe(false) + expect(state.onSettingsChanged).toHaveBeenCalledOnce() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() + await host.stop() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() expect(await state.controller.registerPushDevice({} as never)).toMatchObject({ registered: false }) @@ -189,3 +203,14 @@ it('releases admission when host setup fails before a runtime exists', async () expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() }) + +it('unsubscribes settings when daemon startup fails after hook setup', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-daemon-failure-')) + state.startDaemon.mockRejectedValueOnce(new Error('daemon setup failed')) + const { startOrcad } = await import('./orcad-entry') + await expect(startOrcad()).rejects.toThrow('daemon setup failed') + expect(state.onSettingsChanged).toHaveBeenCalledOnce() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() + expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) + acquireProfileStateMaintenance(state.root).release() +}) diff --git a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts index 5f679d3128d..eff2cb3e6a5 100644 --- a/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts +++ b/src/main/persistence/loading-store/profile-state-sqlite-authority.test.ts @@ -127,14 +127,14 @@ describe('Store with an injected SQLite profile-state authority', () => { store.updateSettings({ terminalFontSize: store.getSettings().terminalFontSize + 1 }) await store.flushPendingOrThrowAsync() - expect(readFileSync(dataFile)).toEqual(legacyBytes) + expect(readFileSync(dataFile).equals(legacyBytes)).toBe(true) expect(existsSync(databaseFile)).toBe(true) const reloaded = new Store({ dataFile, profileStateAuthority: authority }) expect(reloaded.getSettings().theme).toBe('dark') expect(reloaded.getSettings().terminalFontSize).toBe(store.getSettings().terminalFontSize) expect(reloaded.getSettings().opencodeSessionCookie).toBe('authority-secret') - expect(readFileSync(dataFile)).toEqual(legacyBytes) + expect(readFileSync(dataFile).equals(legacyBytes)).toBe(true) reloaded.freezeWrites() }) @@ -848,7 +848,7 @@ describe('Store with an injected SQLite profile-state authority', () => { 'store-recovery-test' ) - expect(readFileSync(join(result.directory, 'profile-state.db'))).toEqual(sourceBytes) + expect(readFileSync(join(result.directory, 'profile-state.db')).equals(sourceBytes)).toBe(true) expect(readFileSync(join(result.directory, 'profile-state.db-wal'), 'utf8')).toBe( 'wal-preservation-sentinel' ) @@ -856,7 +856,7 @@ describe('Store with an injected SQLite profile-state authority', () => { profileId: 'profile-authority-test', reason: 'store-recovery-test' }) - expect(readFileSync(databasePath)).toEqual(sourceBytes) + expect(readFileSync(databasePath).equals(sourceBytes)).toBe(true) }) it('prepares frozen JSON imports without permitting file publication', () => { diff --git a/src/main/persistence/loading-store/profile-state-store-backups.test.ts b/src/main/persistence/loading-store/profile-state-store-backups.test.ts index aa60618cc42..f712bc691ce 100644 --- a/src/main/persistence/loading-store/profile-state-store-backups.test.ts +++ b/src/main/persistence/loading-store/profile-state-store-backups.test.ts @@ -129,7 +129,7 @@ describe('Store automatic SQLite recovery snapshots', () => { expect(readSnapshot(backups[0].path).state.workspaceSession.activeWorktreeId).toBe( 'backup-worktree' ) - expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readFileSync(state.retained[0].path).equals(state.retainedBytes)).toBe(true) expect(readFileSync(state.dataFile, 'utf8')).toBe(state.legacyBytes) expect( readdirSync(state.directory) @@ -208,7 +208,7 @@ describe('Store automatic SQLite recovery snapshots', () => { const backups = profileStateDatabaseBackups(state.databasePath) expect(backups).toHaveLength(2) expect(readSnapshot(backups[0].path).state.settings.theme).toBe('dark') - expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readFileSync(state.retained[0].path).equals(state.retainedBytes)).toBe(true) expect(JSON.parse(readFileSync(state.dataFile, 'utf8'))).toEqual( readSnapshot(state.databasePath).state ) @@ -241,7 +241,7 @@ describe('Store automatic SQLite recovery snapshots', () => { ) expect(readSnapshot(state.databasePath).state.settings.theme).toBe('dark') expect(profileStateDatabaseBackups(state.databasePath)).toEqual(state.retained) - expect(readFileSync(state.retained[0].path)).toEqual(state.retainedBytes) + expect(readFileSync(state.retained[0].path).equals(state.retainedBytes)).toBe(true) expect(readSnapshot(state.retained[0].path).state.settings.theme).toBe('light') expect(readFileSync(state.dataFile, 'utf8')).toBe(state.legacyBytes) expect(existsSync(`${state.dataFile}.bak.0`)).toBe(false) diff --git a/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts index 8d7b7f72674..21c13c3808f 100644 --- a/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts +++ b/src/main/persistence/profile-state/profile-state-authority-bootstrap.test.ts @@ -197,7 +197,7 @@ describe('profile state authority bootstrap', () => { expect(() => bootstrapProfileStateAuthority(options)).toThrow( ProfileStateRecoveryRequiredError ) - expect(readFileSync(options.databaseFile)).toEqual(before) + expect(readFileSync(options.databaseFile).equals(before)).toBe(true) } const exportPath = profileStateJsonExportPath(options.dataFile, 1) writeFileSync(exportPath, raw) @@ -502,7 +502,7 @@ describe('profile state authority bootstrap', () => { expect.arrayContaining([options.databaseFile, `${options.databaseFile}-wal`]) ) expect(existsSync(options.databaseFile)).toBe(false) - expect(readFileSync(options.dataFile)).toEqual(restoredJson) + expect(readFileSync(options.dataFile).equals(restoredJson)).toBe(true) expect(existsSync(exportPath)).toBe(false) expect(readFileSync(join(result.quarantine.directory, 'profile-state.db'), 'utf8')).toBe( 'corrupt sqlite primary' @@ -538,7 +538,7 @@ describe('profile state authority bootstrap', () => { exportPath }) ).toThrow('Profile state JSON is invalid') - expect(readFileSync(options.databaseFile)).toEqual(databaseBytes) - expect(readFileSync(options.dataFile)).toEqual(dataBytes) + expect(readFileSync(options.databaseFile).equals(databaseBytes)).toBe(true) + expect(readFileSync(options.dataFile).equals(dataBytes)).toBe(true) }) }) diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts index 9e1d09b761f..a2d40b8328c 100644 --- a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts +++ b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts @@ -161,7 +161,7 @@ describe('profile state backup worker', () => { await expect(runProfileStateBackupWorker(job, { workerPath })).rejects.toThrow(expectedError) expect(existsSync(job.targetPath)).toBe(false) expect(readFileSync(retained, 'utf8')).toBe('previous recovery point') - expect(readFileSync(job.databasePath)).toEqual(before) + expect(readFileSync(job.databasePath).equals(before)).toBe(true) }) it.each(['true', 'false'])('waits for actual exit after an ok=%s response', async (ok) => { @@ -251,7 +251,7 @@ describe('profile state backup worker', () => { await expect( runProfileStateBackupWorker(job, { workerPath: join(directory, 'missing.js') }) ).rejects.toThrow() - expect(readFileSync(job.databasePath)).toEqual(before) + expect(readFileSync(job.databasePath).equals(before)).toBe(true) }) it('coalesces desktop work and drains a started backup before allowing quarantine', async () => { diff --git a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts index da0ebcf7ac5..238e067e9a3 100644 --- a/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts +++ b/src/main/persistence/profile-state/profile-state-database-snapshot.test.ts @@ -80,7 +80,7 @@ describe('asynchronous profile-state database snapshots', () => { } expect(exportProfileStateJson(db)).toBe(originalJson) expect(db.pragma('journal_mode', { simple: true })).toBe('wal') - expect(readFileSync(databasePath)).toEqual(sourceFile) + expect(readFileSync(databasePath).equals(sourceFile)).toBe(true) importProfileStateJson(db, JSON.stringify({ settings: { theme: 'dark' } })) expect(readSnapshot(targetPath)).toBe(originalJson) expectNoTemporaryFiles(directory) @@ -150,7 +150,7 @@ describe('asynchronous profile-state database snapshots', () => { 'injected native backup failure' ) - expect(readFileSync(targetPath)).toEqual(previous) + expect(readFileSync(targetPath).equals(previous)).toBe(true) expect(exportProfileStateJson(db)).toBe(originalJson) expectNoTemporaryFiles(directory) }) @@ -184,7 +184,7 @@ describe('asynchronous profile-state database snapshots', () => { 'injected rename failure' ) - expect(readFileSync(targetPath)).toEqual(previous) + expect(readFileSync(targetPath).equals(previous)).toBe(true) expectNoTemporaryFiles(directory) }) @@ -202,7 +202,7 @@ describe('asynchronous profile-state database snapshots', () => { db.exec('ROLLBACK') } - expect(readFileSync(targetPath)).toEqual(previous) + expect(readFileSync(targetPath).equals(previous)).toBe(true) expect(exportProfileStateJson(db)).toBe(originalJson) expectNoTemporaryFiles(directory) }) diff --git a/src/main/persistence/profile-state/profile-state-database.test.ts b/src/main/persistence/profile-state/profile-state-database.test.ts index 245806b8ad0..b96858346d5 100644 --- a/src/main/persistence/profile-state/profile-state-database.test.ts +++ b/src/main/persistence/profile-state/profile-state-database.test.ts @@ -162,7 +162,7 @@ describe('profile state database', () => { } const after = statSync(dbPath) expect(after.size).toBe(before.size) - expect(readFileSync(dbPath)).toEqual(beforeBytes) + expect(readFileSync(dbPath).equals(beforeBytes)).toBe(true) }) it('opens the current schema read-only without changing its bytes', () => { @@ -179,7 +179,7 @@ describe('profile state database', () => { } finally { opened.db.close() } - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('rejects a database whose profile identity does not match', () => { @@ -192,7 +192,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-b')).toThrowError( expect.objectContaining({ code: 'identity-mismatch' }) ) - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('rejects malformed database bytes without replacing them', () => { @@ -204,7 +204,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( expect.objectContaining({ code: 'unreadable' }) ) - expect(readFileSync(dbPath)).toEqual(bytes) + expect(readFileSync(dbPath).equals(bytes)).toBe(true) }) it('quarantines the database family without touching live recovery sources', () => { @@ -254,7 +254,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( expect.objectContaining({ code: 'unreadable' }) ) - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('rejects an incomplete current schema without mutating it', () => { @@ -268,7 +268,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( expect.objectContaining({ code: 'unreadable' }) ) - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('rejects current-version tables with incompatible columns without mutating them', () => { @@ -290,7 +290,7 @@ describe('profile state database', () => { expect(() => openProfileStateDatabase(dbPath, 'profile-a')).toThrowError( expect.objectContaining({ code: 'unreadable' }) ) - expect(readFileSync(dbPath)).toEqual(before) + expect(readFileSync(dbPath).equals(before)).toBe(true) }) it('does not create an empty database when the parent directory is absent', () => { diff --git a/src/main/providers/agent-foreground-process-pi.test.ts b/src/main/providers/agent-foreground-process-pi.test.ts index 691ee63e748..7ae8ec3f3fc 100644 --- a/src/main/providers/agent-foreground-process-pi.test.ts +++ b/src/main/providers/agent-foreground-process-pi.test.ts @@ -33,60 +33,64 @@ describe('Pi Windows foreground recognition', () => { } }) - it('recognizes the npm entrypoint within the active ConPTY', async () => { - const rows = [ - { - pid: 100, - ppid: 99, - name: 'bash.exe', - commandLine: '"C:\\Program Files\\Git\\usr\\bin\\bash.exe"' - }, - { - pid: 101, - ppid: 100, - name: 'node.exe', - commandLine: - 'node.exe C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js' - } - ] - getAllProcessesMock.mockImplementation((cb: (snapshot: unknown) => void) => { - cb(withSelf(rows)) - }) - const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 101])) - - await expect( - resolveAgentForegroundProcessWithAvailability(100, 'node.exe', { - fresh: true, - readWindowsConsoleAttachedProcessIds + it.each(['cli.js', String.raw`bundle\cli.js`])( + 'recognizes the npm entrypoint dist/%s within the active ConPTY', + async (entrypoint) => { + const rows = [ + { + pid: 100, + ppid: 99, + name: 'bash.exe', + commandLine: '"C:\\Program Files\\Git\\usr\\bin\\bash.exe"' + }, + { + pid: 101, + ppid: 100, + name: 'node.exe', + commandLine: `node.exe C:\\Users\\dev\\AppData\\Roaming\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\${entrypoint}` + } + ] + getAllProcessesMock.mockImplementation((cb: (snapshot: unknown) => void) => { + cb(withSelf(rows)) }) - ).resolves.toEqual({ available: true, processName: 'pi', processId: 101 }) - expect(readWindowsConsoleAttachedProcessIds).toHaveBeenCalledTimes(1) - }) + const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 101])) - it('anchors a collapsed omp name to the omp pid, not the embedded pi leaf', async () => { - // Pi restarts under a live OMP; an anchor on pi's pid would read that as - // OMP's exit and fire a false "agent done" when the next snapshot degrades. - const rows = [ - { pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' }, - { pid: 101, ppid: 100, name: 'omp.exe', commandLine: 'omp' }, - { - pid: 102, - ppid: 101, - name: 'node.exe', - commandLine: - 'node.exe C:\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js' - } - ] - getAllProcessesMock.mockImplementation((cb: (snapshot: unknown) => void) => { - cb(withSelf(rows)) - }) - const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 101, 102])) + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'node.exe', { + fresh: true, + readWindowsConsoleAttachedProcessIds + }) + ).resolves.toEqual({ available: true, processName: 'pi', processId: 101 }) + expect(readWindowsConsoleAttachedProcessIds).toHaveBeenCalledTimes(1) + } + ) - await expect( - resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', { - fresh: true, - readWindowsConsoleAttachedProcessIds + it.each(['cli.js', String.raw`bundle\cli.js`])( + 'anchors a collapsed omp name to the omp pid, not the embedded pi leaf at dist/%s', + async (entrypoint) => { + // Pi restarts under a live OMP; an anchor on pi's pid would read that as + // OMP's exit and fire a false "agent done" when the next snapshot degrades. + const rows = [ + { pid: 100, ppid: 99, name: 'powershell.exe', commandLine: 'powershell.exe' }, + { pid: 101, ppid: 100, name: 'omp.exe', commandLine: 'omp' }, + { + pid: 102, + ppid: 101, + name: 'node.exe', + commandLine: `node.exe C:\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\${entrypoint}` + } + ] + getAllProcessesMock.mockImplementation((cb: (snapshot: unknown) => void) => { + cb(withSelf(rows)) }) - ).resolves.toEqual({ available: true, processName: 'omp', processId: 101 }) - }) + const readWindowsConsoleAttachedProcessIds = vi.fn(async () => new Set([100, 101, 102])) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'powershell.exe', { + fresh: true, + readWindowsConsoleAttachedProcessIds + }) + ).resolves.toEqual({ available: true, processName: 'omp', processId: 101 }) + } + ) }) diff --git a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts index d16715ed496..169773edbd5 100644 --- a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts +++ b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts @@ -5,6 +5,7 @@ * startup-file chain, OSC 133 hooks, and the shell-ready marker all live here. */ import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell' import { WSL_MANAGED_CLI_PATH_RESTORE } from '../wsl-managed-cli-path-restore' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' @@ -47,6 +48,7 @@ __orca_restore_agent_teams_path() { export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +${ORCA_CLI_POSIX_PATH_RESTORE} ${WSL_MANAGED_CLI_PATH_RESTORE} # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index 12b259f5b9f..18ac3b4683e 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -1,3 +1,4 @@ +import { openCodeHookServiceModuleMock } from '../ipc/pty-ipc-mock-registry' import { settledWriteStub } from './settled-pty-write-stub' import { describe, expect, it, vi } from 'vitest' import { setPtyHostBindings } from '../ipc/pty-host-bindings' @@ -48,18 +49,7 @@ vi.mock('node-pty', () => ({ }) })) -vi.mock('../opencode/hook-service', () => ({ - openCodeHookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - }, - openCode2HookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - } -})) +vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock()) vi.mock('../pi/titlebar-extension-service', () => ({ piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } @@ -176,6 +166,11 @@ describe('PTY provider dispatch', () => { 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_BRIDGE_SESSION_ID', 'ORCA_OPENCODE_PLUGIN_API', + 'ORCA_OPENCODE_STARTUP_PROMPT_BODY', + 'ORCA_OPENCODE_STARTUP_PROMPT_ENDPOINT', + 'ORCA_OPENCODE_STARTUP_PROMPT_NONCE', + 'ORCA_OPENCODE_STARTUP_PROMPT_SHA256', + 'ORCA_OPENCODE_STARTUP_PROMPT_SHELL', 'ORCA_PI_STATUS_OWNED', 'ORCA_PRIME_AGENT_STATUS_OWNED', 'ORCA_PI_TITLE_MARKER_OWNED', diff --git a/src/main/providers/ssh-pty-provider-spawn.test.ts b/src/main/providers/ssh-pty-provider-spawn.test.ts index 06eb32d586f..6626a238ce0 100644 --- a/src/main/providers/ssh-pty-provider-spawn.test.ts +++ b/src/main/providers/ssh-pty-provider-spawn.test.ts @@ -460,6 +460,7 @@ describe('spawn', () => { PATH: '/home/user/.orca-relay/bin:/usr/bin', ORCA_TERMINAL_HANDLE: 'term_ssh', [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true', + ORCA_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1', ORCA_RELAY_NODE_PATH: '/usr/bin/node', @@ -490,6 +491,7 @@ describe('spawn', () => { env: { ORCA_TERMINAL_HANDLE: 'term_ssh', [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true', + ORCA_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1', ORCA_RELAY_NODE_PATH: '/usr/bin/node', diff --git a/src/main/providers/ssh-pty-spawn-env.test.ts b/src/main/providers/ssh-pty-spawn-env.test.ts new file mode 100644 index 00000000000..b1fba08b0ca --- /dev/null +++ b/src/main/providers/ssh-pty-spawn-env.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { buildSshPtySpawnEnv } from './ssh-pty-spawn-env' +import type { RemoteCliBridgeEnv } from './ssh-pty-provider-contract' + +const bridge: RemoteCliBridgeEnv = { + binDir: '/remote/orca/bin', + relayDir: '/remote/orca', + nodePath: '/remote/node', + sockPath: '/remote/orca/socket' +} + +describe('SSH CLI path ownership', () => { + it('replaces the client restore directory with the remote bridge', () => { + const env = { PATH: '/usr/bin', ORCA_CLI_BIN_DIR: '/client/orca/bin' } + const result = buildSshPtySpawnEnv({ env, remoteCliBridgeEnv: bridge }) + expect(result.ORCA_CLI_BIN_DIR).toBe(bridge.binDir) + expect(result.PATH).toBe(`${bridge.binDir}:/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe('/client/orca/bin') + }) + + it('clears a client path when no remote bridge is available', () => { + const result = buildSshPtySpawnEnv({ env: { ORCA_CLI_BIN_DIR: '/client/orca/bin' } }) + expect(result.ORCA_CLI_BIN_DIR).toBeUndefined() + }) + + it('does not give a POSIX wrapper a Windows bridge directory', () => { + const result = buildSshPtySpawnEnv({ + env: { Path: 'C:\\Windows', ORCA_CLI_BIN_DIR: '/client/orca/bin' }, + remoteCliBridgeEnv: { ...bridge, binDir: 'C:\\Orca\\bin', pathDelimiter: ';' } + }) + expect(result.ORCA_CLI_BIN_DIR).toBeUndefined() + expect(result.Path).toBe('C:\\Orca\\bin;C:\\Windows') + }) + + it('preserves an explicitly deleted restore key', () => { + const result = buildSshPtySpawnEnv({ + env: { PATH: '/usr/bin' }, + remoteCliBridgeEnv: bridge, + envToDelete: ['ORCA_CLI_BIN_DIR'] + }) + expect(result.ORCA_CLI_BIN_DIR).toBeUndefined() + }) +}) diff --git a/src/main/providers/ssh-pty-spawn-env.ts b/src/main/providers/ssh-pty-spawn-env.ts index 595e750c90d..45e2abca8bd 100644 --- a/src/main/providers/ssh-pty-spawn-env.ts +++ b/src/main/providers/ssh-pty-spawn-env.ts @@ -7,8 +7,13 @@ export function buildSshPtySpawnEnv(args: { remoteCliBridgeEnv?: RemoteCliBridgeEnv }): Record { const merged = { ...args.env } + // The client CLI path cannot be restored on the execution host. + delete merged.ORCA_CLI_BIN_DIR if (args.remoteCliBridgeEnv) { const pathDelimiter = args.remoteCliBridgeEnv.pathDelimiter ?? ':' + if (pathDelimiter === ':') { + merged.ORCA_CLI_BIN_DIR = args.remoteCliBridgeEnv.binDir + } const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null if (pathKey) { const pathValue = merged[pathKey] ?? '' diff --git a/src/main/pty/overlay-mirror.test.ts b/src/main/pty/overlay-mirror.test.ts index 5499d226422..1a741552719 100644 --- a/src/main/pty/overlay-mirror.test.ts +++ b/src/main/pty/overlay-mirror.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, rmSync, writeFileSync, symlinkSync } from 'node:fs' import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import type * as NodePath from 'node:path' @@ -19,6 +19,33 @@ afterEach(() => { }) describe('safeRemoveOverlay', () => { + it('keeps missing owned paths a silent no-op', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-missing-')) + tempRoots.push(root) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + safeRemoveOverlay(join(root, 'missing', 'leaf'), join(root, 'missing')) + expect(warn).not.toHaveBeenCalled() + }) + + it('refuses cleanup through an intermediate symlink and unlinks only a leaf symlink', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-symlink-')) + tempRoots.push(root) + const overlay = join(root, 'overlay') + const outside = join(root, 'outside') + mkdirSync(overlay) + mkdirSync(outside) + const sentinel = join(outside, 'keep.txt') + writeFileSync(sentinel, 'private sentinel') + const linked = join(overlay, 'linked') + symlinkSync(outside, linked, process.platform === 'win32' ? 'junction' : 'dir') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + safeRemoveOverlay(join(linked, 'keep.txt'), overlay) + expect(existsSync(sentinel)).toBe(true) + safeRemoveOverlay(linked, overlay) + expect(existsSync(linked)).toBe(false) + expect(existsSync(sentinel)).toBe(true) + }) + it('removes valid overlay children whose names start with dot-dot', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-overlay-root-')) tempRoots.push(root) diff --git a/src/main/pty/overlay-mirror.ts b/src/main/pty/overlay-mirror.ts index ec1b99d2b92..6a35844f871 100644 --- a/src/main/pty/overlay-mirror.ts +++ b/src/main/pty/overlay-mirror.ts @@ -138,6 +138,33 @@ export function safeRemoveTree(path: string): void { } } +// Why: cleanup must not traverse a symlink parent inside the owned overlay. +function hasSafeOverlayAncestors(root: string, relativeTarget: string): boolean { + let current = root + try { + if (lstatSync(current).isSymbolicLink()) { + return false + } + } catch (error) { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' + } + const segments = relativeTarget.split(sep).filter(Boolean) + for (const [index, segment] of segments.entries()) { + current = join(current, segment) + if (index === segments.length - 1) { + break + } + try { + if (lstatSync(current).isSymbolicLink()) { + return false + } + } catch (error) { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' + } + } + return true +} + // Why: last-line guard against an overlay-root constant ever being // mis-resolved. Any caller that points safeRemoveTree at a path outside its // designated overlay root is refused so a misconfiguration cannot turn into @@ -147,7 +174,13 @@ export function safeRemoveOverlay(overlayDir: string, overlayRoot: string): void const resolvedRoot = resolve(overlayRoot) const resolvedTarget = resolve(overlayDir) const rel = relative(resolvedRoot, resolvedTarget) - if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + if ( + rel === '' || + rel === '..' || + rel.startsWith(`..${sep}`) || + isAbsolute(rel) || + !hasSafeOverlayAncestors(resolvedRoot, rel) + ) { console.warn( `[overlay-mirror] refusing to remove overlay outside root: target=${resolvedTarget} root=${resolvedRoot}` ) diff --git a/src/main/rate-limits/cursor-fetcher.test.ts b/src/main/rate-limits/cursor-fetcher.test.ts index bb07fb90b05..a075c37db99 100644 --- a/src/main/rate-limits/cursor-fetcher.test.ts +++ b/src/main/rate-limits/cursor-fetcher.test.ts @@ -78,6 +78,7 @@ describe('fetchCursorRateLimits', () => { await fetchCursorRateLimits({ authReadResult: session() }) const [url, init] = netFetchMock.mock.calls[0] ?? [] expect(url).toBe('https://cursor.com/api/usage-summary') + expect(init?.credentials).toBe('omit') expect(init?.headers).toMatchObject({ Origin: 'https://cursor.com', Referer: 'https://cursor.com/dashboard', diff --git a/src/main/rate-limits/cursor-fetcher.ts b/src/main/rate-limits/cursor-fetcher.ts index 82bbf1c0e48..d6cbe09cfad 100644 --- a/src/main/rate-limits/cursor-fetcher.ts +++ b/src/main/rate-limits/cursor-fetcher.ts @@ -84,6 +84,8 @@ async function fetchDashboardJson( const res = await net.fetch(url, { // Keep dashboard redirects visible without forwarding session credentials. redirect: 'manual', + // The selected account's Cookie must not be replaced by Electron's session jar. + credentials: 'omit', headers: requestHeaders(session), signal: requestSignal }) diff --git a/src/main/ripgrep/ripgrep-filename-identity-real.test.ts b/src/main/ripgrep/ripgrep-filename-identity-real.test.ts new file mode 100644 index 00000000000..6fc7116bd7d --- /dev/null +++ b/src/main/ripgrep/ripgrep-filename-identity-real.test.ts @@ -0,0 +1,62 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { spawnBundledRipgrep } from './bundled-ripgrep-spawn' +import { buildRgArgsForQuickOpen, normalizeQuickOpenRgLine } from '../../shared/quick-open-filter' +import { buildRgArgs, createAccumulator, ingestRgJsonLine } from '../../shared/text-search' +import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' + +async function capture(root: string, args: string[]): Promise { + const child = spawnBundledRipgrep(args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + output += chunk + }) + child.stderr?.resume() + await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code) => (code === 0 ? resolve() : reject(new Error(`rg exit ${code}`)))) + }) + return output +} + +describe.skipIf(process.platform === 'win32')('real ripgrep POSIX filename identities', () => { + it('lists, searches, and opens literal-backslash and nested names independently', async () => { + const parent = await mkdtemp(join(tmpdir(), 'orca-rg-identity-')) + const root = join(parent, 'repo\\root') + try { + await mkdir(join(root, 'a'), { recursive: true }) + await writeFile(join(root, 'a\\b.txt'), 'needle literal') + await writeFile(join(root, 'a/b.txt'), 'needle nested') + const args = buildRgArgsForQuickOpen({ + searchRoot: '.', + excludePathPrefixes: [], + forceSlashSeparator: false + }) + const listing = await capture(root, args.primary) + const names = listing + .split('\0') + .filter(Boolean) + .map((line) => normalizeQuickOpenRgLine(line, { kind: 'cwd-relative' })) + expect(names.sort()).toEqual(['a/b.txt', 'a\\b.txt'].sort()) + for (const name of names) { + expect(name).not.toBeNull() + if (name === null) { + throw new Error('invalid name') + } + expect(await readFile(joinWorktreeRelativePath(root, name), 'utf8')).toBe( + name === 'a\\b.txt' ? 'needle literal' : 'needle nested' + ) + } + const acc = createAccumulator() + for (const line of (await capture(root, buildRgArgs('needle', '.', {}))).split('\n')) { + ingestRgJsonLine(line, root, acc, 10) + } + expect([...acc.fileMap.values()].map((file) => file.relativePath).sort()).toEqual(names) + expect(acc.truncated).toBe(false) + } finally { + await rm(parent, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/ripgrep/text-search-unicode-real.test.ts b/src/main/ripgrep/text-search-unicode-real.test.ts new file mode 100644 index 00000000000..b56c1794a19 --- /dev/null +++ b/src/main/ripgrep/text-search-unicode-real.test.ts @@ -0,0 +1,123 @@ +import { mkdir, mkdtemp, 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 { spawnBundledRipgrep } from './bundled-ripgrep-spawn' +import { + buildRgArgs, + createAccumulator, + finalize, + ingestRgJsonLine +} from '../../shared/text-search' + +describe('ripgrep Unicode match coordinates', () => { + let root: string + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rg-unicode-')) + }) + afterEach(async () => { + vi.unstubAllEnvs() + await rm(root, { recursive: true, force: true }) + }) + + async function search( + query: string, + useRegex = false, + includePattern?: string, + excludePattern?: string + ) { + const child = spawnBundledRipgrep( + buildRgArgs(query, '.', { useRegex, includePattern, excludePattern }), + { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + let output = '' + child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { + output += chunk + }) + child.stderr?.resume() + await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code) => + code === 0 || code === 1 ? resolve() : reject(new Error(`rg exit ${code}`)) + ) + }) + const acc = createAccumulator() + for (const line of output.split('\n')) { + ingestRgJsonLine(line, root, acc, 2000) + } + return finalize(acc) + } + + it('uses UTF16 columns and lengths for multibyte prefixes and astral matches', async () => { + await writeFile(join(root, 'example.txt'), '😀 café 日本 needle 😀 needle\r\n') + const result = await search('needle|😀', true) + expect( + result.files[0]?.matches.map(({ column, matchLength }) => [column, matchLength]) + ).toEqual([ + [1, 2], + [12, 6], + [19, 2], + [22, 6] + ]) + }) + + it.each(['src/**', '/src/**'])('honors root-relative include glob %s', async (includePattern) => { + await mkdir(join(root, 'src')) + await mkdir(join(root, 'other')) + await writeFile(join(root, 'src', 'match.txt'), 'needle') + await writeFile(join(root, 'src', 'skip.txt'), 'needle') + await writeFile(join(root, 'other', 'match.txt'), 'needle') + const result = await search('needle', false, includePattern, '/src/skip.txt') + expect(result.files.map((file) => [file.filePath, file.relativePath])).toEqual([ + [join(root, 'src', 'match.txt'), 'src/match.txt'] + ]) + }) + + it('keeps navigation and clamped display coordinates aligned', async () => { + const prefix = '日本😀'.repeat(200) + await writeFile(join(root, 'long.txt'), `${prefix}needle`) + const match = (await search('needle')).files[0]?.matches[0] + expect(match?.column).toBe(prefix.length + 1) + expect( + match?.lineContent.slice( + (match.displayColumn ?? 1) - 1, + (match.displayColumn ?? 1) - 1 + match.matchLength + ) + ).toBe('needle') + }) + + it('decodes malformed UTF8 context and maps matches using the original bytes', async () => { + const bytes = Buffer.concat([ + Buffer.from('😀 '), + Buffer.from([0xe2, 0x82, 0xff]), + Buffer.from(' needle é needle\r\n') + ]) + await writeFile(join(root, 'malformed.txt'), bytes) + const content = bytes.toString('utf8').replace(/\n$/, '') + const result = await search('needle') + expect(result.totalMatches).toBe(2) + expect( + result.files[0]?.matches.map((match) => [match.column, match.matchLength, match.lineContent]) + ).toEqual([ + [content.indexOf('needle') + 1, 6, content], + [content.lastIndexOf('needle') + 1, 6, content] + ]) + expect(result.truncated).toBe(false) + }) + + it('ignores external rg config while retaining workspace ignore files', async () => { + const config = join(root, 'config') + await writeFile(config, '--invert-match\n') + vi.stubEnv('RIPGREP_CONFIG_PATH', config) + await writeFile(join(root, '.ignore'), 'ignored.txt\n') + await writeFile(join(root, 'ignored.txt'), 'needle\n') + await mkdir(join(root, 'docs')) + await writeFile(join(root, 'docs', 'visible.txt'), 'needle\nother\n') + const result = await search('needle') + expect(result.files.map((file) => file.relativePath)).toEqual(['docs/visible.txt']) + expect(result.files[0]?.matches[0]?.lineContent).toBe('needle') + }) +}) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index cd87b024c0f..3ef04041a43 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -13,7 +13,6 @@ import { agentSessionOperationKey, type AgentSessionOperationClaim, type AgentSessionOperationDecision, - type AgentSessionOperationOutcome, type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import { @@ -68,9 +67,12 @@ import { import type { AgentSessionStoreState } from './agent-session-record-store-file' import { setAgentSessionTabVisibility, showAgentSessionTabs } from './agent-session-tab-table' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { loadAgentSessionStoreRows } from './agent-session-record-rows' import { AgentSessionStoreTransactions } from './agent-session-store-transactions' +type AgentSessionOperationSettlement = Parameters[1] + export const AGENT_SESSION_LEASE_TTL_MS = 30_000, AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 @@ -296,14 +298,15 @@ export class AgentSessionRecordStore { }): Promise => this.transact((draft) => claimAgentSessionOperationInto(draft, args)) - async recordOperationOutcome(args: { - callerKey?: string - operationId: string - outcome: AgentSessionOperationOutcome - }): Promise { + async recordOperationOutcome(args: AgentSessionOperationSettlement): Promise { await this.transact((draft) => settleAgentSessionOperationInto(draft, args)) } + /** The same settlement, committed by the journal write that makes it true. It changes only the + * ledger, so no record listener is owed. */ + operationOutcomeReceipt = (args: AgentSessionOperationSettlement): JournalOperationReceipt => + this.transactions.receipt((draft) => settleAgentSessionOperationInto(draft, args)) + replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise => this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) diff --git a/src/main/runtime/agent-session-store-transactions.test.ts b/src/main/runtime/agent-session-store-transactions.test.ts index 5ab6cad9e02..ab268c08db7 100644 --- a/src/main/runtime/agent-session-store-transactions.test.ts +++ b/src/main/runtime/agent-session-store-transactions.test.ts @@ -140,6 +140,57 @@ describe('writing a transaction', () => { }) }) +describe('a receipt', () => { + const operationId = `${NOW}-${'7'.repeat(32)}` + const outcome = { status: 'succeeded' as const, sessionId: 'chat-a-0001' } + + async function pendingOperation(): Promise { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + await store.admitOperation({ callerKey: 'client-1', operationId, fingerprint: 'fp', now: NOW }) + return store + } + + async function persistedStatus(): Promise { + const reopened = await openTestAgentSessionRecordStore(root) + return reopened.getOperationRow('client-1', operationId)?.outcome.status + } + + // Written inside the caller's journal transaction, so it commits or rolls back with that write. + it('shows its rows in memory only once committed is called after the commit', async () => { + const store = await pendingOperation() + const receipt = store.operationOutcomeReceipt({ callerKey: 'client-1', operationId, outcome }) + + openTestJournalHostDatabase(root).transaction((db) => { + receipt.write(db) + expect(store.getOperationRow('client-1', operationId)?.outcome.status).toBe('pending') + }) + expect(store.getOperationRow('client-1', operationId)?.outcome.status).toBe('pending') + expect(await persistedStatus()).toBe('succeeded') + + receipt.committed() + expect(store.getOperationRow('client-1', operationId)?.outcome).toEqual(outcome) + }) + + it('leaves memory and rows as they were when the transaction rolls back', async () => { + const store = await pendingOperation() + const receipt = store.operationOutcomeReceipt({ callerKey: 'client-1', operationId, outcome }) + + expect(() => + openTestJournalHostDatabase(root).transaction((db) => { + receipt.write(db) + throw new Error('the journal row was refused') + }) + ).toThrow('the journal row was refused') + + expect(store.getOperationRow('client-1', operationId)?.outcome.status).toBe('pending') + expect(await persistedStatus()).toBe('pending') + // The next store transaction diffs from what committed, not from the discarded draft. + await store.setConversationName('chat-a-0001', 'after') + expect(await persistedStatus()).toBe('pending') + }) +}) + describe('a change a load would refuse', () => { it('rejects a tab id that could not prefix a pane key, and keeps memory and rows as they were', async () => { const store = await openTestAgentSessionRecordStore(root) diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index 879ff4a160e..31bddace873 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -8,6 +8,7 @@ // the async boundary every awaiting caller was written against. import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards' import type { AgentSessionStoreState } from './agent-session-record-store-file' @@ -104,6 +105,31 @@ export class AgentSessionStoreTransactions { return run } + /** + * `apply`'s rows, written inside a journal transaction the caller runs and adopted once it + * commits. Exact without the queue: `write` and `committed` run in one synchronous step, so no + * store transaction can commit between the draft's staging and its adoption. + */ + receipt(apply: (draft: AgentSessionStoreState) => void): JournalOperationReceipt { + let staged: StagedStoreTransaction | null = null + return { + write: (db) => { + if (this.journalDatabase.readOnly) { + throw readOnlyStoreRefusal() + } + staged = this.stage(apply) + const writes = staged.writes + if (writes) { + writeAgentSessionStoreRows(db, writes) + } + }, + committed: () => { + staged?.adopt() + staged = null + } + } + } + private commit(apply: (draft: AgentSessionStoreState) => T, inMemoryWhenReadOnly: boolean): T { const readOnly = this.journalDatabase.readOnly if (readOnly && !inMemoryWhenReadOnly) { diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index 3265d8ac166..29c1a636ffa 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -19,6 +19,25 @@ import type { AgentPromptActivity } from './agent-prompt-submission-verification import { readTuiIdleHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends OrcaRuntimeWithAgentPromptRequestCorrelation { + readOpenCodeStartupPromptOwner(ptyId: string, incarnationId: string, launchToken: string) { + const pty = this.ptysById.get(ptyId) + if (!pty || pty.incarnationId !== incarnationId) { + return null + } + // The runtime launch route admits identity immediately after low-level spawn returns. + if (pty.launchToken === null && pty.launchAgent === null && pty.launchIncarnationId === null) { + return 'pending' as const + } + if ( + pty.launchIncarnationId !== incarnationId || + pty.launchToken !== launchToken || + (pty.launchAgent !== 'opencode' && pty.launchAgent !== 'opencode2') + ) { + return null + } + return this.terminalRunFacts.read(ptyId, incarnationId) + } + protected resolveAuthoritativeTerminalWaitPermission( terminal: RuntimeTerminalAgentStatusSnapshot, explicitStatus: { status: AgentStatus; updatedAt: number } | null, diff --git a/src/main/runtime/quarter-circle-title-send-authorization.test.ts b/src/main/runtime/quarter-circle-title-send-authorization.test.ts index 8def14f0d99..afaa8362409 100644 --- a/src/main/runtime/quarter-circle-title-send-authorization.test.ts +++ b/src/main/runtime/quarter-circle-title-send-authorization.test.ts @@ -1,7 +1,7 @@ // STA-4028 (regression from #13925): quarter circles are ordinary progress glyphs — // ora, installers, any TUI animates them — so a title carrying nothing else must not // authorize a guarded send, which auto-submits with Enter into whatever owns the pane. -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' import { detectAgentStatusFromTitle } from '../../shared/agent-detection' @@ -18,6 +18,11 @@ const TAB_ID = 'tab-1' const WORKTREE_ID = 'wt-1' const PTY_ID = 'pty-1' +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + // Captured from Claude Code 2.1.228 while it read this repository's package.json. const SPINNER_ONLY_TITLE = '◑ Check package version in package.json' const SPINNER_WITH_IDENTITY_TITLE = '◐ Claude Code' @@ -91,6 +96,42 @@ async function createRuntimeWithTitle( const AUTHORIZED = 'authorized' +// The 150ms foreground tick first crosses its unchanged 6,500ms budget at 6,600ms. +const WRAPPER_REJECTION_TICK_MS = 6_600 +const NO_AGENT_GUARD_DEADLINE_MS = 1_050 + +async function readAgentEvidenceAtDeadline( + read: () => Promise, + deadlineMs: number +): Promise { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + try { + let settled = false + const result = read() + void result.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await vi.advanceTimersByTimeAsync(deadlineMs - 1) + expect(settled, 'agent evidence settled before its deadline').toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(settled, 'agent evidence did not settle at its deadline').toBe(true) + expect(vi.getTimerCount()).toBe(0) + return await result + } finally { + try { + await vi.runOnlyPendingTimersAsync() + } finally { + vi.clearAllTimers() + vi.useRealTimers() + } + } +} + async function guardedSendResult(runtime: OrcaRuntimeService, handle: string): Promise { try { await assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) @@ -104,10 +145,18 @@ describe('quarter-circle title send authorization (STA-4028)', () => { it('refuses a guarded send when a quarter-circle spinner is the only agent evidence', async () => { const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, 'node') - await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ - isRunningAgent: false - }) - await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + expect( + await readAgentEvidenceAtDeadline( + () => runtime.getTerminalAgentStatus(handle), + WRAPPER_REJECTION_TICK_MS + ) + ).toMatchObject({ isRunningAgent: false }) + expect( + await readAgentEvidenceAtDeadline( + () => guardedSendResult(runtime, handle), + WRAPPER_REJECTION_TICK_MS + ) + ).toBe('terminal_guard_no_agent') }) it('refuses a guarded send when the foreground process cannot be read at all', async () => { @@ -115,7 +164,12 @@ describe('quarter-circle title send authorization (STA-4028)', () => { // stays a refusal rather than falling back to the glyph. const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, null) - await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + expect( + await readAgentEvidenceAtDeadline( + () => guardedSendResult(runtime, handle), + NO_AGENT_GUARD_DEADLINE_MS + ) + ).toBe('terminal_guard_no_agent') }) it('authorizes a guarded send when the foreground process is a recognized agent', async () => { @@ -158,7 +212,12 @@ describe('quarter-circle title send authorization (STA-4028)', () => { false ) - await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + expect( + await readAgentEvidenceAtDeadline( + () => guardedSendResult(runtime, handle), + NO_AGENT_GUARD_DEADLINE_MS + ) + ).toBe('terminal_guard_no_agent') }) it('does not carry managed Claude identity into a replacement PTY incarnation', async () => { @@ -198,9 +257,12 @@ describe('quarter-circle title send authorization (STA-4028)', () => { await expect(runtime.getTerminalAgentStatus(replacementHandle)).resolves.toMatchObject({ isRunningAgent: false }) - await expect(guardedSendResult(runtime, replacementHandle)).resolves.toBe( - 'terminal_guard_no_agent' - ) + expect( + await readAgentEvidenceAtDeadline( + () => guardedSendResult(runtime, replacementHandle), + NO_AGENT_GUARD_DEADLINE_MS + ) + ).toBe('terminal_guard_no_agent') }) it('authorizes a guarded send when the busy title itself names the agent', async () => { diff --git a/src/main/runtime/ripgrep-filename-identity.test.ts b/src/main/runtime/ripgrep-filename-identity.test.ts new file mode 100644 index 00000000000..b8ef50a76b6 --- /dev/null +++ b/src/main/runtime/ripgrep-filename-identity.test.ts @@ -0,0 +1,82 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' +import { buildExcludePathPrefixes, normalizeQuickOpenRgLine } from '../../shared/quick-open-filter' +import { createAccumulator, ingestRgJsonLine } from '../../shared/text-search' + +it.each(['/native/repo\\root', '/ssh/repo\\root'])( + 'preserves POSIX search identities under %s independently of client platform', + (root) => { + const acc = createAccumulator() + for (const name of ['a\\b.txt', 'a/b.txt']) { + const relative = normalizeQuickOpenRgLine(`./${name}`, { kind: 'cwd-relative' }) + expect(relative).toBe(name) + expect( + normalizeQuickOpenRgLine(`${root}/${name}`, { kind: 'absolute', rootPath: root }) + ).toBe(name) + expect(joinWorktreeRelativePath(root, normalizeRuntimeRelativePath(name, root))).toBe( + `${root}/${name}` + ) + ingestRgJsonLine( + JSON.stringify({ + type: 'match', + data: { + path: { text: `${root}/${name}` }, + lines: { text: 'needle' }, + line_number: 1, + submatches: [{ start: 0, end: 6 }] + } + }), + root, + acc, + 10 + ) + } + expect([...acc.fileMap.values()].map((file) => file.relativePath)).toEqual([ + 'a\\b.txt', + 'a/b.txt' + ]) + expect(buildExcludePathPrefixes(root, [`${root}/a\\b`])).toEqual(['a\\b']) + } +) + +it.each(['C:\\repo', '\\\\server\\share\\repo'])( + 'preserves Windows separator compatibility under %s', + (root) => { + expect(joinWorktreeRelativePath(root, normalizeRuntimeRelativePath('a\\b.txt', root))).toBe( + `${root}\\a\\b.txt` + ) + expect( + normalizeQuickOpenRgLine(`${root}\\a\\b.txt`, { kind: 'absolute', rootPath: root }) + ).toBe('a/b.txt') + } +) + +describe.skipIf(process.platform === 'win32')('real POSIX filename collision', () => { + it('opens both literal-backslash and nested files without changing identity', async () => { + const parent = await mkdtemp(join(tmpdir(), 'orca-rg-path-')) + const root = join(parent, 'repo\\root') + try { + await mkdir(join(root, 'a'), { recursive: true }) + await writeFile(join(root, 'a\\b.txt'), 'literal') + await writeFile(join(root, 'a/b.txt'), 'nested') + for (const [name, expected] of [ + ['a\\b.txt', 'literal'], + ['a/b.txt', 'nested'] + ]) { + const relative = normalizeQuickOpenRgLine(`./${name}`, { kind: 'cwd-relative' }) + expect(relative).toBe(name) + expect( + await readFile( + joinWorktreeRelativePath(root, normalizeRuntimeRelativePath(name, root)), + 'utf8' + ) + ).toBe(expected) + } + } finally { + await rm(parent, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts index 77d44ece745..459ae5c75a4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts @@ -56,4 +56,66 @@ describe('agentSession.modelCatalog', () => { await call('agentSession.modelCatalog', { agent: 'claude' }, STRUCTURED_CLIENT) expect(read).toHaveBeenCalledWith({ agent: 'claude' }) }) + + it('passes a wait for the listing through to the catalog', async () => { + await call( + 'agentSession.modelCatalog', + { agent: 'codex', sessionId: SESSION, waitForListing: true }, + STRUCTURED_CLIENT + ) + expect(read).toHaveBeenCalledWith({ agent: 'codex', sessionId: SESSION, waitForListing: true }) + }) +}) + +describe('agentSession.modelCatalog before anything built the host', () => { + const read = vi.fn(async () => ({ + origin: 'probe' as const, + models: [{ id: 'gpt-host', label: 'GPT Host', isDefault: true, efforts: [] }], + fetchedAt: 1 + })) + const installHost = vi.fn(async () => { + setStructuredAgentSessionHost(Object.assign(hostStub(), { deps: { modelCatalog: { read } } })) + }) + + beforeEach(() => { + read.mockClear() + installHost.mockClear() + clearStructuredHostStub() + }) + + // A new chat's picker reads before its create lands; on a host with no saved chats nothing else + // has built the host yet, and a refusal here left the picker on the client's built-in list. + it('builds the host for a structured chat and answers from its catalog', async () => { + const reply = await call( + 'agentSession.modelCatalog', + { agent: 'codex', sessionId: SESSION }, + STRUCTURED_CLIENT, + { ensureStructuredAgentSessionHost: installHost } + ) + expect(installHost).toHaveBeenCalledTimes(1) + expect(reply).toMatchObject({ ok: true, result: { origin: 'probe' } }) + expect(read).toHaveBeenCalledWith({ agent: 'codex', sessionId: SESSION }) + }) + + // Terminal-backed chat reads with no session: a host that runs no structured chat keeps its + // journal closed, and the read falls back to the CLI listing. + it('does not build the host for a read that names no session', async () => { + const reply = await call('agentSession.modelCatalog', { agent: 'codex' }, STRUCTURED_CLIENT, { + ensureStructuredAgentSessionHost: installHost + }) + expect(installHost).not.toHaveBeenCalled() + expect(reply).toMatchObject({ ok: false }) + expect(read).not.toHaveBeenCalled() + }) + + it('does not build the host for a client that cannot read structured sessions', async () => { + const reply = await call( + 'agentSession.modelCatalog', + { agent: 'codex', sessionId: SESSION }, + { clientKind: 'runtime', clientCapabilities: [] }, + { ensureStructuredAgentSessionHost: installHost } + ) + expect(installHost).not.toHaveBeenCalled() + expect(reply).toMatchObject({ ok: false }) + }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 19e93689577..3cd86c876ad 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -11,7 +11,7 @@ import { defineMethod } from '../core' import { requireInstalledStructuredHost, - requireStructuredHost as requireHost + requireStructuredHost } from './structured-agent-session-gate' import { ModelCatalogParams, OptionsParams } from './structured-agent-session-schemas' @@ -25,8 +25,14 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ defineMethod({ name: 'agentSession.modelCatalog', params: ModelCatalogParams, + // A structured chat's read names its session and builds the host, since it may come first; + // terminal-backed chat's session-less read must not open the journal where none runs. handler: async ({ worktree, ...params }, ctx) => { - const catalog = requireHost(ctx).deps.modelCatalog + const host = + params.sessionId === undefined + ? requireStructuredHost(ctx) + : await requireInstalledStructuredHost(ctx) + const catalog = host.deps.modelCatalog if (!catalog) { return { origin: 'unknown' as const } } diff --git a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts index 85e01116c3c..e143535d659 100644 --- a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts +++ b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts @@ -14,9 +14,7 @@ import { type TerminalOutputMeta } from './terminal-output-frame-chunks' -// Byte-for-byte reference: the pre-optimization implementation, copied verbatim. -// It accumulates `chunk += part` over `for (const part of data)` and measures each -// code point through the shared clipboard measurer. +// The legacy splitter, caching only its pure byte counts for repeated code points. function legacyByteLength(data: string): number { return measureClipboardTextByteLength(data).byteLength } @@ -64,6 +62,7 @@ function* legacyIterateTerminalOutputFrameChunks( let chunkStartOffset = 0 let offset = 0 let delayedChunk: { text: string; seq?: number } | null = null + const partByteLengths = new Map() const takeChunk = (): { text: string; seq?: number } | null => { if (!chunk) { @@ -78,7 +77,11 @@ function* legacyIterateTerminalOutputFrameChunks( } for (const part of data) { - const partBytes = legacyByteLength(part) + let partBytes = partByteLengths.get(part) + if (partBytes === undefined) { + partBytes = legacyByteLength(part) + partByteLengths.set(part, partBytes) + } if (chunkBytes > 0 && chunkBytes + partBytes > TERMINAL_STREAM_CHUNK_BYTES) { const nextChunk = takeChunk() if (nextChunk) { @@ -522,34 +525,4 @@ describe('iterateTerminalOutputFrameChunks equivalence with the pre-optimization expect([...iterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1) expect([...legacyIterateTerminalOutputFrameChunks(overByOne)].length).toBeGreaterThan(1) }) - - // The chunking loop is only reached when rawLength === data.length and transformed - // is falsy, which makes canPreserveChunkSeq === (typeof meta.seq === 'number') and - // therefore shouldDelayFinalSeq unconditionally false. The branch survives here - // (it also survived in the pre-optimization code) purely as defence in depth. - it('never reaches the delayed-final-seq branch for any meta shape', () => { - for (const data of ['', 'a', 'abc', 'x'.repeat(200)]) { - for (const seq of [undefined, 0, 5] as (number | undefined)[]) { - for (const rawDelta of [undefined, 0, 1, -1] as (number | undefined)[]) { - for (const transformed of [undefined, false, true] as (boolean | undefined)[]) { - const meta: TerminalOutputMeta = {} - if (seq !== undefined) { - meta.seq = seq - } - if (rawDelta !== undefined) { - meta.rawLength = data.length + rawDelta - } - if (transformed !== undefined) { - meta.transformed = transformed - } - const rawLength = meta.rawLength ?? data.length - const reachesChunkLoop = !meta.transformed && rawLength === data.length - const canPreserveChunkSeq = typeof meta.seq === 'number' && rawLength === data.length - const shouldDelayFinalSeq = !canPreserveChunkSeq && typeof meta.seq === 'number' - expect(reachesChunkLoop && shouldDelayFinalSeq, JSON.stringify(meta)).toBe(false) - } - } - } - } - }) }) diff --git a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts index 8ed435fddc7..83e30a15f47 100644 --- a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts +++ b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split class members. +import { RipgrepSearchDiagnostics } from '../../shared/ripgrep-search-diagnostics' import { SearchSubprocessLineAccumulator } from '../../shared/search-subprocess-lines' import { RuntimeFileCommandsWithSearchRuntimeFiles } from './runtime-file-commands-search-runtime-files' import type { SearchOptions, SearchResult } from '../../shared/code-search-types' @@ -50,20 +51,26 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC return new Promise((resolvePromise, rejectPromise) => { const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}` - const rgArgs = buildRgArgs(options.query, authorizedRootPath, options) + const rgArgs = buildRgArgs(options.query, '.', options) const previousChild = this.activeRuntimeTextSearches.get(searchKey) if (previousChild) { killSpawnedRipgrepProcess(previousChild) } const acc = createAccumulator() - const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) + const lines = new SearchSubprocessLineAccumulator() + const diagnostics = new RipgrepSearchDiagnostics() let resolved = false let processErrorObserved = false let unavailableExitObserved = false let child: ChildProcessHandle | null = null const transformAbsPath = wslDistroForOutput - ? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p) + ? (p: string): string | null => + p.includes('\\') + ? null + : p.startsWith('/') + ? toWindowsWslPath(p, wslDistroForOutput) + : p : undefined const finish = (result: SearchResult | PromiseLike): void => { @@ -77,7 +84,10 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC cleanupListeners() resolvePromise(result) } - const resolveOnce = (): void => finish(finalize(acc)) + const resolveOnce = (code = 0, signal: NodeJS.Signals | null = null): void => { + const error = diagnostics.failure(code, signal, acc) + finish(error ? Promise.reject(error) : finalize(acc)) + } const rejectUnavailable = (): void => finish(Promise.reject(bundledRipgrepUnavailableError())) let killTimeout: ReturnType | null = null @@ -133,10 +143,16 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC nextChild.stdout?.setEncoding('utf-8') const onStdoutData = (chunk: string): void => { - lines.push(chunk, processLine) + if (!lines.push(chunk, processLine)) { + acc.truncated = true + if (child) { + killSpawnedRipgrepProcess(child) + } + resolveOnce() + } } - const onStderrData = (): void => { - // Drain stderr so rg cannot block on a full pipe. + const onStderrData = (chunk: Buffer): void => { + diagnostics.append(chunk) } const onError = (error: NodeJS.ErrnoException): void => { processErrorObserved = true @@ -171,7 +187,10 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC }) return } - resolveOnce() + finish(Promise.reject(error)) + if (child) { + killSpawnedRipgrepProcess(child) + } } const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { // Why first: this code is above rg's own 0/1/2, so the unavailable check would otherwise @@ -190,11 +209,11 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC rejectUnavailable() return } - const tail = lines.finish() + const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null if (tail !== null) { processLine(tail) } - resolveOnce() + resolveOnce(code ?? -1, signal) } nextChild.stdout?.on('data', onStdoutData) @@ -229,7 +248,7 @@ export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileC worktree: target.worktree, path: joinWorktreeRelativePath( target.worktree.path, - normalizeRuntimeRelativePath(relativePath) + normalizeRuntimeRelativePath(relativePath, target.worktree.path) ), executionHostId: target.executionHostId })) diff --git a/src/main/runtime/runtime-relative-paths.ts b/src/main/runtime/runtime-relative-paths.ts index 1ec252e24f2..1f5ab8e71cb 100644 --- a/src/main/runtime/runtime-relative-paths.ts +++ b/src/main/runtime/runtime-relative-paths.ts @@ -2,15 +2,21 @@ import { posix, win32 } from 'node:path' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' export function joinWorktreeRelativePath(rootPath: string, relativePath: string): string { - const normalizedRelativePath = relativePath.replace(/\\/g, '/') + const normalizedRelativePath = isWindowsAbsolutePathLike(rootPath) + ? relativePath.replace(/\\/g, '/') + : relativePath if (isWindowsAbsolutePathLike(rootPath)) { return win32.join(rootPath.replace(/\//g, '\\'), ...normalizedRelativePath.split('/')) } return posix.join(rootPath, ...normalizedRelativePath.split('/')) } -export function normalizeRuntimeRelativePath(relativePath: string): string { - const normalized = relativePath.replace(/\\/g, '/').replace(/\/+$/, '') +export function normalizeRuntimeRelativePath(relativePath: string, rootPath?: string): string { + const path = + rootPath !== undefined && !isWindowsAbsolutePathLike(rootPath) + ? relativePath + : relativePath.replace(/\\/g, '/') + const normalized = path.replace(/\/+$/, '') if (normalized === '') { return '' } diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 36ab233a915..57e632a6c85 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -35,11 +35,17 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { createCoordinatorMailObservationClock } from './structured-chat-coordinator-observation-clock.test-fixture' // The turns a send or Stop is waiting on to open, so a test knows the wait began. const openWaits = vi.hoisted(() => { - const turnIds: string[] = [] - return { turnIds } + const state: { + turnIds: string[] + ended: string[] + began: (() => void) | null + releaseAll: (() => void) | null + } = { turnIds: [], ended: [], began: null, releaseAll: null } + return state }) vi.mock('../codex/codex-structured-turn-open-wait', async (importOriginal) => { const actual = await importOriginal() @@ -47,11 +53,16 @@ vi.mock('../codex/codex-structured-turn-open-wait', async (importOriginal) => { ...actual, createCodexTurnOpenWaits: () => { const waits = actual.createCodexTurnOpenWaits() + openWaits.releaseAll = waits.releaseAll return { ...waits, wait: (turnId: string, withinMs: number) => { openWaits.turnIds.push(turnId) - return waits.wait(turnId, withinMs) + const pending = waits.wait(turnId, withinMs) + openWaits.began?.() + return pending.then(() => { + openWaits.ended.push(turnId) + }) } } } @@ -170,6 +181,8 @@ function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessage beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-codex-turn-end-')) openWaits.turnIds.length = 0 + openWaits.ended.length = 0 + openWaits.began = null answers = 0 steers = 0 interrupts = 0 @@ -239,6 +252,8 @@ beforeEach(async () => { }) afterEach(async () => { + openWaits.releaseAll?.() + vi.useRealTimers() await stopStructuredAgentSessionRuntime() await rm(root, { recursive: true, force: true }) }) @@ -341,7 +356,14 @@ describe('a queued card sent now into the turn a Stop ends', () => { ) // A drain ignoring the pause re-sends only after the stopped turn ends: watch past that. await vi.waitFor(() => expect(turns.turnId).toBeNull()) - await new Promise((resolve) => setTimeout(resolve, 2_500)) + const clock = createCoordinatorMailObservationClock(() => host, SESSION) + clock.start() + try { + await clock.observe(2_500) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + } finally { + clock.restore() + } expect(steers + answers).toBe(2) expect(await sends(cardId)).toEqual([{ origin: 'client', verdict: 'withdrawn' }]) }, 20_000) @@ -454,9 +476,17 @@ describe('a Stop sent after Codex answered a cold send, before it opened the tur const sent = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) + await host.flushStreamedEvents(SESSION) + const began = Promise.withResolvers() + openWaits.began = began.resolve + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) const stopping = stop() + await began.promise // Without the wait, it would reach Codex now, which would refuse it, and be done. - expect(await settledWithin(stopping, 1_000)).toBe('held') + const held = settledWithin(stopping, 1_000) + await vi.advanceTimersByTimeAsync(1_000) + expect(openWaits.ended).toEqual([]) + expect(await held).toBe('held') expect(interrupts).toBe(0) turns.start() await stopping @@ -480,8 +510,15 @@ describe('a Stop in that window that the turn never opens for', () => { async function waitingStop(): Promise<{ stopping: Promise }> { await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) + await host.flushStreamedEvents(SESSION) + const began = Promise.withResolvers() + openWaits.began = began.resolve + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) const stopping = stop() - expect(await settledWithin(stopping, 200)).toBe('held') + await began.promise + const held = settledWithin(stopping, 200) + await vi.advanceTimersByTimeAsync(200) + expect(await held).toBe('held') return { stopping } } @@ -499,10 +536,17 @@ describe('a Stop in that window that the turn never opens for', () => { const { stopping } = await waitingStop() const closing = host.close(SESSION, 'evict') + const closedWithinBound = settledWithin( + closing, + CODEX_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS + ) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 201) + expect(openWaits.ended).toEqual([]) + expect(childCloses).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(openWaits.ended).toEqual(['turn-1']) - expect( - await settledWithin(closing, CODEX_TURN_OPEN_WAIT_MS + CHILD_EVICTION_TIMEOUT_MS) - ).not.toBe('held') + expect(await closedWithinBound).not.toBe('held') expect(await settledWithin(stopping, 0)).not.toBe('held') expect(childCloses).toBe(1) expect(interrupts).toBe(0) @@ -512,9 +556,16 @@ describe('a Stop in that window that the turn never opens for', () => { it('lets the app quit behind it within the eviction budget, and still close the child', async () => { await waitingStop() - expect( - await settledWithin(stopStructuredAgentSessionRuntime(), CHILD_EVICTION_TIMEOUT_MS) - ).not.toBe('held') + const quitWithinBound = settledWithin( + stopStructuredAgentSessionRuntime(), + CHILD_EVICTION_TIMEOUT_MS + ) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 201) + expect(openWaits.ended).toEqual([]) + expect(childCloses).toBe(0) + await vi.advanceTimersByTimeAsync(1) + expect(openWaits.ended).toEqual(['turn-1']) + expect(await quitWithinBound).not.toBe('held') expect(childCloses).toBe(1) }) }) diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 1289e979ad6..095aad2a8a5 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -70,6 +70,7 @@ describe('structuredSessionChildIdentityEnv', () => { // For a CLI that predates the id, which refuses on it instead of guessing a sibling. ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca'), + ORCA_CLI_BIN_DIR: SHIM_DIR, // The instance that minted the id, so any current CLI dials it rather than the default. ORCA_USER_DATA_PATH: USER_DATA }) @@ -131,6 +132,7 @@ describe('structuredSessionChildIdentityEnv', () => { expect(env.PATH).toBeUndefined() // The native launcher: `orca.cmd` refuses message bodies cmd.exe would mangle. expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca.exe')) + expect(env.ORCA_CLI_BIN_DIR).toBeUndefined() }) it('unpackaged, through the dev launcher dir', () => { diff --git a/src/main/runtime/terminal-run-facts.test.ts b/src/main/runtime/terminal-run-facts.test.ts index 85460b9fc27..1966a405949 100644 --- a/src/main/runtime/terminal-run-facts.test.ts +++ b/src/main/runtime/terminal-run-facts.test.ts @@ -2,6 +2,21 @@ import { describe, expect, it } from 'vitest' import { TerminalRunFactsRegister } from './terminal-run-facts' describe('terminal run facts', () => { + it('keeps driving input before publication across the exact reserved commit', () => { + const facts = new TerminalRunFactsRegister() + facts.recordInput('pending', 'driving', 'x', 100) + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-1' }) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-1' }) + expect(facts.read('pending', 'inc-1').firstUserInputAt).toBe(100) + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-2' }) + facts.recordInput('pending', 'driving', 'x', 200) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-2' }) + expect(facts.read('pending', 'inc-2').firstUserInputAt).toBe(200) + facts.delete('pending') + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-3' }) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-3' }) + expect(facts.read('pending', 'inc-3').firstUserInputAt).toBeNull() + }) it('reads a run main never saw committed as not fresh', () => { expect(new TerminalRunFactsRegister().read('pty-1', 'inc-1')).toEqual({ freshSpawn: false, diff --git a/src/main/runtime/terminal-run-facts.ts b/src/main/runtime/terminal-run-facts.ts index b20f64985e4..b1c029a767c 100644 --- a/src/main/runtime/terminal-run-facts.ts +++ b/src/main/runtime/terminal-run-facts.ts @@ -42,12 +42,38 @@ export class TerminalRunFactsRegister { private readonly runsByPtyId = new Map() // Why apart from the run record: input must count on a PTY main adopted without a commit. private readonly lastInputAtByPtyId = new Map() + private readonly pendingByPtyId = new Map< + string, + { incarnationId: string | null; firstInputAt: number | null } + >() + + reserveSpawnCommit(commit: TerminalSpawnCommit): void { + if (!commit.incarnationId) { + return + } + const prior = this.pendingByPtyId.get(commit.id) + this.pendingByPtyId.set(commit.id, { + incarnationId: commit.incarnationId, + firstInputAt: + prior?.incarnationId === null || prior?.incarnationId === commit.incarnationId + ? prior.firstInputAt + : null + }) + } + + discardSpawnCommit(commit: TerminalSpawnCommit): void { + if (this.pendingByPtyId.get(commit.id)?.incarnationId === (commit.incarnationId ?? null)) { + this.pendingByPtyId.delete(commit.id) + } + } /** Once per process: a re-registration of the same incarnation keeps its facts, and so does a * reattach or adoption of the running process unless its incarnation shows another process. */ recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { const incarnationId = commit.incarnationId ?? null const previous = this.runsByPtyId.get(commit.id) + const pending = this.pendingByPtyId.get(commit.id) + this.pendingByPtyId.delete(commit.id) if (incarnationId !== null && previous?.incarnationId === incarnationId) { return } @@ -60,7 +86,11 @@ export class TerminalRunFactsRegister { this.runsByPtyId.set(commit.id, { incarnationId, spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin, - firstUserInputAt: sameProcess ? (previous?.firstUserInputAt ?? null) : null + firstUserInputAt: sameProcess + ? (previous?.firstUserInputAt ?? null) + : pending?.incarnationId === incarnationId + ? pending.firstInputAt + : null }) } @@ -73,6 +103,14 @@ export class TerminalRunFactsRegister { } this.lastInputAtByPtyId.set(ptyId, now) const run = this.runsByPtyId.get(ptyId) + if (inputKind === 'driving') { + const pending = this.pendingByPtyId.get(ptyId) + if (pending) { + pending.firstInputAt ??= now + } else if (!run) { + this.pendingByPtyId.set(ptyId, { incarnationId: null, firstInputAt: now }) + } + } if (run && inputKind === 'driving') { run.firstUserInputAt ??= now } @@ -99,5 +137,6 @@ export class TerminalRunFactsRegister { delete(ptyId: string): void { this.runsByPtyId.delete(ptyId) this.lastInputAtByPtyId.delete(ptyId) + this.pendingByPtyId.delete(ptyId) } } diff --git a/src/main/shell-startup-feature-channel.test.ts b/src/main/shell-startup-feature-channel.test.ts index e21269bfa91..0001a98641e 100644 --- a/src/main/shell-startup-feature-channel.test.ts +++ b/src/main/shell-startup-feature-channel.test.ts @@ -17,6 +17,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './pty/posix-shell-startup-command' import { selectShellStartupFeatures } from './shell-startup-features' +import { prependOrcaCliDirToChildPath } from './cli/orca-cli-child-path' import { runZshPty } from './zsh-startup-hook-pty-harness' import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' import { @@ -119,6 +120,46 @@ describePosix('zsh launch config', () => { rmSync(userDataPath, { recursive: true, force: true }) }) + itWithZsh.each([false, true])( + 'restores this app CLI after zsh startup resets PATH and replaces prompt hooks %s', + async (replacePromptHooks) => { + const cliBin = join(userDataPath, 'cli', 'bin') + const ambientBin = join(userDataPath, 'ambient-bin') + mkdirSync(cliBin, { recursive: true }) + mkdirSync(ambientBin) + for (const bin of [cliBin, ambientBin]) { + const launcher = join(bin, 'orca-dev') + writeFileSync(launcher, '#!/bin/sh\nexit 0\n') + chmodSync(launcher, 0o755) + } + writeFileSync( + join(userDataPath, '.zshrc'), + `export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${replacePromptHooks ? 'precmd_functions=()\n' : ''}` + ) + const env: Record = { + ...process.env, + HOME: userDataPath, + USERPROFILE: userDataPath, + PATH: `${ambientBin}:/usr/bin:/bin` + } + const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath }) + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env, + ...PLAIN_PANE, + hasStartupCommand: true + }) + const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellLaunchConfig(ZSH_PATH, features) + const result = await runZshPty({ + env: { ...env, ...config.env }, + commands: ['ORCA_LOOKUP=$(command -v orca-dev)'], + report: ['ORCA_LOOKUP'] + }) + expect(result.values.ORCA_LOOKUP).toBe(launcher) + } + ) + it('publishes exactly the selected features, whatever process.env holds', async () => { process.env.ORCA_SHELL_FEATURES = 'overlay,markers,ready,identity' const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index cb7c2fca2e9..7b5f922e89f 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -102,8 +102,11 @@ export const ZSH_USER_ZSHENV_SOURCE_BLOCK = `{ [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( \${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( \${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi }` // Why: daemon, local, and relay wrappers must preserve one Bash prompt-hook contract. diff --git a/src/main/ssh/ssh-relay-gc-listing.test-fixture.ts b/src/main/ssh/ssh-relay-gc-listing.test-fixture.ts new file mode 100644 index 00000000000..d2e5da79387 --- /dev/null +++ b/src/main/ssh/ssh-relay-gc-listing.test-fixture.ts @@ -0,0 +1,148 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import type { ClientChannel } from 'ssh2' +import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-exec-command' + +export const SSH_EXEC_OUTPUT_CAP_CHARS = 1024 * 1024 + +class RelayGcShellChannel extends PassThrough implements ClientChannel { + stdin: this = this + stdout: this = this + stderr: ReturnType['stderr'] + server = false as const + type = 'session' as const + subtype = 'exec' as const + incoming: unknown = null + outgoing: unknown = null + private readonly exited = Promise.withResolvers() + + constructor(private readonly child: ReturnType) { + super({ autoDestroy: false, emitClose: false }) + this.stderr = child.stderr + child.stdout.pipe(this) + child.stdout.on('error', (error) => this.emit('error', error)) + child.on('error', (error) => { + this.exited.resolve() + this.emit('error', error) + }) + child.on('close', (code) => { + this.exited.resolve() + this.emit('close', code) + }) + child.stdin.end() + } + + async dispose(): Promise { + this.close() + await this.exited.promise + this.destroy() + } + + close(): void { + this.child.kill() + } + + eof(): void { + this.end() + } + + setWindow(): void { + throw new Error('GC listing does not resize a terminal') + } + + signal(): void { + throw new Error('GC listing does not signal a terminal') + } + + exit(): void { + throw new Error('GC listing does not set a remote exit status') + } +} + +export async function runCappedRelayGcShellCommand(command: string): Promise { + let channel: RelayGcShellChannel | undefined + const connection: Pick = { + exec: async (shellCommand) => { + channel = new RelayGcShellChannel( + spawnProcess({ program: '/bin/sh', args: ['-c', shellCommand] }) + ) + return channel + }, + usesSystemSshTransport: () => false + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: execCommand only reads exec and usesSystemSshTransport; both methods are checked above. + const sshConnection = connection as SshConnection + try { + return await execCommand(sshConnection, command) + } finally { + await channel?.dispose() + } +} + +export function createRelayGcListingFixture(): { + root: string + findCommand: string + validNames: string[] + fillValidEntryBoundary: () => void + dispose: () => void +} { + const workspace = mkdtempSync(join(tmpdir(), 'orca-relay-gc-scale-')) + let root = workspace + try { + // Long Unicode paths cross the SSH character cap with fewer real directories. + for (let index = 0; index < 4; index += 1) { + root = join(root, `gc-context-${index}-ü-${'x'.repeat(150)}`) + mkdirSync(root) + } + const validNames = ['relay-0.1.0+aaa', 'relay-0.1.0+bbb'] + const stageName = (index: number): string => + `relay-9.9.9+abc.upload-${String(index).padStart(12, '0')}` + const stageCount = + Math.ceil(SSH_EXEC_OUTPUT_CAP_CHARS / (join(root, stageName(0)).length + 1)) + 1 + for (let index = 0; index < stageCount; index += 1) { + mkdirSync(join(root, stageName(index))) + } + for (const name of validNames) { + mkdirSync(join(root, name)) + } + mkdirSync(join(root, 'relay-0.1.0+abc.upload-000000000000')) + mkdirSync(join(root, 'relay-0.1.0+ggg')) + mkdirSync(join(root, 'orcad-0.1.0+aaa')) + mkdirSync(join(root, stageName(0), 'relay-0.1.0+ccc')) + writeFileSync(join(root, 'relay-0.1.0+ddd'), '') + symlinkSync(join(root, validNames[0]), join(root, 'relay-0.1.0+eee'), 'dir') + return { + root, + findCommand: `find ${shellEscape(root)} -mindepth 1 -maxdepth 1 -type d -name 'relay-*' -print`, + validNames, + fillValidEntryBoundary: () => { + for (let index = 0; index < 63; index += 1) { + const name = `relay-0.1.0+${index.toString(16)}` + validNames.push(name) + mkdirSync(join(root, name)) + } + }, + dispose: () => rmSync(workspace, { recursive: true, force: true }) + } + } catch (error) { + rmSync(workspace, { recursive: true, force: true }) + throw error + } +} + +export async function readUncappedRelayGcPaths(command: string): Promise { + const result = await runProcess({ + program: '/bin/sh', + args: ['-c', command], + maxOutputBytes: 4 * SSH_EXEC_OUTPUT_CAP_CHARS + }) + if (result.code !== 0 || result.timedOut || result.outputTruncated) { + throw new Error(`GC fixture enumeration failed: ${result.code}: ${result.stderr}`) + } + return result.stdout.trim().split('\n') +} diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 758d4739b0f..76073d24d89 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -11,7 +11,7 @@ import { utimesSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { describe, expect, it } from 'vitest' import { decodeRemotePowerShellScript } from './ssh-remote-powershell' import { @@ -31,6 +31,12 @@ import { relayLivenessProbeCommand } from './ssh-remote-commands' import { getRemoteHostPlatform } from './ssh-remote-platform' +import { + createRelayGcListingFixture, + readUncappedRelayGcPaths, + runCappedRelayGcShellCommand, + SSH_EXEC_OUTPUT_CAP_CHARS +} from './ssh-relay-gc-listing.test-fixture' import { RELAY_INSTALL_COMPLETE_FILENAME, relayArtifactFilenames @@ -256,22 +262,43 @@ describe('ssh remote command builders', () => { it.runIf(process.platform !== 'win32')( 'bounds real POSIX GC output with more than the exec-cap stage population', async () => { - const root = mkdtempSync(join(tmpdir(), 'orca-relay-gc-scale-')) + const fixture = createRelayGcListingFixture() try { - for (let index = 0; index < 15_197; index += 1) { - mkdirSync(join(root, `relay-0.1.0+abc.upload-${String(index).padStart(12, '0')}`)) + const paths = await readUncappedRelayGcPaths(fixture.findCommand) + expect(paths.join('\n').length).toBeGreaterThan(SSH_EXEC_OUTPUT_CAP_CHARS) + const cappedRaw = await runCappedRelayGcShellCommand( + `${fixture.findCommand} | LC_ALL=C sort` + ) + expect(cappedRaw.length).toBe(SSH_EXEC_OUTPUT_CAP_CHARS) + for (const name of fixture.validNames) { + expect(cappedRaw).not.toContain(join(fixture.root, name)) } - mkdirSync(join(root, 'relay-0.1.0+aaa')) - mkdirSync(join(root, 'relay-0.1.0+bbb')) - const output = await runShellCommand(listRelayBaseDirsCommand(posix, root)) + const output = await runCappedRelayGcShellCommand( + listRelayBaseDirsCommand(posix, fixture.root) + ) const entries = output.trim().split('\n') expect(entries).toEqual(['relay-0.1.0+aaa', 'relay-0.1.0+bbb']) expect(Buffer.byteLength(output)).toBeLessThan(1_024) expect(entries.length).toBeLessThanOrEqual(MAX_RELAY_GC_LISTING_ENTRIES) + + fixture.fillValidEntryBoundary() + const validNames = new Set(fixture.validNames) + const unboundedEntries = (await readUncappedRelayGcPaths(fixture.findCommand)) + .map((path) => basename(path)) + .filter((name) => validNames.has(name)) + expect(unboundedEntries).toHaveLength(65) + const boundedOutput = await runCappedRelayGcShellCommand( + listRelayBaseDirsCommand(posix, fixture.root) + ) + const boundedEntries = boundedOutput.trim().split('\n') + expect(boundedEntries).toEqual(unboundedEntries.slice(0, 64)) + expect(boundedEntries).toHaveLength(64) + expect(new Set(boundedEntries).size).toBe(64) + expect(Buffer.byteLength(boundedOutput)).toBeLessThan(1_024) } finally { - rmSync(root, { recursive: true, force: true }) + fixture.dispose() } }, 30_000 diff --git a/src/main/ssh/system-ssh-operation-lifecycle.ts b/src/main/ssh/system-ssh-operation-lifecycle.ts index 5ebfb17ced1..148ccad8ff6 100644 --- a/src/main/ssh/system-ssh-operation-lifecycle.ts +++ b/src/main/ssh/system-ssh-operation-lifecycle.ts @@ -3,6 +3,18 @@ import type { SystemSshCommandChannel } from './system-ssh-command' export type ProcessResult = { label: string; stderr: string } +export class SystemSshCommandExitError extends Error { + constructor( + label: string, + readonly exitCode: number | null, + readonly stderr: string, + signal?: NodeJS.Signals | null + ) { + const detail = exitCode === null ? `signal ${signal ?? 'unknown'}` : `exit ${exitCode}` + super(`${label} failed (${detail}): ${stderr.trim()}`) + } +} + /** * `timeoutMs` bounds a remote consumer that never returns. Windows PowerShell 5.1 cannot drain a * large redirected stdin over a non-pty ssh exec (#16432): the remote process stays alive at idle @@ -52,8 +64,7 @@ export function waitForChannelClose( } const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => { if (code !== 0) { - const detail = code === null ? `signal ${signal ?? 'unknown'}` : `exit ${code}` - settle(reject, new Error(`${label} failed (${detail}): ${stderr.trim()}`)) + settle(reject, new SystemSshCommandExitError(label, code, stderr, signal)) return } settle(resolve) diff --git a/src/main/ssh/system-ssh-windows-upload.test.ts b/src/main/ssh/system-ssh-windows-upload.test.ts index c3a5ff80276..bc339f24785 100644 --- a/src/main/ssh/system-ssh-windows-upload.test.ts +++ b/src/main/ssh/system-ssh-windows-upload.test.ts @@ -51,12 +51,16 @@ import { writeBufferViaSystemSsh } from './system-ssh-file-binary-transfer' import { waitForChannelClose } from './system-ssh-operation-lifecycle' +import * as windowsFileWrite from './system-ssh-windows-file-write' import { getRemoteHostPlatform } from './ssh-remote-platform' import { clearWindowsRemoteWriteCapabilitiesForTests, getWindowsRemoteWriteCapabilities } from './system-ssh-windows-write-capabilities' -import { explainWindowsPowerShellStdinFailure } from './system-ssh-windows-write-strategy' +import { + explainWindowsPowerShellStdinFailure, + writeWindowsRemoteFile +} from './system-ssh-windows-write-strategy' import type { SshTarget } from '../../shared/ssh-types' type FakeChannel = EventEmitter & { @@ -106,6 +110,8 @@ const sftpBatches: RecordedSftpBatch[] = [] const commands: RecordedCommand[] = [] /** Index of the exec that should report a non-zero exit, to model a chunk failing mid-file. */ let failAtSpawn = -1 +let failedWriteExit = 1 +let failedWriteStderr = '' let localDir: string const fileWrites = (): RecordedCommand[] => @@ -177,6 +183,8 @@ beforeEach(() => { commands.length = 0 sftpBatches.length = 0 failAtSpawn = -1 + failedWriteExit = 1 + failedWriteStderr = '' clearWindowsRemoteWriteCapabilitiesForTests() waitForChannelCloseSpy.mockClear() localDir = mkdtempSync(join(tmpdir(), 'orca-win-upload-')) @@ -192,9 +200,14 @@ beforeEach(() => { executable: command.split(' ')[0] ?? '', stdin: channel.written }) - setImmediate(() => - spawnIndex === failAtSpawn ? channel.emit('close', 1, null) : channel.emit('close', 0, null) - ) + setImmediate(() => { + if (spawnIndex === failAtSpawn) { + channel.stderr.write(failedWriteStderr) + channel.emit('close', failedWriteExit, null) + } else { + channel.emit('close', 0, null) + } + }) }) }) }) @@ -624,6 +637,229 @@ describe('Windows upload on a host with no sftp subsystem', () => { expect(fileWrites().map(writtenPath)).not.toContain(`${remoteRoot}/relay.js`) expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) }) + + it.each([ + ['relay.js', 'aabb9009eeff'], + ['relay-9009.js', 'aabbccddeeff'], + ['relay-CommandNotFoundException.js', 'aabbccddeeff'], + ['is not recognized as an internal or external command.js', 'aabbccddeeff'] + ])('propagates a failed write whose path contains absence-like text: %s', async (file, nonce) => { + const remotePath = `${remoteRoot}/${file}` + const staging = vi + .spyOn(windowsFileWrite, 'makeWindowsStagingPath') + .mockImplementation((path) => `${path}${WINDOWS_STAGED_WRITE_SUFFIX}-${nonce}`) + writeFileSync(join(localDir, file), Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES * 3)) + failAtSpawn = 0 + try { + await expect( + uploadFileViaSystemSsh(target, join(localDir, file), remotePath, { hostPlatform }) + ).rejects.toThrow('failed (exit 1)') + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + } finally { + staging.mockRestore() + } + }) + + it.each([ + 'relay-CommandNotFoundException.js', + 'is not recognized as an internal or external command.js' + ])('propagates a write-denied stderr naming an absence-like filename: %s', async (file) => { + const remotePath = `${remoteRoot}/${file}` + writeFileSync(join(localDir, file), 'x') + failAtSpawn = 0 + failedWriteStderr = `Access to the path '${remotePath}' is denied.` + + await expect( + uploadFileViaSystemSsh(target, join(localDir, file), remotePath, { hostPlatform }) + ).rejects.toThrow('Access to the path') + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + }) + + const capturedPowerShellMissingPwsh = + "pwsh.exe : The term 'pwsh.exe' is not recognized as the name of a cmdlet, function, script file, or operable program. \r\nCheck the spelling of the name, or if a path was included, verify that the path is correct and try again.\r\nAt line:1 char:1\r\n+ pwsh.exe -NoProfile -NonInteractive -Command exit\r\n+ ~~~~~~~~\r\n + CategoryInfo : ObjectNotFound: (pwsh.exe:String) [], CommandNotFoundException\r\n + FullyQualifiedErrorId : CommandNotFoundException" + + // Source-derived resource/layout controls; these are not captured localized Windows errors. + const sourceDerivedLocalizedMissingPwsh = [ + 'pwsh.exe : La commande est introuvable.', + 'Vérifiez le nom de la commande.', + 'À la ligne:1 caractère:1', + '+ pwsh.exe -NoProfile -NonInteractive -EncodedCommand JABwAGEAdABoAA== ...', + '+ ~~~~~~~~', + ' + CategoryInfo : CommandNotFoundException — cible pwsh.exe, type String', + ' + FullyQualifiedErrorId : CommandNotFoundException' + ].join('\r\n') + const sourceDerivedWrappedMissingPwsh = [ + 'pwsh.exe : The term', + "'pwsh.exe' is not recognized.", + 'At line:1 char:1', + '+ pwsh.exe -NoProfile ', + '-NonInteractive -EncodedCommand ', + 'JABwAGEAdABoAA== ...', + '+ ~~~~~~~~', + ' + CategoryInfo : ObjectNotFound: ', + '(pwsh.exe:String) [], CommandNotFoundExcept', + 'ion', + ' + FullyQualifiedErrorId : CommandNotFoundExcept', + 'ion' + ].join('\r\n') + + it.each([ + "'missing-tool.exe' is not recognized as an internal or external command", + `Access to the path 'relay.js' is denied.\n${capturedPowerShellMissingPwsh}`, + `${capturedPowerShellMissingPwsh}\nAccess to the path 'relay.js' is denied.`, + capturedPowerShellMissingPwsh.replaceAll('pwsh.exe', 'missing-tool.exe'), + capturedPowerShellMissingPwsh.replace(/.*CategoryInfo.*\r?\n/, ''), + capturedPowerShellMissingPwsh.replace('At line:', 'pwsh.exe : Another failure.\r\nAt line:'), + capturedPowerShellMissingPwsh.replace('At line:', 'another.exe : Another failure.\r\nAt line:'), + sourceDerivedLocalizedMissingPwsh.replace('cible pwsh.exe', 'cible relay-pwsh.exe.js'), + sourceDerivedLocalizedMissingPwsh.replace('cible pwsh.exe', 'cible C:\\bin\\pwsh.exe'), + sourceDerivedLocalizedMissingPwsh.replace('cible pwsh.exe', 'cible pwsh.exe-backup'), + sourceDerivedLocalizedMissingPwsh.replace('type String', 'type FileInfo'), + sourceDerivedWrappedMissingPwsh.replace('+ pwsh.exe', '+ missing-tool.exe'), + `${sourceDerivedWrappedMissingPwsh}\n${capturedPowerShellMissingPwsh}`, + `${sourceDerivedLocalizedMissingPwsh}\nAccess to the path 'relay.js' is denied.`, + `Access to the path 'relay.js' is denied.\n${sourceDerivedLocalizedMissingPwsh}`, + 'CommandNotFoundException: missing-tool.exe', + "Access to the path 'relay.js' is denied.\nCommandNotFoundException: pwsh.exe", + "Access to the path 'relay.js' is denied.\n'pwsh.exe' is not recognized as an internal or external command" + ])( + 'does not mark PowerShell 7 absent when its script reports another missing command: %s', + async (stderr) => { + writeFileSync(join(localDir, 'relay.js'), 'x') + failAtSpawn = 0 + failedWriteStderr = stderr + + await expect( + uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + ).rejects.toThrow('failed (exit 1)') + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + } + ) + + it.each([ + [9009, ''], + [1, "'pwsh.exe' is not recognized as an internal or external command"], + [1, 'CommandNotFoundException: pwsh.exe'], + [ + 1, + "'pwsh.exe' is not recognized as an internal or external command,\r\noperable program or batch file." + ], + [1, capturedPowerShellMissingPwsh] + ])('falls back on an actual missing PowerShell 7 signal: %s %s', async (exit, stderr) => { + writeFileSync(join(localDir, 'relay.js'), Buffer.alloc(WINDOWS_STDIN_WRITE_CHUNK_BYTES * 3)) + failAtSpawn = 0 + failedWriteExit = exit + failedWriteStderr = stderr + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + + expect(fileWrites().map((write) => write.executable)).toEqual([ + 'pwsh.exe', + 'powershell.exe', + 'powershell.exe', + 'powershell.exe' + ]) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(false) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(true) + }) + + it.each([ + sourceDerivedLocalizedMissingPwsh, + sourceDerivedWrappedMissingPwsh, + sourceDerivedLocalizedMissingPwsh.replace(' -NonInteractive', '\r\n -NonInteractive') + ])( + 'falls back on a source-derived localized or wrapped missing-pwsh record: %s', + async (stderr) => { + writeFileSync(join(localDir, 'relay.js'), 'x') + failAtSpawn = 0 + failedWriteStderr = stderr + + await uploadFileViaSystemSsh(target, join(localDir, 'relay.js'), `${remoteRoot}/relay.js`, { + hostPlatform + }) + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe', 'powershell.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(false) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(true) + } + ) + + it.each([ + 'relay-CommandNotFoundException.js', + 'is not recognized as an internal or external command.js' + ])('propagates a timeout whose filename resembles a missing command: %s', async (file) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const remotePath = `${remoteRoot}/${file}` + writeFileSync(join(localDir, file), 'x') + let noteWriteStarted: () => void = () => {} + const writeStarted = new Promise((resolve) => { + noteWriteStarted = resolve + }) + spawnSystemSshCommandMock.mockImplementation((_target: SshTarget, command: string) => { + const executable = command.split(' ')[0] ?? '' + return createFakeChannel((channel) => { + commands.push({ + script: decodePowerShellCommand(command), + executable, + stdin: channel.written + }) + if (executable !== 'pwsh.exe') { + setImmediate(() => channel.emit('close', 0, null)) + } else { + noteWriteStarted() + } + }) + }) + try { + const result = expect( + uploadFileViaSystemSsh(target, join(localDir, file), remotePath, { hostPlatform }) + ).rejects.toThrow('timed out') + await writeStarted + await vi.advanceTimersByTimeAsync(WINDOWS_STDIN_WRITE_TIMEOUT_MS + 1) + await result + + expect(fileWrites().map((write) => write.executable)).toEqual(['pwsh.exe']) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + 'relay-CommandNotFoundException.js', + 'is not recognized as an internal or external command.js' + ])('propagates a short source whose filename resembles a missing command: %s', async (file) => { + await expect( + writeWindowsRemoteFile( + target, + `${remoteRoot}/${file}`, + { + totalBytes: 1, + readChunk: async () => Buffer.alloc(0), + withLocalFile: async (send) => send(join(localDir, file)) + }, + {} + ) + ).rejects.toThrow('Source ran short') + + expect(fileWrites()).toHaveLength(0) + expect(getWindowsRemoteWriteCapabilities(target).shouldTry('pwsh')).toBe(true) + expect(commands.some((command) => command.script.includes('::Move('))).toBe(false) + }) }) describe('last-resort Windows PowerShell failure reporting', () => { diff --git a/src/main/ssh/system-ssh-windows-write-strategy.ts b/src/main/ssh/system-ssh-windows-write-strategy.ts index f2cdca12516..e8016db313c 100644 --- a/src/main/ssh/system-ssh-windows-write-strategy.ts +++ b/src/main/ssh/system-ssh-windows-write-strategy.ts @@ -3,6 +3,7 @@ import { getSystemSshBuildArgsFromOperationOptions } from './system-ssh-args' import { spawnSystemSshCommand } from './system-ssh-command' import { awaitWithSystemSshAbort, + SystemSshCommandExitError, throwIfAborted, waitForChannelClose } from './system-ssh-operation-lifecycle' @@ -267,11 +268,42 @@ export function explainWindowsPowerShellStdinFailure(error: unknown): unknown { } function isPwshUnavailableError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - // cmd.exe's "not recognized" and sshd's exit 9009 both mean "no pwsh here". A timeout does not: - // that is the stdin defect, and PowerShell 7 does not have it, so it must not be cached as absent. - return /is not recognized as an internal or external command|9009|CommandNotFoundException/i.test( - message + if (!(error instanceof SystemSshCommandExitError)) { + return false + } + // A complete missing-pwsh diagnostic establishes absence; paths and mixed errors do not. + const stderr = error.stderr.trim() + return ( + error.exitCode === 9009 || + /^'pwsh\.exe' is not recognized as an internal or external command(?:,\r?\noperable program or batch file\.)?$/i.test( + stderr + ) || + /^CommandNotFoundException:[ \t]*pwsh\.exe$/i.test(stderr) || + isPowerShellMissingPwshDiagnostic(stderr) + ) +} + +function isPowerShellMissingPwshDiagnostic(stderr: string): boolean { + // NormalView wraps physical lines; its localized prose and category template are not identifiers. + const record = stderr.replace(/\r?\n/g, '') + const sections = + /^pwsh\.exe[ \t]*:[ \t]*([^+]+)\+[ \t]*pwsh\.exe([ \t]+-[^~]*?)\+[ \t]*~{8}[ \t]*\+[ \t]*CategoryInfo[ \t]*:[ \t]*([^+]+)\+[ \t]*FullyQualifiedErrorId[ \t]*:[ \t]*CommandNotFoundException[ \t]*$/.exec( + record + ) + if (!sections) { + return false + } + const prelude = sections[1] ?? '' + const source = sections[2] ?? '' + const category = sections[3] ?? '' + return ( + !/\b[\w.-]+\.exe[ \t]*:/.test(prelude) && + /^[ \t]+-NoProfile[ \t]+-NonInteractive[ \t]+-(?:Command[ \t]+exit|EncodedCommand[ \t]+[A-Za-z0-9+/=]+(?:[ \t]*\.{3})?)[ \t]*$/.test( + source + ) && + category.match(/(? { const runtime = source.indexOf('const runtime = new OrcaRuntimeService(') const identityReader = source.indexOf('readObservedAgentStatusPaneIdentity:', runtime) const identitySubscription = source.indexOf('agentHookServer.subscribeEnrichedStatus(') - const hooksEnabled = source.indexOf('if (isAgentStatusHooksEnabled(', identitySubscription) + const hookStart = source.indexOf('await agentHookServer.start(', identitySubscription) + const settingsListener = source.indexOf('profileStore.onSettingsChanged(', hookStart) + const daemon = source.indexOf('await startOrcadDaemon()', hookStart) const identityFlush = source.indexOf('observedStatusCapture.attach(runtime)', runtime) expect(runtime).toBeGreaterThanOrEqual(0) expect(identityReader).toBeGreaterThan(runtime) expect(identitySubscription).toBeGreaterThanOrEqual(0) expect(identitySubscription).toBeLessThan(runtime) - expect(hooksEnabled).toBeGreaterThan(identitySubscription) + expect(hookStart).toBeGreaterThan(identitySubscription) + expect(settingsListener).toBeGreaterThan(hookStart) + expect(daemon).toBeGreaterThan(settingsListener) + expect(runtime).toBeGreaterThan(daemon) + expect(source.slice(identitySubscription, hookStart)).not.toContain( + 'if (isAgentStatusHooksEnabled(' + ) + expect(source.slice(hookStart, settingsListener)).toContain( + 'statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings())' + ) + expect(source.slice(settingsListener, daemon)).toContain( + 'agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings))' + ) expect(identityFlush).toBeGreaterThan(runtime) expect(source.slice(identitySubscription, runtime)).toContain( 'observedStatusCapture.observe(enriched)' diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts index 03cef473299..b5cb284db08 100644 --- a/src/main/startup/main-process-pty-startup.ts +++ b/src/main/startup/main-process-pty-startup.ts @@ -171,9 +171,6 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. startAgentHookServer: async () => { const settings = state.store?.getSettings() - if (!isAgentStatusHooksEnabled(settings)) { - return - } logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) // Why (#11217): the hook listener fails open on every request error, so an IDS resetting // loopback POSTs mid-body stops agent status for every runtime with no symptom but staleness. @@ -182,6 +179,7 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ track('agent_hook_transport_blocked', { count: report.count }) }) await agentHookServer.start({ + statusHooksEnabled: isAgentStatusHooksEnabled(settings), env: app.isPackaged ? 'production' : 'development', // Why: hooks source this endpoint file at invocation time so old PTY env reaches the current process after restart; dev namespaces it (worktrees share `orca-dev`). userDataPath: app.getPath('userData'), diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 238998bac49..c9ec3b30368 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -49,6 +49,7 @@ import { syncMacMenuBarIcon } from './main-window-actions' import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' +import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { reportProfileStateWriteFailure } from './profile-state-write-failure' @@ -248,6 +249,7 @@ export async function initializeReadyFoundation(): Promise { syncMacMenuBarIcon(settings.showMenuBarIcon !== false) } if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) // Why both directions: the ensure gate only blocks NEW relays, so off must stop the running // guest process and timers, and on must restart them — otherwise open WSL panes report no // status until their next spawn. diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 9e5c5493f09..6a685f2f982 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -1,6 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' -import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract' +import type { registerSshHandlers } from '../ipc/ssh' +import type { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' +import type { registerDaemonManagementHandlers } from '../ipc/pty-management' +import type { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' +import type { startFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade' +import { + createMainWindowServiceStub, + createRuntime, + deferred, + type MainWindowStub +} from './main-window-service-stubs.test-fixture' const { onMock, @@ -17,6 +27,11 @@ const { setWorktreeCatalogRemoteClientNotifierMock, registerWorktreeHandlersMock, registerPtyHandlersMock, + registerSshHandlersMock, + registerRemoteWorkspaceHandlersMock, + registerDaemonManagementHandlersMock, + registerWorkspaceCleanupHandlersMock, + startFolderRepoGitUpgradeWatchMock, hydrateLocalPtyRegistryAtBootMock, setWorktreeBaseDirectoryWatcherSyncContextMock, scheduleWorktreeBaseDirectoryWatcherSyncMock, @@ -42,6 +57,12 @@ const { setWorktreeCatalogRemoteClientNotifierMock: vi.fn(), registerWorktreeHandlersMock: vi.fn(), registerPtyHandlersMock: vi.fn(), + registerSshHandlersMock: vi.fn<(...args: Parameters) => void>(), + registerRemoteWorkspaceHandlersMock: + vi.fn<(...args: Parameters) => void>(), + registerDaemonManagementHandlersMock: vi.fn(), + registerWorkspaceCleanupHandlersMock: vi.fn(), + startFolderRepoGitUpgradeWatchMock: vi.fn(), hydrateLocalPtyRegistryAtBootMock: vi.fn(), setWorktreeBaseDirectoryWatcherSyncContextMock: vi.fn(), scheduleWorktreeBaseDirectoryWatcherSyncMock: vi.fn(), @@ -99,6 +120,20 @@ vi.mock('../ipc/pty', () => ({ registerPtyHandlers: registerPtyHandlersMock })) +vi.mock('../ipc/ssh', () => ({ registerSshHandlers: registerSshHandlersMock })) +vi.mock('../ipc/remote-workspace', () => ({ + registerRemoteWorkspaceHandlers: registerRemoteWorkspaceHandlersMock +})) +vi.mock('../ipc/pty-management', () => ({ + registerDaemonManagementHandlers: registerDaemonManagementHandlersMock +})) +vi.mock('../ipc/workspace-cleanup', () => ({ + registerWorkspaceCleanupHandlers: registerWorkspaceCleanupHandlersMock +})) +vi.mock('../ipc/folder-repo-git-upgrade', () => ({ + startFolderRepoGitUpgradeWatch: startFolderRepoGitUpgradeWatchMock +})) + vi.mock('../memory/hydrate-local-pty-registry', () => ({ hydrateLocalPtyRegistryAtBoot: hydrateLocalPtyRegistryAtBootMock })) @@ -133,57 +168,16 @@ import { attachMainWindowServices } from './attach-main-window-services' type MockFn = ReturnType -type MainWindowStub = { - id?: number - isDestroyed?: MockFn - on: MockFn - once: MockFn - webContents: { - id?: number - getURL: MockFn - isDestroyed?: MockFn - isLoadingMainFrame: MockFn - on: MockFn - send?: MockFn - reload?: MockFn - session: { - setPermissionRequestHandler: MockFn - setPermissionCheckHandler: MockFn - } - } -} - -type RuntimeStub = { - attachWindow: MockFn - setNotifier: ReturnType void>> - markRendererReloading: MockFn - markRendererReloadCancelled: MockFn - markGraphReloadFailed: MockFn - markGraphUnavailable: MockFn -} - function createMainWindow( extraWebContents: { isLoadingMainFrame?: MockFn; on?: MockFn; send?: MockFn } = {} ): MainWindowStub { - return { - id: 1, - isDestroyed: vi.fn(() => false), - on: vi.fn(), - once: vi.fn(), - webContents: { - id: 1, - getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), - isDestroyed: vi.fn(() => false), - isLoadingMainFrame: vi.fn(() => true), - on: vi.fn(), - reload: vi.fn(), - session: { - setPermissionRequestHandler: setPermissionRequestHandlerMock, - setPermissionCheckHandler: setPermissionCheckHandlerMock - }, - ...extraWebContents - } - } + return createMainWindowServiceStub( + { + setPermissionRequestHandler: setPermissionRequestHandlerMock, + setPermissionCheckHandler: setPermissionCheckHandlerMock + }, + extraWebContents + ) } function createStore(): Store & { flushPendingAsync: MockFn } { @@ -193,25 +187,6 @@ function createStore(): Store & { flushPendingAsync: MockFn } { } as unknown as Store & { flushPendingAsync: MockFn } } -function createRuntime(): RuntimeStub { - return { - attachWindow: vi.fn(), - setNotifier: vi.fn<(notifier: RuntimeNotifier | null) => void>(), - markRendererReloading: vi.fn(), - markRendererReloadCancelled: vi.fn(), - markGraphReloadFailed: vi.fn(), - markGraphUnavailable: vi.fn() - } -} - -function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void - const promise = new Promise((next) => { - resolve = next - }) - return { promise, resolve } -} - function getClosedHandlers(mainWindowOnMock: MockFn): (() => void)[] { return mainWindowOnMock.mock.calls .filter(([event]) => event === 'closed') @@ -239,11 +214,30 @@ describe('attachMainWindowServices', () => { it('gives host-local catalog notifiers the runtime', () => { const runtime = createRuntime() + const mainWindow = createMainWindow() + const store = createStore() - attachMainWindowServices(createMainWindow() as never, createStore(), runtime as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Stubs provide the window/runtime methods exercised by attachment. + attachMainWindowServices(mainWindow as never, store, runtime as never) expect(setRepoRemoteClientNotifierMock).toHaveBeenCalledWith(runtime) expect(setWorktreeCatalogRemoteClientNotifierMock).toHaveBeenCalledWith(runtime) + expect(registerSshHandlersMock).toHaveBeenCalledExactlyOnceWith( + store, + expect.any(Function), + runtime + ) + expect(registerSshHandlersMock.mock.calls[0]?.[1]()).toBe(mainWindow) + expect(registerRemoteWorkspaceHandlersMock).toHaveBeenCalledExactlyOnceWith( + store, + expect.any(Function), + runtime + ) + expect(registerRemoteWorkspaceHandlersMock.mock.calls[0]?.[1]()).toBe(mainWindow) + expect(registerDaemonManagementHandlersMock).toHaveBeenCalledExactlyOnceWith() + expect(registerDaemonManagementHandlersMock).toHaveBeenCalledAfter(registerPtyHandlersMock) + expect(registerWorkspaceCleanupHandlersMock).toHaveBeenCalledExactlyOnceWith(store) + expect(startFolderRepoGitUpgradeWatchMock).toHaveBeenCalledExactlyOnceWith(store, mainWindow) }) it('reloads the app renderer through main and marks expected renderer teardown', async () => { diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 7bd3e41b378..b448681857e 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -26,6 +26,8 @@ import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/brows import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' import type { PreQuitCleanupFailureMode, UpdateInstallMode } from '../updater' import { scheduleHistoryGc } from '../terminal-history-gc' +import { openCodeHookService, openCode2HookService } from '../opencode/hook-service' +import { listLiveDaemonPtyIds } from '../daemon/daemon-provider-state' import { hydrateLocalPtyRegistryAtBoot } from '../memory/hydrate-local-pty-registry' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { getKnownWorktreeIdsForHistoryGc } from './history-gc-worktree-ids' @@ -107,6 +109,8 @@ export function attachMainWindowServices( scheduleHistoryGc(async () => { return getKnownWorktreeIdsForHistoryGc(store) }) + openCodeHookService.configDirGc.schedule(listLiveDaemonPtyIds) + openCode2HookService.configDirGc.schedule(listLiveDaemonPtyIds) const localPtyProviderStartupReady = options?.awaitLocalPtyProviderStartup?.() if (localPtyProviderStartupReady) { void localPtyProviderStartupReady diff --git a/src/main/window/main-window-service-stubs.test-fixture.ts b/src/main/window/main-window-service-stubs.test-fixture.ts new file mode 100644 index 00000000000..2db0cbcc762 --- /dev/null +++ b/src/main/window/main-window-service-stubs.test-fixture.ts @@ -0,0 +1,80 @@ +import { vi } from 'vitest' +import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract' + +type MockFn = ReturnType + +export type MainWindowStub = { + id?: number + isDestroyed?: MockFn + on: MockFn + once: MockFn + webContents: { + id?: number + getURL: MockFn + isDestroyed?: MockFn + isLoadingMainFrame: MockFn + on: MockFn + send?: MockFn + reload?: MockFn + session: { + setPermissionRequestHandler: MockFn + setPermissionCheckHandler: MockFn + } + } +} + +type RuntimeStub = { + attachWindow: MockFn + setNotifier: ReturnType void>> + markRendererReloading: MockFn + markRendererReloadCancelled: MockFn + markGraphReloadFailed: MockFn + markGraphUnavailable: MockFn +} + +export function createMainWindowServiceStub( + permissionHandlers: { + setPermissionRequestHandler: MockFn + setPermissionCheckHandler: MockFn + }, + extraWebContents: { isLoadingMainFrame?: MockFn; on?: MockFn; send?: MockFn } = {} +): MainWindowStub { + return { + id: 1, + isDestroyed: vi.fn(() => false), + on: vi.fn(), + once: vi.fn(), + webContents: { + id: 1, + getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'), + isDestroyed: vi.fn(() => false), + isLoadingMainFrame: vi.fn(() => true), + on: vi.fn(), + reload: vi.fn(), + session: { + setPermissionRequestHandler: permissionHandlers.setPermissionRequestHandler, + setPermissionCheckHandler: permissionHandlers.setPermissionCheckHandler + }, + ...extraWebContents + } + } +} + +export function createRuntime(): RuntimeStub { + return { + attachWindow: vi.fn(), + setNotifier: vi.fn<(notifier: RuntimeNotifier | null) => void>(), + markRendererReloading: vi.fn(), + markRendererReloadCancelled: vi.fn(), + markGraphReloadFailed: vi.fn(), + markGraphUnavailable: vi.fn() + } +} + +export function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} diff --git a/src/main/worker-thread-request-queue.test.ts b/src/main/worker-thread-request-queue.test.ts index 4bf2188dc54..2046267396c 100644 --- a/src/main/worker-thread-request-queue.test.ts +++ b/src/main/worker-thread-request-queue.test.ts @@ -92,9 +92,10 @@ function makeQueue( function send( queue: WorkerThreadRequestQueue, - label: string + label: string, + owner?: { readonly signal: AbortSignal } ): Promise { - return queue.dispatch((id) => ({ id, label }), TIMEOUT_MS) + return queue.dispatch((id) => ({ id, label }), TIMEOUT_MS, undefined, owner) } /** Resolve to the response or to the rejection, so a test can assert on either. */ @@ -328,6 +329,161 @@ describe('WorkerThreadRequestQueue', () => { expect(await behind).toMatchObject({ label: 'd' }) }) + it('keeps one owner fault budget across idle batches and refuses a fourth worker', async () => { + const workers: FakeWorker[] = [] + const queue = makeQueue(workers) + const owner = { signal: new AbortController().signal } + try { + for (let fault = 0; fault < 3; fault++) { + const call = settle(send(queue, `owned-${fault}`, owner)) + workers.at(-1)?.emit('error', new Error(`fault-${fault}`)) + expect(await call).toMatchObject({ message: `fault-${fault}` }) + } + const refused = settle(send(queue, 'fourth', owner)) + expect(workers).toHaveLength(3) + await expect(refused).resolves.toMatchObject({ message: 'crashed repeatedly (fault-2)' }) + const nextScan = send(queue, 'new-scan', { signal: new AbortController().signal }) + expect(workers).toHaveLength(4) + workers[3].respond() + await expect(nextScan).resolves.toMatchObject({ label: 'new-scan' }) + } finally { + queue.dispose() + } + }) + + it('resets only the successful owner and does not count idle worker exits', async () => { + const workers: FakeWorker[] = [] + const queue = makeQueue(workers) + const a = { signal: new AbortController().signal } + const b = { signal: new AbortController().signal } + try { + const initial = send(queue, 'initial', a) + workers[0].respond() + await initial + workers[0].emit('exit', 17) + for (let fault = 0; fault < 2; fault++) { + const call = settle(send(queue, `a-${fault}`, a)) + workers.at(-1)?.emit('error', new Error(`a-fault-${fault}`)) + await call + } + const peer = send(queue, 'b-success', b) + workers.at(-1)?.respond() + await peer + const third = settle(send(queue, 'a-third', a)) + workers.at(-1)?.emit('error', new Error('a-third-fault')) + await third + const refused = settle(send(queue, 'a-fourth', a)) + expect(workers).toHaveLength(4) + await expect(refused).resolves.toMatchObject({ + message: 'crashed repeatedly (a-third-fault)' + }) + const healthy = send(queue, 'b-still-healthy', b) + workers.at(-1)?.respond() + await expect(healthy).resolves.toMatchObject({ label: 'b-still-healthy' }) + } finally { + queue.dispose() + } + }) + + it('clears a successful owner budget while preserving another owner failure count', async () => { + const workers: FakeWorker[] = [] + const queue = makeQueue(workers) + const a = { signal: new AbortController().signal } + const b = { signal: new AbortController().signal } + try { + for (const owner of [a, b]) { + for (let fault = 0; fault < 2; fault++) { + const call = settle(send(queue, 'failure', owner)) + workers.at(-1)?.emit('error', new Error('failure')) + await call + } + } + const successful = send(queue, 'a-success', a) + workers.at(-1)?.respond() + await successful + const thirdB = settle(send(queue, 'b-third', b)) + workers.at(-1)?.emit('error', new Error('b-third-fault')) + await thirdB + for (let fault = 0; fault < 2; fault++) { + const call = settle(send(queue, 'a-new-failure', a)) + workers.at(-1)?.emit('error', new Error('a-new-failure')) + await call + } + const stillAllowed = send(queue, 'a-allowed', a) + expect(workers).toHaveLength(8) + workers.at(-1)?.respond() + await expect(stillAllowed).resolves.toMatchObject({ label: 'a-allowed' }) + await expect(settle(send(queue, 'b-refused', b))).resolves.toMatchObject({ + message: 'crashed repeatedly (b-third-fault)' + }) + expect(workers).toHaveLength(8) + } finally { + queue.dispose() + } + }) + + it('drains only the failing owner while queued peers keep their FIFO order', async () => { + const workers: FakeWorker[] = [] + const queue = makeQueue(workers) + const a = { signal: new AbortController().signal } + const b = { signal: new AbortController().signal } + try { + const failed = ['a1', 'a2', 'a3', 'a4'].map((label) => settle(send(queue, label, a))) + const peer = settle(send(queue, 'peer', b)) + const ordinary = settle(send(queue, 'ordinary')) + for (let fault = 0; fault < 3; fault++) { + workers.at(-1)?.emit('error', new Error(`fault-${fault}`)) + } + expect(workers).toHaveLength(4) + expect(labels(workers[3])).toEqual(['peer']) + expect((await Promise.all(failed)).at(-1)).toMatchObject({ + message: 'crashed repeatedly (fault-2)' + }) + workers[3].respond() + await expect(peer).resolves.toMatchObject({ label: 'peer' }) + expect(labels(workers[3])).toEqual(['peer', 'ordinary']) + workers[3].respond() + await expect(ordinary).resolves.toMatchObject({ label: 'ordinary' }) + } finally { + queue.dispose() + } + }) + + it('keeps retirement refusals out of the owner failure budget', async () => { + vi.useFakeTimers() + const workers: FakeWorker[] = [] + let finish: (code: number) => void = () => {} + const first = new FakeWorker() + first.exit = new Promise((resolve) => { + finish = resolve + }) + const queue = makeQueue(workers, { + awaitRetirement: true, + makeWorker: () => (workers.length === 0 ? first : new FakeWorker()) + }) + const owner = { signal: new AbortController().signal } + try { + const failed = settle(send(queue, 'first', owner)) + first.emit('error', new Error('first-fault')) + await failed + for (let attempt = 0; attempt < 4; attempt++) { + await expect(settle(send(queue, 'not-executed', owner))).resolves.toMatchObject({ + message: 'unavailable: previous worker still exiting' + }) + } + expect(workers).toHaveLength(1) + finish(1) + await vi.advanceTimersByTimeAsync(0) + const recovered = send(queue, 'recovered', owner) + expect(workers).toHaveLength(2) + workers[1].respond() + await expect(recovered).resolves.toMatchObject({ label: 'recovered' }) + } finally { + finish(1) + queue.dispose() + } + }) + describe('awaitRetirement', () => { function stalledExit(): { worker: FakeWorker; finish: (code: number) => void } { const worker = new FakeWorker() diff --git a/src/main/worker-thread-request-queue.ts b/src/main/worker-thread-request-queue.ts index 39561fa80a4..6793d597e27 100644 --- a/src/main/worker-thread-request-queue.ts +++ b/src/main/worker-thread-request-queue.ts @@ -42,6 +42,10 @@ export type WorkerThreadRequestQueueOptions = { awaitRetirement?: boolean } +export type WorkerThreadRequestOwner = { readonly signal: AbortSignal } + +type OwnerFailures = { consecutiveDeaths: number; refused: Error | null } + type PendingCall = { request: TRequest timeoutMs: number @@ -49,6 +53,7 @@ type PendingCall = { reject: (error: Error) => void timer: NodeJS.Timeout | null signal?: AbortSignal + owner?: WorkerThreadRequestOwner cleanupAbort: () => void } @@ -59,6 +64,7 @@ export class WorkerThreadRequestQueue< private active: PendingCall | null = null private queue: PendingCall[] = [] private consecutiveDeaths = 0 + private readonly ownerFailures = new WeakMap() private nextId = 1 private disposed = false private readonly host: LazyWorkerThreadHost @@ -85,7 +91,8 @@ export class WorkerThreadRequestQueue< dispatch( buildRequest: (id: number) => TRequest, timeoutMs: number, - signal?: AbortSignal + signal?: AbortSignal, + owner?: WorkerThreadRequestOwner ): Promise { return new Promise((resolve, reject) => { if (this.disposed) { @@ -96,6 +103,11 @@ export class WorkerThreadRequestQueue< reject(signal.reason ?? new Error('Worker request aborted')) return } + const refused = owner && this.ownerFailures.get(owner)?.refused + if (refused) { + reject(refused) + return + } // Built before the cap check so a rejection can name the dropped work; // the id it burns is only a correlation token, so a gap costs nothing. const request = buildRequest(this.nextId++) @@ -106,7 +118,7 @@ export class WorkerThreadRequestQueue< } // A fresh burst from full idle starts new work: clear any death count // carried from a prior burst so the respawn cap can't drain it early. - if (!this.active && this.queue.length === 0) { + if (!owner && !this.active && this.queue.length === 0) { this.consecutiveDeaths = 0 } const call: PendingCall = { @@ -116,6 +128,7 @@ export class WorkerThreadRequestQueue< reject, timer: null, signal, + owner, cleanupAbort: () => signal?.removeEventListener('abort', abort) } const abort = (): void => { @@ -201,7 +214,11 @@ export class WorkerThreadRequestQueue< this.armDeadline(call) return } - this.consecutiveDeaths = 0 + if (call.owner) { + this.failuresFor(call.owner).consecutiveDeaths = 0 + } else { + this.consecutiveDeaths = 0 + } this.settle(call, () => call.resolve(response)) this.afterSettle() } @@ -226,12 +243,16 @@ export class WorkerThreadRequestQueue< private onWorkerFault(error: Error): void { const failed = this.active this.host.destroy() - this.consecutiveDeaths++ - if (failed) { - this.settle(failed, () => failed.reject(error)) + if (!failed) { + this.pump() + return } - if (this.consecutiveDeaths >= this.options.maxConsecutiveDeaths) { - this.drainQueueAfterCrashLoop(error) + const deaths = failed.owner + ? ++this.failuresFor(failed.owner).consecutiveDeaths + : ++this.consecutiveDeaths + this.settle(failed, () => failed.reject(error)) + if (deaths >= this.options.maxConsecutiveDeaths) { + this.drainQueueAfterCrashLoop(error, failed.owner) return } if (this.queue.length > 0) { @@ -239,14 +260,28 @@ export class WorkerThreadRequestQueue< } } - private drainQueueAfterCrashLoop(error: Error): void { - const pending = this.queue - this.queue = [] - this.consecutiveDeaths = 0 + private drainQueueAfterCrashLoop(error: Error, owner?: WorkerThreadRequestOwner): void { + const pending = this.queue.filter((call) => call.owner === owner) + this.queue = this.queue.filter((call) => call.owner !== owner) const drainError = new Error(this.options.describeCrashLoop(error.message)) + if (owner) { + this.failuresFor(owner).refused = drainError + } else { + this.consecutiveDeaths = 0 + } for (const call of pending) { this.settle(call, () => call.reject(drainError)) } + this.afterSettle() + } + + private failuresFor(owner: WorkerThreadRequestOwner): OwnerFailures { + let failures = this.ownerFailures.get(owner) + if (!failures) { + failures = { consecutiveDeaths: 0, refused: null } + this.ownerFailures.set(owner, failures) + } + return failures } private failQueuedAsUnavailable(): void { diff --git a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts new file mode 100644 index 00000000000..48246e10573 --- /dev/null +++ b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts @@ -0,0 +1,281 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { prependOrcaCliDirToChildPath } from './cli/orca-cli-child-path' +import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './pty/posix-shell-startup-command' +import { getZshShellReadyWrapperFile } from './providers/local-pty-shell-ready-wrapper-generation' +import { encodeShellStartupFeatures, selectShellStartupFeatures } from './shell-startup-features' +import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' +import { hasZsh, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness' + +const itWithZsh = hasZsh ? it : it.skip +const USER_WIDGET = `orca_test_line_init() { + O_UC=$((\${O_UC:-0}+1)) + O_UN="$WIDGET" + builtin printf 'ORCA_TEST_USER_WIDGET_CALL\\n' + return 1 +} +zle -N zle-line-init orca_test_line_init +` +const USER_REDRAW = `orca_test_redraw() { + O_RC=$((\${O_RC:-0}+1)) + O_RN="$WIDGET" + return 1 +} +zle -N zle-line-pre-redraw orca_test_redraw +` +const USER_PRECMD = `precmd() { + O_PCALLS=$((\${O_PCALLS:-0}+1)) + O_PN="$0" + O_PO="\${O_PO:-\${options[ksharrays]}:\${options[nounset]}}" + return 1 +} +` + +describe('zsh deferred startup after prompt-hook replacement', () => { + const roots: string[] = [] + + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + }) + + itWithZsh.each([ + 'history', + 'startup', + 'chained-startup', + 'stock-history', + 'stock-startup', + 'stock-chained-redraw', + 'stock-nonzero-precmd', + 'ordered-startup', + 'replaced-precmd-startup', + 'status-startup', + 'sticky-startup', + 'scheduled-startup', + 'unavailable-startup', + 'repeat-history', + 'repeat-startup' + ] as const)( + 'restores CLI precedence and preserves the user widget in a %s pane', + async (intent) => { + const historyOnly = intent.endsWith('history') + const repeatSource = intent.startsWith('repeat-') + const stockBinding = intent.startsWith('stock-') + const chainedLineInit = intent === 'chained-startup' + const chainedRedraw = intent === 'stock-chained-redraw' + const nonzeroPrecmd = intent === 'stock-nonzero-precmd' + const orderedPrecmd = intent === 'ordered-startup' + const replacedPrecmd = intent === 'replaced-precmd-startup' + const statusPrecmd = intent === 'status-startup' + const stickyPrecmd = intent === 'sticky-startup' + const scheduledPrecmd = intent === 'scheduled-startup' + const unavailableSched = intent === 'unavailable-startup' + const scheduleCleanup = scheduledPrecmd || unavailableSched || orderedPrecmd + const home = mkdtempSync(join(tmpdir(), 'orca-deferred-line-init-')) + roots.push(home) + const cliBin = join(home, 'cli', 'bin') + const ambientBin = join(home, 'ambient-bin') + const wrapperDir = join(home, 'wrapper') + for (const bin of [cliBin, ambientBin, wrapperDir]) { + mkdirSync(bin, { recursive: true }) + } + for (const bin of [cliBin, ambientBin]) { + writeFileSync(join(bin, 'orca-dev'), '#!/bin/sh\nexit 0\n') + chmodSync(join(bin, 'orca-dev'), 0o755) + } + writeFileSync( + join(home, '.zshenv'), + (chainedLineInit || chainedRedraw || repeatSource + ? '' + : stockBinding + ? USER_REDRAW + : USER_WIDGET) + + (statusPrecmd + ? 'precmd() { O_FIRST_IN=${O_FIRST_IN:-$?}; }\n' + : stickyPrecmd + ? `emulate sh -c 'precmd() { O_FIRST_IN="\${O_FIRST_IN:-$?:\${options[shwordsplit]}:\${options[ksharrays]}}"; return 1; }'\n` + : scheduledPrecmd + ? 'zmodload zsh/sched\nO_EVENT() { O_EO=${_orca_deferred_init_done:-0}; }\nsched +0 O_EVENT\nsched +3600 O_EVENT\n' + : unavailableSched + ? 'zmodload zsh/zleparameter zsh/terminfo\nO_MP=("${module_path[@]}"); module_path=()\n' + : nonzeroPrecmd || replacedPrecmd + ? USER_PRECMD + : orderedPrecmd + ? USER_PRECMD.replace('return 1', 'return 0') + : '') + ) + if (statusPrecmd || stickyPrecmd) { + writeFileSync( + join(home, '.zlogin'), + 'orca_test_status() { return 42; }; orca_test_status\n' + ) + } + // Replay Ubuntu's later widget binding before the user's own startup changes. + const stockWidget = + stockBinding || stickyPrecmd || scheduledPrecmd + ? USER_WIDGET.replaceAll('orca_test_line_init', 'zle-line-init') + : '' + writeFileSync( + join(home, '.zshrc'), + `${statusPrecmd || stickyPrecmd ? 'PS1="ORCA_FIRST_PROMPT:%? "\n' : ''}${repeatSource ? USER_WIDGET : ''}${unavailableSched ? 'module_path=("${O_MP[@]}")\n' : ''}${stockWidget}export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${orderedPrecmd ? '' : 'precmd_functions=()\n'}${ + chainedLineInit + ? `${USER_WIDGET.replace('zle -N zle-line-init orca_test_line_init', 'zle -N orca_test_line_init')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-init orca_test_line_init\n` + : chainedRedraw + ? `${USER_REDRAW.replace('zle -N zle-line-pre-redraw orca_test_redraw', 'zle -N orca_test_redraw')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-pre-redraw orca_test_redraw\n` + : '' + }${nonzeroPrecmd ? 'orca_test_array() { O_AC=called; }\nprecmd_functions=(orca_test_array)\nsetopt KSH_ARRAYS NO_UNSET\n' : orderedPrecmd ? 'orca_test_array() { O_AO=${O_AO:-${_orca_deferred_init_done:-0}}; }\nprecmd_functions=(orca_test_array "${precmd_functions[@]}")\n' : replacedPrecmd ? 'precmd() { O_NPC=$((${O_NPC:-0}+1)); O_NPN="$0"; }\n' : ''}` + ) + writeFileSync(join(wrapperDir, '.zshenv'), getZshShellReadyWrapperFile()) + writeFileSync(join(wrapperDir, ZSH_WRAPPER_DIR_MARKER_FILE), '') + const env: Record = { + ...process.env, + HOME: home, + USERPROFILE: home, + PATH: `${ambientBin}:/usr/bin:/bin`, + // Stock cases replay this replacement after the fixture installs its own widgets. + DEBIAN_PREVENT_KEYBOARD_CHANGES: '1', + ZDOTDIR: wrapperDir, + ORCA_HISTFILE: join(home, 'scoped-history') + } + const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath: home }) + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env, + hasStartupCommand: !historyOnly, + waitsForShellReady: !historyOnly, + emitsStartupIdentity: false + }) + env.ORCA_SHELL_FEATURES = encodeShellStartupFeatures(features) + if (!historyOnly) { + env[POSIX_SHELL_STARTUP_COMMAND_ENV] = 'O_SU=$((${O_SU:-0}+1))' + } + + if (statusPrecmd || stickyPrecmd) { + const baseline = await runZshPty({ env: { ...env, ZDOTDIR: home }, report: ['O_FIRST_IN'] }) + expect(baseline.values.O_FIRST_IN).toBe(stickyPrecmd ? '42:on:on' : '42') + } + + const result = await runZshPty({ + env, + commands: [ + ...(repeatSource + ? ['source -- "$HOME/wrapper/.zshenv"', 'source -- "$HOME/wrapper/.zshenv"'] + : []), + 'O_LK=$(command -v orca-dev)', + 'O_IR=${+functions[__orca_deferred_line_init]}', + 'O_SR=${+widgets[__orca_saved_line_init]}', + ...(scheduleCleanup + ? [ + 'O_SC=${+functions[__orca_deferred_sched_init]}', + 'O_SE=${zsh_scheduled_events[*]:-UNSET}' + ] + : []), + ...(scheduledPrecmd ? ['O_EV=${#zsh_scheduled_events}'] : []), + ...(stockBinding ? ['O_RW=${widgets[zle-line-pre-redraw]:-none}'] : []), + 'O_LI=${widgets[zle-line-init]:-none}', + 'O_PC="${precmd_functions[*]}"', + ...(nonzeroPrecmd ? ['precmd; O_PS=$?'] : []) + ], + report: [ + 'O_LK', + 'O_UC', + 'O_UN', + 'O_IR', + 'O_SR', + ...(scheduleCleanup ? ['O_SC', 'O_SE'] : []), + ...(scheduledPrecmd ? ['O_EV', 'O_EO'] : []), + ...(stockBinding ? ['O_RW', 'O_RC', 'O_RN'] : []), + ...(nonzeroPrecmd ? ['O_PCALLS', 'O_PN', 'O_PO', 'O_AC', 'O_PS'] : []), + ...(orderedPrecmd ? ['O_PCALLS', 'O_PN', 'O_AO'] : []), + ...(replacedPrecmd ? ['O_PCALLS', 'O_NPC', 'O_NPN'] : []), + ...(statusPrecmd || stickyPrecmd ? ['O_FIRST_IN'] : []), + 'O_LI', + 'O_PC', + 'O_SU', + 'HISTFILE' + ] + }) + + expect(result.values.O_LK).toBe(launcher) + expect(Number(result.values.O_UC)).toBeGreaterThan(0) + if (!chainedLineInit) { + expect(result.values.O_UN).toBe('zle-line-init') + } + if (!chainedLineInit && !chainedRedraw && !repeatSource) { + expect(result.values.O_IR).toBe('0') + } + expect(result.values.O_SR).toBe('0') + if (repeatSource) { + expect(result.output).not.toContain('job table full or recursion limit exceeded') + expect(result.values.O_PC).not.toContain('__orca_deferred_init') + } + if (scheduleCleanup) { + expect(result.values.O_SC).toBe('0') + expect(result.values.O_SE).not.toContain('orca') + } + if (unavailableSched) { + // Stock completion modules can fail before the fixture restores module_path. + expect(result.output).not.toContain('zsh/sched') + expect(result.output).not.toContain('__orca_arm_deferred_line_init:') + expect(result.values.O_SE).toBe('UNSET') + } + if (scheduledPrecmd) { + expect(result.values.O_EV).toBe('1') + expect(result.values.O_EO).toBe('0') + expect(result.values.O_SE).toContain('O_EVENT') + } + if (stockBinding) { + expect(Number(result.values.O_RC)).toBeGreaterThan(0) + expect(result.values.O_RN).toBe(chainedRedraw ? 'orca_test_redraw' : 'zle-line-pre-redraw') + if (!chainedRedraw) { + expect(result.values.O_RW).toBe('user:orca_test_redraw') + } + } + expect(result.values.HISTFILE).toBe(join(home, 'scoped-history')) + if (nonzeroPrecmd) { + expect(Number(result.values.O_PCALLS)).toBeGreaterThan(0) + expect(result.values.O_PN).toBe('precmd') + expect(result.values.O_PO).toBe('on:on') + expect(result.values.O_AC).toBe('called') + expect(result.values.O_PS).toBe('1') + } + if (orderedPrecmd) { + expect(Number(result.values.O_PCALLS)).toBeGreaterThan(0) + expect(result.values.O_PN).toBe('precmd') + expect(result.values.O_AO).toBe('0') + } + if (replacedPrecmd) { + expect(result.values.O_PCALLS).toBe('UNSET') + expect(Number(result.values.O_NPC)).toBeGreaterThan(0) + expect(result.values.O_NPN).toBe('precmd') + } + if (statusPrecmd || stickyPrecmd) { + expect(result.values.O_FIRST_IN).toBe(stickyPrecmd ? '42:on:on' : '42') + expect(result.output).toContain('ORCA_FIRST_PROMPT:42 ') + } + if (historyOnly) { + expect(result.output).not.toContain('\x1b]133;') + expect(result.values.O_LI).toBe( + stockBinding ? 'user:zle-line-init' : 'user:orca_test_line_init' + ) + expect(result.values.O_PC).not.toContain('orca') + expect(result.values.O_SU).toBe('UNSET') + } else { + expect(result.output).toContain(MARKERS.ready) + expect(result.values.O_LI).toBe('user:__orca_prompt_mark') + expect(result.values.O_PC).toBe( + nonzeroPrecmd || orderedPrecmd + ? 'orca_test_array __orca_osc133_precmd' + : '__orca_osc133_precmd' + ) + expect(result.values.O_SU).toBe('1') + expect(result.output.split('ORCA_TEST_USER_WIDGET_CALL\r\n').length - 1).toBe( + result.output.split(MARKERS.ready).length - 1 + ) + } + } + ) +}) diff --git a/src/main/zsh-deferred-startup-line-init.ts b/src/main/zsh-deferred-startup-line-init.ts new file mode 100644 index 00000000000..95b9da94df6 --- /dev/null +++ b/src/main/zsh-deferred-startup-line-init.ts @@ -0,0 +1,56 @@ +// Why: stock zshrc can replace line-init after the user clears the prompt-hook array. +export const ZSH_DEFERRED_LINE_INIT_BLOCK = `__orca_deferred_line_init() { + builtin emulate -L zsh + (( \${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && \${+widgets[zle-line-init]} )); then + zle zle-line-init "$@" + elif [[ "\${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" + fi +} +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? + builtin emulate -L zsh + (( \${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + if [[ "\${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( \${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 + fi +}` + +// Why: restore the exact prior widget before the existing readiness hook captures it. +export const ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK = ` if (( \${+widgets[__orca_saved_line_init]} )); then + if [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi` + +// Why: add-zle-hook-widget can keep an alias of the bootstrap in its own chain. +export const ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK = ` (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "\${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init` diff --git a/src/main/zsh-scoped-histfile.live-shell.test.ts b/src/main/zsh-scoped-histfile.live-shell.test.ts index 68ee27d24e8..5c5020c96bc 100644 --- a/src/main/zsh-scoped-histfile.live-shell.test.ts +++ b/src/main/zsh-scoped-histfile.live-shell.test.ts @@ -333,25 +333,14 @@ describe.skipIf(process.platform === 'win32')( }) itWithZsh( - 'degrades to an unwrapped pane, leaking nothing, when a config drops precmd_functions', + 'still scopes history without leaking it when a config drops precmd_functions', withHome({ ...USER_FILES, '.zshrc': 'precmd_functions=()\n' }, async (home) => { const scoped = join(home, 'orca-history', 'zsh_history') const { env, launch } = launchPane(home, scoped) const report = ['HISTFILE', 'ORCA_HISTFILE', 'ZDOTDIR'] const { values } = await runZshPty({ env, report }) - // Why compared against an unwrapped run rather than asserted to differ - // from the scoped path: whether the scoped value survives at all is the - // host's call, not Orca's. macOS /etc/zshrc overwrites HISTFILE, so it - // does not; a host with no such assignment keeps whatever the spawn env - // set. The contract on both is the same — this pane is the pane the user - // would have had unwrapped. - const unwrapped = await runZshPty({ - env: { PATH: '/usr/bin:/bin', HOME: home, HISTFILE: scoped }, - report - }) - - expect(values.HISTFILE).toBe(unwrapped.values.HISTFILE) + expect(values.HISTFILE).toBe(scoped) expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR) // ORCA_HISTFILE was consumed in .zshenv precisely so a dropped hook // leaks nothing to the pane's children. diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index d6d6ef39a30..6ec28b647c2 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -1,3 +1,4 @@ +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-account-shell' /** * The single `.zshenv` Orca writes for every transport: local PTY, daemon/SSH, @@ -14,7 +15,8 @@ import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-accou * dir shared by two installed builds could mix files from both. * * This shape gives ZDOTDIR back before anything else can observe it, then defers - * Orca's work to a `precmd` hook that runs at the first prompt — after + * Orca's work to a `precmd` hook, with a one-shot line-editor fallback if the + * user's config replaces its hook array. Both run at the first prompt — after * `.zprofile`, `/etc/zshrc`, `.zshrc` and `.zlogin`, all of which zsh now reads * from the user's own directory exactly as in an unwrapped shell. #11044 becomes * unreachable rather than repaired, and the emulation and mixed-build classes @@ -30,6 +32,11 @@ import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-accou import { getPosixOmpShellWrapper } from './pty/omp-shell-wrapper' import { WSL_MANAGED_CLI_PATH_RESTORE } from './wsl-managed-cli-path-restore' import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' +import { + ZSH_DEFERRED_LINE_INIT_BLOCK, + ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK, + ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK +} from './zsh-deferred-startup-line-init' import { getZshShellReadyMarkerRegistrationBlock, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, @@ -146,6 +153,7 @@ function buildDeferredInit(spec: ZshStartupHookSpec): string { const permanentPrecmd = spec.osc133CommandMarkers ? ` if __orca_has_feature markers; then precmd_functions=(\${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + (( \${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd) preexec_functions=(__orca_osc133_preexec \${preexec_functions[@]}) else precmd_functions=(\${precmd_functions:#__orca_deferred_init}) @@ -170,9 +178,11 @@ ${indentBlock(getZshShellReadyMarkerRegistrationBlock(spec.readyMarkerEscaped, t (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions +${ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK} ${permanentPrecmd} ${joinBlocks([ spec.restores.managedWslCli ? indentBlock(WSL_MANAGED_CLI_PATH_RESTORE, ' ') : null, + indentBlock(ORCA_CLI_POSIX_PATH_RESTORE, ' ').replace(/\n$/, ''), featureGuard('overlay', getOverlayRestoreBlocks(spec)), // Why outside the overlay guard: a system-default Codex home carries no overlay key. indentBlock(getPosixCodexShellLaunchPreflight(), ' ').replace(/\n$/, ''), @@ -190,7 +200,8 @@ ${ __orca_has_feature markers && __orca_osc133_precmd\n` : '' } builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature +${ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK} + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init }` } @@ -201,6 +212,7 @@ export function buildZshStartupHook(spec: ZshStartupHookSpec): string { ZSH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, spec.osc133CommandMarkers ? ZSH_OSC133_FUNCTION_BLOCK : null, + ZSH_DEFERRED_LINE_INIT_BLOCK, buildDeferredInit(spec), ZSH_USER_ZSHENV_SOURCE_BLOCK ])}\n` diff --git a/src/relay/fs-handler-list-files-cancel.test.ts b/src/relay/fs-handler-list-files-cancel.test.ts index 97bea1b0813..b6829921cb4 100644 --- a/src/relay/fs-handler-list-files-cancel.test.ts +++ b/src/relay/fs-handler-list-files-cancel.test.ts @@ -52,7 +52,7 @@ describe('relay list-files cancellation', () => { const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal }) // Partial output before the abort — must be discarded, not resolved. - ignoredProc.stdout?.emit('data', 'src/index.ts\n') + ignoredProc.stdout?.emit('data', 'src/index.ts\0') controller.abort() await expect(promise).rejects.toSatisfy(isFileListingCancellation) @@ -81,8 +81,8 @@ describe('relay list-files cancellation', () => { const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal }) setTimeout(() => { - ignoredProc.stdout?.emit('data', 'src/index.ts\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/out.js\n') + ignoredProc.stdout?.emit('data', 'src/index.ts\0') + ignoredProc.stdout?.emit('data', 'dist/out.js\0') ignoredProc.emit('close', 0, null) }, 5) diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index 3f8b8c2b883..36a3914d021 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -71,6 +71,21 @@ describe('relay quick open ignored file listing', () => { await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) + it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { + const child = createMockProcess() + spawnMock.mockReturnValue(child) + const promise = listFilesWithRg('/remote/root') + + child.stdout?.emit('data', Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) + if (kind === 'incomplete') { + child.emit('close', 0, null) + } + await expect(promise).rejects.toThrow('not valid UTF-8') + if (kind === 'invalid') { + expect(child.kill).toHaveBeenCalled() + } + }) + it('uses one broad rg pass for unbounded listings and keeps blocklists/excludes', async () => { const ignoredProc = createMockProcess() @@ -80,10 +95,10 @@ describe('relay quick open ignored file listing', () => { expect(spawnMock).toHaveBeenCalledTimes(1) setTimeout(() => { - ignoredProc.stdout?.emit('data', 'src/index.ts\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'node_modules/pkg/index.js\n') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\n') + ignoredProc.stdout?.emit('data', 'src/index.ts\0') + ignoredProc.stdout?.emit('data', 'dist/generated.js\0') + ignoredProc.stdout?.emit('data', 'node_modules/pkg/index.js\0') + ignoredProc.stdout?.emit('data', 'packages/other/src/x.ts\0') ignoredProc.emit('close', 0, null) }, 10) @@ -106,10 +121,7 @@ describe('relay quick open ignored file listing', () => { spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) const promise = listFilesWithRg('/remote/root', [], { maxResults: 2 }) - ;(primaryProc.stdout as unknown as EventEmitter).emit( - 'data', - 'src/one.ts\nsrc/two.ts\nsrc/three.ts\n' - ) + primaryProc.stdout?.emit('data', 'src/one.ts\0src/two.ts\0src/three.ts\0') await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) expect(primaryProc.kill).toHaveBeenCalled() @@ -127,14 +139,11 @@ describe('relay quick open ignored file listing', () => { expect(spawnMock).toHaveBeenCalledTimes(1) expect(spawnMock.mock.calls[0][1]).toContain('--no-ignore-vcs') - ;(ignoredProc.stdout as unknown as EventEmitter).emit( + ignoredProc.stdout?.emit( 'data', - `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\n')}\n` - ) - ;(ignoredProc.stdout as unknown as EventEmitter).emit( - 'data', - 'src/components/target.ts\nscripts/check-target.ts\n' + `${Array.from({ length: 100_100 }, (_, index) => `data/payload-${index}.bin`).join('\0')}\0` ) + ignoredProc.stdout?.emit('data', 'src/components/target.ts\0scripts/check-target.ts\0') ignoredProc.emit('close', 0, null) await expect(promise).resolves.toEqual(['scripts/check-target.ts', 'src/components/target.ts']) @@ -148,11 +157,11 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithRg('/remote/root', [], { maxResults: 2 }) expect(spawnMock).toHaveBeenCalledTimes(1) expect(spawnMock.mock.calls[0][1]).not.toContain('--no-ignore-vcs') - primary.stdout?.emit('data', 'src/index.ts\n') + primary.stdout?.emit('data', 'src/index.ts\0') primary.emit('close', 0, null) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) expect(spawnMock.mock.calls[1][1]).toContain('--no-ignore-vcs') - broad.stdout?.emit('data', 'src/index.ts\ndist/generated.js\ndist/extra.js\n') + broad.stdout?.emit('data', 'src/index.ts\0dist/generated.js\0dist/extra.js\0') await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) expect(broad.kill).toHaveBeenCalled() @@ -168,14 +177,11 @@ describe('relay quick open ignored file listing', () => { maxResults: 32, searchQuery: 'target' }) - ;(failed.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n') + failed.stdout?.emit('data', 'src/target.ts\0') failed.emit('error', Object.assign(new Error('spawn rg EAGAIN'), { code: 'EAGAIN' })) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - ;(succeeded.stdout as unknown as EventEmitter).emit( - 'data', - 'src/target.ts\nsrc/another-target.ts\n' - ) + succeeded.stdout?.emit('data', 'src/target.ts\0src/another-target.ts\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/target.ts', 'src/another-target.ts']) @@ -193,7 +199,7 @@ describe('relay quick open ignored file listing', () => { searchQuery: 'target' }) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - ;(succeeded.stdout as unknown as EventEmitter).emit('data', 'src/target.ts\n') + succeeded.stdout?.emit('data', 'src/target.ts\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/target.ts']) @@ -209,7 +215,7 @@ describe('relay quick open ignored file listing', () => { failed.emit('error', Object.assign(new Error('spawn rg EAGAIN'), { code: 'EAGAIN' })) await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) - succeeded.stdout?.emit('data', 'src/index.ts\ndist/generated.js\n') + succeeded.stdout?.emit('data', 'src/index.ts\0dist/generated.js\0') succeeded.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/index.ts', 'dist/generated.js']) @@ -305,18 +311,12 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit(root, ['packages/other']) setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit( - 'data', - `${staged('100644', 'src/index.ts')}\0` - ) - ;(primaryProc.stdout as unknown as EventEmitter).emit( - 'data', - `${staged('100644', 'tab\tfile.txt')}\0` - ) + primaryProc.stdout?.emit('data', `${staged('100644', 'src/index.ts')}\0`) + primaryProc.stdout?.emit('data', `${staged('100644', 'tab\tfile.txt')}\0`) primaryProc.emit('close', 0, null) - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/\0') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'packages/other/src/x.ts\0') + ignoredProc.stdout?.emit('data', 'dist/\0') + ignoredProc.stdout?.emit('data', 'packages/other/src/x.ts\0') ignoredProc.emit('close', 0, null) }, 10) @@ -346,7 +346,7 @@ describe('relay quick open ignored file listing', () => { spawnMock.mockImplementation(() => (++callIndex === 1 ? primaryProc : ignoredProc)) const promise = listFilesWithGit('/remote/root', [], { maxResults: 2 }) - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/one.ts\0src/two.ts') + primaryProc.stdout?.emit('data', 'src/one.ts\0src/two.ts') primaryProc.emit('close', 0, null) await expect(promise).resolves.toEqual(['src/one.ts', 'src/two.ts']) expect(primaryProc.kill).toHaveBeenCalled() @@ -359,10 +359,7 @@ describe('relay quick open ignored file listing', () => { spawnMock.mockReturnValue(primaryProc) const promise = listFilesWithGit('/remote/root', [], { maxResults: 1 }) - ;(primaryProc.stdout as unknown as EventEmitter).emit( - 'data', - `discarded/\0${staged('100644', 'src/kept.ts')}\0` - ) + primaryProc.stdout?.emit('data', `discarded/\0${staged('100644', 'src/kept.ts')}\0`) await expect(promise).resolves.toEqual(['src/kept.ts']) expect(primaryProc.kill).toHaveBeenCalled() @@ -389,7 +386,7 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit(root) setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit( + primaryProc.stdout?.emit( 'data', `${staged('100644', 'README.md')}\0${staged('160000', 'packages/app')}\0packages/lib/\0` ) @@ -419,11 +416,11 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit('/remote/root') setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + primaryProc.stdout?.emit('data', 'src/index.ts\0') primaryProc.emit('close', 0, null) // Entries streamed before the kill are kept alongside the primary pass. - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.stdout?.emit('data', 'dist/generated.js\0') ignoredProc.emit('close', null, 'SIGTERM') }, 10) @@ -449,7 +446,7 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit('/remote/root') setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + primaryProc.stdout?.emit('data', 'src/index.ts\0') primaryProc.emit('close', 0, null) ignoredProc.emit('close', 128, null) @@ -475,10 +472,10 @@ describe('relay quick open ignored file listing', () => { const promise = listFilesWithGit('/remote/root') setTimeout(() => { - ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0') + primaryProc.stdout?.emit('data', 'src/index.ts\0') primaryProc.emit('close', null, 'SIGTERM') - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.stdout?.emit('data', 'dist/generated.js\0') ignoredProc.emit('close', 0, null) }, 10) @@ -500,7 +497,7 @@ describe('relay quick open ignored file listing', () => { setTimeout(() => { primaryProc.emit('close', 128, null) - ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/generated.js\0') + ignoredProc.stdout?.emit('data', 'dist/generated.js\0') ignoredProc.emit('close', 0, null) }, 10) @@ -733,12 +730,12 @@ describe('relay quick open ignored file listing', () => { expect(() => probe.emit('error', error)).not.toThrow() }) - it('keeps post-spawn rg search errors on the existing empty-result path', async () => { + it('rejects post-spawn rg search errors instead of returning an empty result', async () => { const started = createMockProcess() Object.defineProperty(started, 'pid', { value: 1 }) spawnMock.mockReturnValueOnce(started) const ordinaryFailure = searchWithRg('/remote/root', 'ok', { maxResults: 100 }) started.emit('error', new Error('post-spawn failure')) - await expect(ordinaryFailure).resolves.toMatchObject({ files: [], totalMatches: 0 }) + await expect(ordinaryFailure).rejects.toThrow('post-spawn failure') }) }) diff --git a/src/relay/fs-handler-list-files-path-framing.test.ts b/src/relay/fs-handler-list-files-path-framing.test.ts new file mode 100644 index 00000000000..037bbc97a41 --- /dev/null +++ b/src/relay/fs-handler-list-files-path-framing.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { rgPath } from '@vscode/ripgrep-universal' +import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' +import { listFilesWithRg } from './fs-handler-list-files' + +let root: string | undefined + +afterEach(async () => { + configureRelayBundledRipgrep(undefined) + vi.unstubAllEnvs() + if (root) { + await rm(root, { recursive: true, force: true }) + } +}) + +it('ignores user rg configuration when listing files', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rg-config-')) + configureRelayBundledRipgrep(rgPath) + const config = join(root, 'config') + await writeFile(config, '--glob\n!*.ts\n') + await writeFile(join(root, 'visible.ts'), '') + vi.stubEnv('RIPGREP_CONFIG_PATH', config) + + expect(await listFilesWithRg(root)).toContain('visible.ts') + expect(await listFilesWithRg(root, [], { maxResults: 10 })).toContain('visible.ts') +}) + +it.skipIf(process.platform === 'win32')( + 'preserves newline, carriage return and Unicode filenames', + async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rg-filenames-')) + configureRelayBundledRipgrep(rgPath) + const names = ['first\nsecond.ts', 'trailing\r', 'résumé-😀.ts', 'spaces .ts '] + const fixtureRoot = root + await Promise.all(names.map((name) => writeFile(join(fixtureRoot, name), ''))) + + expect((await listFilesWithRg(root)).sort()).toEqual([...names].sort()) + expect((await listFilesWithRg(root, [], { maxResults: 10 })).sort()).toEqual([...names].sort()) + expect(await listFilesWithRg(root, [], { searchQuery: 'second', maxResults: 1 })).toEqual([ + 'first\nsecond.ts' + ]) + } +) + +it.skipIf(process.platform !== 'linux')( + 'rejects real non-UTF8 filenames instead of fabricating paths', + async () => { + root = await mkdtemp(join(tmpdir(), 'orca-rg-invalid-name-')) + configureRelayBundledRipgrep(rgPath) + const invalidPath = Buffer.concat([Buffer.from(join(root, 'bad-')), Buffer.from([0xff])]) + await writeFile(invalidPath, '') + await expect(listFilesWithRg(root)).rejects.toThrow('not valid UTF-8') + } +) diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index 506693fa0c8..ca25cd85e9e 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -1,3 +1,4 @@ +import { RipgrepFilenameDecoder } from '../shared/ripgrep-filename-decoder' /** * Ripgrep-based file listing for Quick Open. * Why a full rewrite vs. the older execFile+maxBuffer version: on a home-dir @@ -97,6 +98,10 @@ export function listFilesWithRg( new Promise((passResolve, passReject) => { const attemptRanker = searchQuery === undefined ? null : new QuickOpenPathRanker(searchQuery, maxResults ?? 16) + const filenameDecoder = new RipgrepFilenameDecoder((error) => { + killSpawnedRipgrepProcess(child) + rejectPass(error) + }) let passBuf = '' let passDone = false let passFileCount = 0 @@ -126,11 +131,9 @@ export function listFilesWithRg( ) : error } - let timer: ReturnType | null = null const cleanup = (): void => { if (timer) { clearTimeout(timer) - timer = null } child.stdout?.off('data', handleStdoutData) child.stderr?.off('data', handleStderrData) @@ -188,17 +191,21 @@ export function listFilesWithRg( } children.push({ child, isDone: () => passDone, reject: rejectPass }) - timer = setTimeout(() => { + const timer = setTimeout(() => { // Discard residual buffer on abnormal exit — a truncated byte // sequence could look like a valid path. killSpawnedRipgrepProcess(child) rejectPass(new Error('rg list timed out')) }, LIST_FILES_TIMEOUT_MS) - function handleStdoutData(chunk: string): void { - passBuf += chunk + function handleStdoutData(chunk: Buffer | string): void { + const decoded = filenameDecoder.decode(chunk) + if (decoded === null) { + return + } + passBuf += decoded let start = 0 - let idx = passBuf.indexOf('\n', start) + let idx = passBuf.indexOf('\0', start) while (idx !== -1) { if (processLine(passBuf.substring(start, idx), attemptRanker)) { passFileCount++ @@ -207,7 +214,7 @@ export function listFilesWithRg( return } start = idx + 1 - idx = passBuf.indexOf('\n', start) + idx = passBuf.indexOf('\0', start) } passBuf = start < passBuf.length ? passBuf.substring(start) : '' } @@ -248,6 +255,9 @@ export function listFilesWithRg( rejectPass(new Error(`rg killed by ${signal}`)) return } + if (!filenameDecoder.finish()) { + return + } // Flush residual line only on clean exit. if (passBuf) { if (processLine(passBuf, attemptRanker)) { @@ -259,16 +269,13 @@ export function listFilesWithRg( // (e.g. EACCES on .ssh), but rg also returns 2 for fatal errors // (bad flag, invalid glob). Only trust exit 2 when rg emitted at // least one parseable path — otherwise treat it as a real failure. - if (code === 0 || code === 1) { - resolvePass() - } else if (code === 2 && passFileCount > 0) { + if (code === 0 || code === 1 || (code === 2 && passFileCount > 0)) { resolvePass() } else { rejectPass(new Error(`rg exited with code ${code}`)) } } - child.stdout?.setEncoding('utf-8') child.stdout?.on('data', handleStdoutData) child.stderr?.on('data', handleStderrData) child.once('error', handleError) diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index a3b2234ced0..ec2715c9b1a 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -1,3 +1,4 @@ +import { RipgrepSearchDiagnostics } from '../shared/ripgrep-search-diagnostics' /** * Pure helpers and child-process search utilities extracted from fs-handler.ts. * @@ -111,18 +112,15 @@ export function searchWithRg( return Promise.reject(abortSignalReason(signal)) } return new Promise((resolve, reject) => { - const rgArgs = buildRgArgs(query, rootPath, opts) + const rgArgs = buildRgArgs(query, '.', opts) const acc = createAccumulator() - const lines = new SearchSubprocessLineAccumulator(Number.MAX_SAFE_INTEGER) + const lines = new SearchSubprocessLineAccumulator() + const diagnostics = new RipgrepSearchDiagnostics() let resolved = false let processErrorObserved = false let unavailableExitObserved = false let launchFailureCheck: Promise | null = null - // Why: spawn can throw synchronously on invalid options (e.g. bad cwd), - // which would leak out of the `new Promise` executor and leave the - // promise forever pending. Treat a synchronous throw as a clean - // "no results" fallback, the same way an async 'error' event is handled. const resolvedRgCommand = resolveRelayRipgrepCommand() // Why not spawn a bare name when this is null: on Windows CreateProcessW searches the spawn // cwd -- the user's repo -- before PATH, so a planted rg.exe would run instead. @@ -142,8 +140,8 @@ export function searchWithRg( env, stdio: ['ignore', 'pipe', 'pipe'] }) - } catch { - resolve(finalize(acc)) + } catch (error) { + reject(error) return } @@ -181,9 +179,14 @@ export function searchWithRg( } } - function resolveOnce(): void { + function resolveOnce(code = 0, signal: NodeJS.Signals | null = null): void { if (settle()) { - resolve(finalize(acc)) + const error = diagnostics.failure(code, signal, acc) + if (error) { + reject(error) + } else { + resolve(finalize(acc)) + } } } @@ -235,11 +238,15 @@ export function searchWithRg( } function handleStdoutData(chunk: string): void { - lines.push(chunk, processLine) + if (!lines.push(chunk, processLine)) { + acc.truncated = true + killSpawnedRipgrepProcess(child) + resolveOnce() + } } - function handleStderrData(): void { - /* drain */ + function handleStderrData(chunk: Buffer): void { + diagnostics.append(chunk) } function handleError(error: Error): void { @@ -248,7 +255,10 @@ export function searchWithRg( settleLaunchFailure(error) return } - resolveOnce() + if (settle()) { + reject(error) + } + killSpawnedRipgrepProcess(child) } function handleClose(code: number | null, signal: NodeJS.Signals | null): void { @@ -261,11 +271,11 @@ export function searchWithRg( settleLaunchFailure() return } - const tail = lines.finish() + const tail = !signal && (code === 0 || code === 1) ? lines.finish() : null if (tail !== null) { processLine(tail) } - resolveOnce() + resolveOnce(code ?? -1, signal) } child.stdout?.setEncoding('utf-8') diff --git a/src/relay/fs-search-errors-real.test.ts b/src/relay/fs-search-errors-real.test.ts new file mode 100644 index 00000000000..eaf7fb9633c --- /dev/null +++ b/src/relay/fs-search-errors-real.test.ts @@ -0,0 +1,39 @@ +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 { rgPath } from '@vscode/ripgrep-universal' +import { configureRelayBundledRipgrep } from './relay-bundled-ripgrep' +import { searchWithRg } from './fs-handler-utils' + +let root: string +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-search-errors-')) + configureRelayBundledRipgrep(rgPath) + await writeFile(join(root, 'source.txt'), 'needle\n') +}) +afterEach(async () => { + configureRelayBundledRipgrep(undefined) + await rm(root, { recursive: true, force: true }) +}) + +it('reports an invalid regular expression as an error', async () => { + await expect(searchWithRg(root, '[', { useRegex: true, maxResults: 100 })).rejects.toThrow( + /regex parse error|unclosed character class/ + ) +}) + +it('distinguishes no matches from invalid syntax', async () => { + await expect(searchWithRg(root, 'absent', { maxResults: 100 })).resolves.toEqual({ + files: [], + totalMatches: 0, + truncated: false + }) +}) + +it('retains intentional capped results after stopping the process', async () => { + await writeFile(join(root, 'source.txt'), 'needle\n'.repeat(1000)) + const result = await searchWithRg(root, 'needle', { maxResults: 2 }) + expect(result.totalMatches).toBe(2) + expect(result.truncated).toBe(true) +}) diff --git a/src/relay/opencode-overlay-mirror.ts b/src/relay/opencode-overlay-mirror.ts new file mode 100644 index 00000000000..0bd5e0b0f6c --- /dev/null +++ b/src/relay/opencode-overlay-mirror.ts @@ -0,0 +1,40 @@ +import { mkdirSync, readdirSync, realpathSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { mirrorEntry } from '../main/pty/overlay-mirror' + +export function mirrorOpenCodeConfig( + sourceDir: string, + overlayDir: string, + excludedPluginEntries: ReadonlySet +): void { + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = join(sourceDir, entry.name) + if (entry.name === 'plugins') { + const isSymlink = entry.isSymbolicLink() + let isLinkPointingToDir = false + if (isSymlink) { + try { + isLinkPointingToDir = statSync(sourcePath).isDirectory() + } catch { + isLinkPointingToDir = false + } + } + if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { + const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath + const overlayPluginsDir = join(overlayDir, 'plugins') + mkdirSync(overlayPluginsDir, { recursive: true }) + for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { + if (excludedPluginEntries.has(pluginEntry.name)) { + continue + } + mirrorEntry( + join(resolvedSource, pluginEntry.name), + join(overlayPluginsDir, pluginEntry.name) + ) + } + continue + } + } + mirrorEntry(sourcePath, join(overlayDir, entry.name)) + } +} diff --git a/src/relay/opencode-startup-prompt-install.test.ts b/src/relay/opencode-startup-prompt-install.test.ts new file mode 100644 index 00000000000..71cb373511e --- /dev/null +++ b/src/relay/opencode-startup-prompt-install.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PluginOverlayManager } from './plugin-overlay' +import { createInstallPluginsHandler } from './wsl-install-plugins-handler' + +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'relay-prompt-install-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) +const promptPath = (config: string) => + join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js') +describe('execution-host startup prompt installation', () => { + it('caches prompt source independently and revokes future installs with an empty source', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const config = join(root, 'custom') + manager.setSources({ opencodeStartupPromptSource: 'prompt source' }) + expect(manager.hasOpenCodeSource()).toBe(false) + expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true) + manager.setSources({ opencodePluginSource: '' }) + expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true) + expect(readFileSync(promptPath(config), 'utf8')).toBe('prompt source') + manager.setSources({ opencodeStartupPromptSource: '' }) + expect(manager.installOpenCodeStartupPromptPlugin({}, join(root, 'not-installed'))).toBe(false) + expect(existsSync(join(root, 'not-installed'))).toBe(false) + }) + it('materializes a prompt-only overlay without overwriting a same-named user plugin', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const config = join(root, 'custom') + mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt'), { recursive: true }) + writeFileSync(promptPath(config), 'user collision') + writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}') + manager.setSources({ opencodeStartupPromptSource: 'prompt source' }) + const overlay = manager.materializeOpenCode('pane', config) + if (!overlay) { + throw new Error('Missing prompt overlay') + } + expect(readFileSync(promptPath(overlay), 'utf8')).toBe('prompt source') + expect(readFileSync(promptPath(config), 'utf8')).toBe('user collision') + expect(readFileSync(join(overlay, 'opencode.json'), 'utf8')).toContain('user/model') + expect(existsSync(join(overlay, 'plugins', 'orca-opencode-status.js'))).toBe(false) + }) + it('installs through WSL with both status sources disabled and preserves explicit config', () => { + const config = join(root, 'custom') + const manager = new PluginOverlayManager({ homeDir: root }) + const install = createInstallPluginsHandler(manager, { + HOME: root, + OPENCODE_CONFIG_DIR: config + }) + const result = install({ + opencodeStartupPromptSource: 'first', + opencodePluginSource: '', + opencode2PluginSource: '' + }) + expect(result.installed).toMatchObject({ + opencodeStartupPrompt: true, + opencode: false, + opencode2: false + }) + expect(result.overlayDirs).toEqual({}) + expect(readFileSync(promptPath(config), 'utf8')).toBe('first') + install({ opencodeStartupPromptSource: 'second' }) + expect(readFileSync(promptPath(config), 'utf8')).toBe('second') + }) + it('refreshes prompt source in both cached status overlays without rebuilding them', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const install = createInstallPluginsHandler(manager, { HOME: root }) + const first = install({ + opencodeStartupPromptSource: 'first', + opencodePluginSource: 'status v1', + opencode2PluginSource: 'status v2' + }) + const second = install({ opencodeStartupPromptSource: 'second' }) + expect(second.overlayDirs).toEqual(first.overlayDirs) + for (const config of [second.overlayDirs.opencode, second.overlayDirs.opencode2]) { + if (!config) { + throw new Error('Missing status overlay') + } + expect(readFileSync(promptPath(config), 'utf8')).toBe('second') + } + }) +}) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 6d78918c46e..08c9f772bed 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -1,3 +1,9 @@ +import { mirrorOpenCodeConfig } from './opencode-overlay-mirror' +import { + writeOpenCodeStartupPromptPlugin, + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY +} from '../shared/opencode-startup-prompt-install' +import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // Why: relay-side equivalent of Orca's local agent integration installers. // OpenCode still needs a config overlay, while Pi/OMP now get Orca-managed @@ -17,19 +23,11 @@ import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // implementation rooted at $HOME/.orca-relay/ for OpenCode and at the remote // Pi/OMP homes for those agents. import { createHash } from 'node:crypto' -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - realpathSync, - statSync, - writeFileSync -} from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { writeOverlayOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write' import { homedir } from 'node:os' import { join } from 'node:path' -import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' +import { safeRemoveOverlay } from '../main/pty/overlay-mirror' import type { PiAgentKind } from '../shared/pi-agent-kind' import { installOpenCodePluginInCanonicalConfig, @@ -52,9 +50,10 @@ const PI_OVERLAY_SUBDIR_BY_KIND: Record = { const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE2_PLUGIN_FILE = 'orca-opencode2-status.js' // Orca's own entries (either major, file and TUI copy) are never mirrored from user config. -const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set( - [OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) -) +const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set([ + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, + ...[OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) +]) const PI_EXTENSION_FILE = 'orca-agent-status.ts' const PI_AGENT_SUBDIR = 'agent' const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' @@ -87,6 +86,7 @@ function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } export type PluginSources = { + opencodeStartupPromptSource?: string /** Empty string revokes future installs; omission preserves the cached source. */ opencodePluginSource?: string /** Source body of OpenCode 2's status plugin. */ @@ -118,6 +118,7 @@ export function getRelayOpenCodePluginPath( ) } export class PluginOverlayManager { + private opencodeStartupPromptSource: string | null = null private opencodePluginSource: string | null = null private opencode2PluginSource: string | null = null private piExtensionSources: Record = { @@ -147,6 +148,9 @@ export class PluginOverlayManager { * process start. Future PTYs pick up the refreshed source when the relay * writes plugin/extension files before spawn. */ setSources(sources: PluginSources): void { + if (typeof sources.opencodeStartupPromptSource === 'string') { + this.opencodeStartupPromptSource = sources.opencodeStartupPromptSource + } if (typeof sources.opencodePluginSource === 'string') { this.opencodePluginSource = sources.opencodePluginSource } @@ -178,38 +182,6 @@ export class PluginOverlayManager { const source = this.piExtensionSources[kind] return source ?? (kind === 'omp' ? this.piExtensionSources.pi : null) } - private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void { - for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { - const sourcePath = join(sourceDir, entry.name) - if (entry.name === 'plugins') { - const isSymlink = entry.isSymbolicLink() - let isLinkPointingToDir = false - if (isSymlink) { - try { - isLinkPointingToDir = statSync(sourcePath).isDirectory() - } catch { - isLinkPointingToDir = false - } - } - if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { - const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath - const overlayPluginsDir = join(overlayDir, 'plugins') - mkdirSync(overlayPluginsDir, { recursive: true }) - for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { - if (ORCA_OPENCODE_PLUGIN_ENTRIES.has(pluginEntry.name)) { - continue - } - mirrorEntry( - join(resolvedSource, pluginEntry.name), - join(overlayPluginsDir, pluginEntry.name) - ) - } - continue - } - } - mirrorEntry(sourcePath, join(overlayDir, entry.name)) - } - } private writeOpenCodePlugin(overlayDir: string, pluginFileName: string, source: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) @@ -230,7 +202,7 @@ export class PluginOverlayManager { agent: 'opencode' | 'opencode2' = 'opencode' ): string | null { const source = agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource - if (!source || !isUsableId(id)) { + if ((!source && !this.opencodeStartupPromptSource) || !isUsableId(id)) { return null } const pluginFileName = agent === 'opencode2' ? OPENCODE2_PLUGIN_FILE : OPENCODE_PLUGIN_FILE @@ -246,9 +218,14 @@ export class PluginOverlayManager { // Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's // remote root into the overlay before adding Orca's plugin so status // reporting does not hide their auth, models, keybinds, or plugins. - this.mirrorOpenCodeConfig(existingConfigDir, dir) + mirrorOpenCodeConfig(existingConfigDir, dir, ORCA_OPENCODE_PLUGIN_ENTRIES) + } + if (source) { + this.writeOpenCodePlugin(dir, pluginFileName, source) + } + if (this.opencodeStartupPromptSource) { + writeOpenCodeStartupPromptPlugin(dir, this.opencodeStartupPromptSource, 'overlay') } - this.writeOpenCodePlugin(dir, pluginFileName, source) return dir } catch (err) { process.stderr.write( @@ -258,6 +235,29 @@ export class PluginOverlayManager { } } + installOpenCodeStartupPromptPlugin( + environment: NodeJS.ProcessEnv | Record, + configDir?: string + ): boolean { + if (!this.opencodeStartupPromptSource) { + return false + } + try { + writeOpenCodeStartupPromptPlugin( + configDir ?? + environment.OPENCODE_CONFIG_DIR ?? + resolveOpenCodeConfigDirectory(environment, this.homeDir), + this.opencodeStartupPromptSource + ) + return true + } catch (error) { + process.stderr.write( + `[plugin-overlay] failed to install OpenCode startup prompt: ${error instanceof Error ? error.message : String(error)}\n` + ) + return false + } + } + hasOpenCode2Source(): boolean { return this.hasOpenCodeSource('opencode2') } diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 4f86f8a3508..b96781818e7 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -34,6 +34,13 @@ import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces' import { applyScrubSafeAgentEnvAliases } from '../shared/agent-hook-scrub-safe-env' import { addWslEnvKeys } from '../shared/wsl-env' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../shared/opencode-startup-prompt' import { ORCA_IMAGE_PROTOCOL_ENV, ORCA_IMAGE_PROTOCOL_VALUE @@ -2043,6 +2050,16 @@ export class PtyHandler { envToDelete ) delete spawnEnv.ORCA_OPENCODE_PLUGIN_API + // Relay input streams lack driving-input provenance, so native intent is unavailable. + for (const key of [ + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV + ]) { + delete spawnEnv[key] + } const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({ command, agent: launchAgent, diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts index c8d699f36be..82ad3a7fb85 100644 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ b/src/relay/pty-shell-overlay-wrappers.ts @@ -1,6 +1,7 @@ import { readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../shared/orca-cli-shell-path' import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' import { BASH_FEATURE_CHANNEL_BLOCK, @@ -73,6 +74,7 @@ fi [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac +${ORCA_CLI_POSIX_PATH_RESTORE} ${getPosixOmpShellWrapper()} ${getPosixCodexShellLaunchPreflight()}${BASH_HISTFILE_RESTORE_BLOCK} # Why: SSH bash sessions need the same command lifecycle markers as local diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index e0d9a95ae68..4ee9113919a 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -186,17 +186,20 @@ export class RelayAgentHookRuntime { })) registerManagedHookInstaller(this.dispatcher) this.dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { + const startupPrompt = params.opencodeStartupPromptSource const opencode = params.opencodePluginSource const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource + assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) this.pluginOverlay.setSources({ + opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, @@ -213,8 +216,12 @@ export class RelayAgentHookRuntime { installOpenCodePluginInCanonicalConfig(source, agent, process.env, homedir(), true) } } + const startupPromptInstalled = this.pluginOverlay.installOpenCodeStartupPromptPlugin( + process.env + ) return { installed: { + opencodeStartupPrompt: startupPromptInstalled, opencode: this.pluginOverlay.hasOpenCodeSource(), opencode2: this.pluginOverlay.hasOpenCode2Source(), pi: this.pluginOverlay.hasPiSource('pi'), diff --git a/src/relay/relay-bundled-ripgrep.test.ts b/src/relay/relay-bundled-ripgrep.test.ts index 5f667ad1429..93c4e56490f 100644 --- a/src/relay/relay-bundled-ripgrep.test.ts +++ b/src/relay/relay-bundled-ripgrep.test.ts @@ -104,7 +104,7 @@ describe('relay bundled ripgrep', () => { return child } const child = createProcess(42) - succeedWith(child, 'src/index.ts\n') + succeedWith(child, 'src/index.ts\0') return child }) diff --git a/src/relay/relay-bundled-ripgrep.ts b/src/relay/relay-bundled-ripgrep.ts index 787176e3f42..5c2dd89f3be 100644 --- a/src/relay/relay-bundled-ripgrep.ts +++ b/src/relay/relay-bundled-ripgrep.ts @@ -4,12 +4,13 @@ * the answer whenever that binary is absent or cannot launch on this host. */ import { existsSync, statSync } from 'node:fs' -import { delimiter, join, win32 } from 'node:path' +import { win32 } from 'node:path' import { isRipgrepSpawnCwdUsable, isTransientRipgrepSpawnError } from '../shared/ripgrep-process-availability' import { relayLogLine } from './relay-diagnostic-log' +import { buildRelayCommandEnv } from './relay-command-env' export const PATH_RIPGREP_COMMAND = 'rg' @@ -31,13 +32,13 @@ export function isDriveRootedWindowsPath(dir: string): boolean { // Why only rg.exe, though libuv honours %PATHEXT%: `.bat`/`.cmd` shims are not spawnable without // `shell: true`, and every ripgrep installer (winget, choco, scoop, cargo) lays down rg.exe. -function resolveWindowsPathRipgrep(): string | null { - for (const entry of (process.env.PATH ?? '').split(delimiter)) { +function resolveWindowsPathRipgrep(path: string): string | null { + for (const entry of path.split(';')) { const dir = entry.replace(/^"|"$/g, '').trim() if (!dir || !isDriveRootedWindowsPath(dir)) { continue } - const candidate = join(dir, 'rg.exe') + const candidate = win32.join(dir, 'rg.exe') try { if (statSync(candidate).isFile()) { return candidate @@ -49,7 +50,8 @@ function resolveWindowsPathRipgrep(): string | null { return null } -let windowsPathRipgrep: string | null | undefined +let windowsPathRipgrep: { path: string; command: string | null; expiresAt: number } | undefined +const WINDOWS_PATH_MISS_RETRY_MS = 60_000 let bundledRipgrepPath: string | null = null // Why a back-off, not forever: Windows AV often locks a just-installed rg.exe for its first spawns. @@ -69,13 +71,23 @@ export function pathRipgrepCommand(): string | null { if (process.platform !== 'win32') { return PATH_RIPGREP_COMMAND } - // Why the explicit undefined check and not `??=`: a miss resolves to null, which is nullish, so - // `??=` would re-walk every PATH entry on each call -- and a miss is the expensive case, since - // it stats every directory instead of stopping at the first hit. - if (windowsPathRipgrep === undefined) { - windowsPathRipgrep = resolveWindowsPathRipgrep() + const env = buildRelayCommandEnv() + const path = env.PATH ?? env.Path ?? '' + const now = Date.now() + // Cache the effective PATH; retry misses so an install can become visible without restarting. + if ( + !windowsPathRipgrep || + windowsPathRipgrep.path !== path || + now >= windowsPathRipgrep.expiresAt + ) { + const command = resolveWindowsPathRipgrep(path) + windowsPathRipgrep = { + path, + command, + expiresAt: command === null ? now + WINDOWS_PATH_MISS_RETRY_MS : Infinity + } } - return windowsPathRipgrep + return windowsPathRipgrep.command } /** Null means this host has no usable rg, so the caller falls back to git/readdir. */ diff --git a/src/relay/relay-ripgrep-cwd-env.test.ts b/src/relay/relay-ripgrep-cwd-env.test.ts index 38734ca63e6..c30590b4ad1 100644 --- a/src/relay/relay-ripgrep-cwd-env.test.ts +++ b/src/relay/relay-ripgrep-cwd-env.test.ts @@ -16,7 +16,7 @@ describe.runIf(process.platform !== 'win32')( writeFileSync(join(dir, 'found.txt'), 'needle') const cargo = join(dir, 'cargo') mkdirSync(join(cargo, 'bin'), { recursive: true }) - writeFileSync(join(cargo, 'bin', 'rg'), '#!/bin/sh\necho found.txt\n', { mode: 0o755 }) + writeFileSync(join(cargo, 'bin', 'rg'), "#!/bin/sh\nprintf 'found.txt\\0'\n", { mode: 0o755 }) vi.stubEnv('PATH', join(dir, 'empty-path')) vi.stubEnv('CARGO_HOME', cargo) configureRelayBundledRipgrep(undefined) diff --git a/src/relay/relay-windows-path-ripgrep-cache.test.ts b/src/relay/relay-windows-path-ripgrep-cache.test.ts new file mode 100644 index 00000000000..bea305bd741 --- /dev/null +++ b/src/relay/relay-windows-path-ripgrep-cache.test.ts @@ -0,0 +1,79 @@ +import type * as fs from 'node:fs' +import { statSync } from 'node:fs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { pathRipgrepCommand, resetRelayRipgrepPathCacheForTests } from './relay-bundled-ripgrep' + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, statSync: vi.fn(actual.statSync) } +}) + +const originalPlatform = process.platform +const present = new Set() +const statMock = vi.mocked(statSync) +const fileStats = statSync(new URL(import.meta.url)) + +describe('Windows relay ripgrep effective PATH cache', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.stubEnv('Path', undefined) + vi.stubEnv('PATH', 'C:\\tools') + vi.stubEnv('CARGO_HOME', 'C:\\cargo') + vi.useFakeTimers() + vi.setSystemTime(1000) + present.clear() + resetRelayRipgrepPathCacheForTests() + statMock.mockImplementation((path) => { + if (!present.has(String(path))) { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + } + return fileStats + }) + statMock.mockClear() + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + vi.unstubAllEnvs() + vi.useRealTimers() + statMock.mockReset() + resetRelayRipgrepPathCacheForTests() + }) + + it('finds rg in the Cargo fallback appended to the command environment', () => { + present.add('C:\\cargo\\bin\\rg.exe') + expect(pathRipgrepCommand()).toBe('C:\\cargo\\bin\\rg.exe') + const calls = statMock.mock.calls.length + expect(pathRipgrepCommand()).toBe('C:\\cargo\\bin\\rg.exe') + expect(statMock).toHaveBeenCalledTimes(calls) + }) + + it('supports mixed-case Path and never probes cwd-relative entries', () => { + vi.stubEnv('PATH', undefined) + vi.stubEnv('Path', '.;node_modules\\.bin;\\tools;C:tools;C:\\safe') + present.add('C:\\safe\\rg.exe') + expect(pathRipgrepCommand()).toBe('C:\\safe\\rg.exe') + expect(statMock.mock.calls.map(([path]) => path)).toEqual(['C:\\safe\\rg.exe']) + }) + + it('invalidates both successful and missing resolutions when effective PATH changes', () => { + expect(pathRipgrepCommand()).toBeNull() + vi.stubEnv('CARGO_HOME', 'D:\\cargo') + present.add('D:\\cargo\\bin\\rg.exe') + expect(pathRipgrepCommand()).toBe('D:\\cargo\\bin\\rg.exe') + vi.stubEnv('PATH', 'E:\\tools') + present.add('E:\\tools\\rg.exe') + expect(pathRipgrepCommand()).toBe('E:\\tools\\rg.exe') + }) + + it('retries cached misses after a bounded delay without rescanning on each call', () => { + expect(pathRipgrepCommand()).toBeNull() + const calls = statMock.mock.calls.length + present.add('C:\\tools\\rg.exe') + vi.advanceTimersByTime(59_999) + expect(pathRipgrepCommand()).toBeNull() + expect(statMock).toHaveBeenCalledTimes(calls) + vi.advanceTimersByTime(1) + expect(pathRipgrepCommand()).toBe('C:\\tools\\rg.exe') + }) +}) diff --git a/src/relay/relay-windows-path-ripgrep.test.ts b/src/relay/relay-windows-path-ripgrep.test.ts index a6dee0e14e8..aa8601fdab5 100644 --- a/src/relay/relay-windows-path-ripgrep.test.ts +++ b/src/relay/relay-windows-path-ripgrep.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { configureRelayBundledRipgrep, isDriveRootedWindowsPath, @@ -9,6 +9,9 @@ import { resetRelayRipgrepPathCacheForTests } from './relay-bundled-ripgrep' +// These tests isolate PATH safety; effective-environment coverage lives in the cache suite. +vi.mock('./relay-command-env', () => ({ buildRelayCommandEnv: () => ({ ...process.env }) })) + const originalPlatform = process.platform const originalPath = process.env.PATH @@ -68,7 +71,7 @@ describe('relay PATH ripgrep resolution', () => { // while the spawn -- running with the user's repo as cwd -- resolves them against the repo's. it('skips rooted PATH entries that carry no drive', () => { setPlatform('win32') - process.env.PATH = `\\tools${delimiter}/tools${delimiter}C:tools` + process.env.PATH = '\\tools;/tools;C:tools' resetRelayRipgrepPathCacheForTests() expect(pathRipgrepCommand()).toBeNull() @@ -77,7 +80,7 @@ describe('relay PATH ripgrep resolution', () => { // Why a relative PATH entry is skipped: it resolves against the cwd, the hazard being avoided. it('ignores relative PATH entries on Windows', () => { setPlatform('win32') - process.env.PATH = `.${delimiter}node_modules/.bin` + process.env.PATH = '.;node_modules/.bin' resetRelayRipgrepPathCacheForTests() expect(pathRipgrepCommand()).toBeNull() diff --git a/src/relay/wsl-install-plugins-handler.ts b/src/relay/wsl-install-plugins-handler.ts index b602d353ea8..e66ff7d20a5 100644 --- a/src/relay/wsl-install-plugins-handler.ts +++ b/src/relay/wsl-install-plugins-handler.ts @@ -16,6 +16,7 @@ import { export type InstallPluginsResult = { installed: { + opencodeStartupPrompt?: boolean opencode: boolean opencode2?: boolean pi: boolean @@ -43,18 +44,21 @@ export function createInstallPluginsHandler( let materialized2: { source: string; sourceDir: string | undefined; dir: string } | null = null return (params) => { + const startupPrompt = params.opencodeStartupPromptSource const opencode = params.opencodePluginSource const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource // Why: bound per-source bytes so a buggy/hostile host can't OOM the guest relay. + assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) pluginOverlay.setSources({ + opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, @@ -135,8 +139,28 @@ export function createInstallPluginsHandler( } } } + const promptConfigDirs = [opencodeDir, opencode2Dir].filter( + (dir): dir is string => typeof dir === 'string' + ) + if (promptConfigDirs.length === 0) { + promptConfigDirs.push( + resolveOpenCodeSourceConfigDir( + Object.fromEntries( + Object.entries(env).flatMap(([key, value]) => + typeof value === 'string' ? [[key, value]] : [] + ) + ), + env.SHELL + ) ?? resolveOpenCodeConfigDirectory(env, env.HOME) + ) + } + const promptInstallResults = promptConfigDirs.map((dir) => + pluginOverlay.installOpenCodeStartupPromptPlugin(env, dir) + ) + const startupPromptInstalled = promptInstallResults.every(Boolean) return { installed: { + opencodeStartupPrompt: startupPromptInstalled, opencode: pluginOverlay.hasOpenCodeSource(), opencode2: pluginOverlay.hasOpenCode2Source(), pi: pluginOverlay.hasPiSource('pi'), diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 6c52abb6518..879fcea5984 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -21,6 +21,7 @@ import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime- import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-publication' import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' import { useCodexTerminalServerIsolationNotice } from '../components/terminal-pane/codex-terminal-server-isolation-notice' +import { useCodexSharedSettingsNotice } from '../components/terminal-pane/codex-shared-settings-notice' /** * App-level subscriptions that must outlive any individual surface. Each one is here because @@ -56,4 +57,5 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }): useOsc52ClipboardDefaultOnNotice(persistedUIReady) useBrowserIdentityMigrationNotice() useCodexTerminalServerIsolationNotice() + useCodexSharedSettingsNotice() } diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx index 2708e63a137..1c1e802e650 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.range-selection.test.tsx @@ -3,6 +3,16 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Selection } from 'monaco-editor' import type * as DiffCommentZoneCardModule from './diff-comment-zone-card' +import type { NotesSendMenu } from '../editor/NotesSendMenu' + +const notesMenuFixture = vi.hoisted(() => ({ + NotesSendMenu: vi.fn(() => { + throw new Error('Range selection must not render the agent notes menu') + }) +})) + +// Saved-note delivery is outside the selection and inline-draft paths exercised here. +vi.mock('../editor/NotesSendMenu', () => ({ NotesSendMenu: notesMenuFixture.NotesSendMenu })) const storeFixture = vi.hoisted(() => ({ activeGroupIdByWorktree: {}, @@ -146,7 +156,11 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals() document.body.replaceChildren() - vi.clearAllMocks() + try { + expect(notesMenuFixture.NotesSendMenu).not.toHaveBeenCalled() + } finally { + vi.clearAllMocks() + } }) describe('useDiffCommentDecorator range highlight', () => { diff --git a/src/renderer/src/components/editor/CsvViewer.delimiter.test.tsx b/src/renderer/src/components/editor/CsvViewer.delimiter.test.tsx new file mode 100644 index 00000000000..73f4091df0a --- /dev/null +++ b/src/renderer/src/components/editor/CsvViewer.delimiter.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import CsvViewer from './CsvViewer' + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => + Array.from({ length: Math.min(count, 3) }, (_, index) => ({ + index, + key: index, + start: index * 28 + })), + getTotalSize: () => count * 28 + }) +})) + +afterEach(cleanup) + +async function chooseDelimiter(label: string): Promise { + fireEvent.keyDown(screen.getByRole('combobox', { name: 'Delimiter' }), { key: 'ArrowDown' }) + fireEvent.click(await screen.findByRole('option', { name: label })) +} + +describe('CSV delimiter selection', () => { + it('lets the reader resolve ambiguous headerless decimal data', async () => { + render() + expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Auto (Comma)') + expect(screen.getByRole('columnheader', { name: '50;coffee' })).toBeTruthy() + + await chooseDelimiter('Semicolon (;)') + + expect(screen.getByRole('columnheader', { name: '1,50' })).toBeTruthy() + expect(screen.getByRole('cell', { name: '2,75' })).toBeTruthy() + expect(screen.getByRole('cell', { name: 'tea' })).toBeTruthy() + }) + + it('applies the selected separator to quoted multiline records', async () => { + render( + + ) + await chooseDelimiter('Semicolon (;)') + + expect(screen.getAllByRole('columnheader').map((cell) => cell.textContent)).toEqual([ + '#', + 'multi\nline', + 'value' + ]) + expect(screen.getAllByRole('cell').map((cell) => cell.textContent)).toEqual([ + 'first\nsecond', + 'a;b' + ]) + }) + + it('allows an explicit comma separator even when the extension is tsv', async () => { + render() + expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Auto (Tab)') + + await chooseDelimiter('Comma (,)') + + expect(screen.getByRole('columnheader', { name: 'amount' })).toBeTruthy() + expect(screen.getByRole('cell', { name: '1,50' })).toBeTruthy() + }) + + it('keeps an explicit choice on refresh and can return to the current auto choice', async () => { + const { rerender } = render() + await chooseDelimiter('Semicolon (;)') + + rerender() + + expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Semicolon (;)') + expect(screen.getAllByRole('columnheader')).toHaveLength(2) + await chooseDelimiter('Auto (Tab)') + expect(screen.getByRole('columnheader', { name: 'value' })).toBeTruthy() + expect(screen.getByRole('cell', { name: '2' })).toBeTruthy() + }) + + it('resets to Auto when the editor mounts a different file identity', async () => { + const { rerender } = render( + + ) + await chooseDelimiter('Comma (,)') + + rerender() + + expect(screen.getByRole('combobox', { name: 'Delimiter' }).textContent).toBe('Auto (Tab)') + expect(screen.getByRole('columnheader', { name: 'value' })).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/editor/CsvViewer.tsx b/src/renderer/src/components/editor/CsvViewer.tsx index e424a98c938..49d3a2ba51e 100644 --- a/src/renderer/src/components/editor/CsvViewer.tsx +++ b/src/renderer/src/components/editor/CsvViewer.tsx @@ -1,6 +1,7 @@ -import React, { useMemo, useRef } from 'react' +import React, { useMemo, useRef, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { detectCsvDelimiter, parseCsv } from './csv-parse' +import { CsvDelimiterPicker, type CsvDelimiterChoice } from './csv-delimiter-picker' import { translate } from '@/i18n/i18n' type CsvViewerProps = { @@ -23,11 +24,21 @@ const CHAR_PX = 7 // independently of the body, leaving values squashed together. export default function CsvViewer({ content, filePath }: CsvViewerProps): React.JSX.Element { const scrollRef = useRef(null) + const [delimiterChoice, setDelimiterChoice] = useState('auto') + const detectedDelimiter = useMemo( + () => detectCsvDelimiter(filePath, content), + [filePath, content] + ) + const delimiter = + delimiterChoice === 'auto' + ? detectedDelimiter + : delimiterChoice === 'comma' + ? ',' + : delimiterChoice === 'semicolon' + ? ';' + : '\t' - const parsed = useMemo(() => { - const delimiter = detectCsvDelimiter(filePath, content) - return parseCsv(content, delimiter) - }, [content, filePath]) + const parsed = useMemo(() => parseCsv(content, delimiter), [content, delimiter]) // Why: memoize header/body split so their references stay stable across // renders that don't change content. A top-level rest-destructure would @@ -178,7 +189,7 @@ export default function CsvViewer({ content, filePath }: CsvViewerProps): React. -
+
{bodyRows.length.toLocaleString()}{' '} {translate('auto.components.editor.CsvViewer.ac31d2cd60', 'rows')} @@ -186,6 +197,11 @@ export default function CsvViewer({ content, filePath }: CsvViewerProps): React. {columnCount} {translate('auto.components.editor.CsvViewer.eedd0d37a7', 'columns')} +
) diff --git a/src/renderer/src/components/editor/IpynbCellEditor.external-sync.test.tsx b/src/renderer/src/components/editor/IpynbCellEditor.external-sync.test.tsx new file mode 100644 index 00000000000..d53b24baf9b --- /dev/null +++ b/src/renderer/src/components/editor/IpynbCellEditor.external-sync.test.tsx @@ -0,0 +1,308 @@ +// @vitest-environment happy-dom +import { useMemo, useState } from 'react' +import type { editor } from 'monaco-editor' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { IpynbCellSource } from './IpynbCellEditor' +import { parseIpynb } from './ipynb-parse' +import { + getIpynbCellKey, + hasIpynbSourceDraft, + useIpynbDocumentEditing +} from './useIpynbDocumentEditing' + +const liveModels = vi.hoisted(() => { + const models: editor.ITextModel[] = [] + return models +}) +const widgetCalls = vi.hoisted(() => ({ + focus: vi.fn(), + layout: vi.fn(), + updateOptions: vi.fn<(options: editor.IEditorOptions) => void>() +})) + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string) => fallback +})) +vi.mock('@/store', () => ({ + useAppStore: ( + selector: (state: { settings: undefined; editorFontZoomLevel: number }) => unknown + ) => selector({ settings: undefined, editorFontZoomLevel: 0 }) +})) +vi.mock('@/hooks/use-document-dark-theme', () => ({ useDocumentDarkTheme: () => true })) +vi.mock('./MonacoCodeExcerpt', () => ({ useMonacoColorizedLines: () => [] })) +vi.mock('./editor-shortcuts', () => ({ installMonacoEditorFindShortcut: () => () => {} })) +vi.mock('@/lib/monaco-setup', async () => { + const actual = await import('monaco-editor/esm/vs/editor/editor.api.js') + for (const id of ['python', 'javascript', 'markdown']) { + actual.languages.register({ id }) + } + return { + monaco: { + ...actual, + editor: { + ...actual.editor, + setTheme: vi.fn(), + createModel: (...args: Parameters) => { + const model = actual.editor.createModel(...args) + liveModels.push(model) + return model + }, + create: (_container: HTMLElement, { model }: { model: editor.ITextModel }) => ({ + getModel: () => model, + getContentHeight: () => 28, + layout: widgetCalls.layout, + onDidContentSizeChange: () => ({ dispose: vi.fn() }), + restoreViewState: vi.fn(), + saveViewState: () => null, + getTargetAtClientPoint: () => null, + setPosition: vi.fn(), + focus: widgetCalls.focus, + onDidBlurEditorWidget: () => ({ dispose: vi.fn() }), + addCommand: vi.fn(), + updateOptions: widgetCalls.updateOptions, + pushUndoStop: () => { + model.pushStackElement() + return true + }, + dispose: vi.fn() + }) + } + } + } +}) + +function notebookContent( + cells: { id: string; source: string; kind?: 'code' | 'markdown' }[], + language = 'python' +): string { + return JSON.stringify({ + nbformat: 4, + nbformat_minor: 5, + metadata: { language_info: { name: language } }, + cells: cells.map(({ id, source, kind }) => ({ + id, + cell_type: kind ?? 'code', + metadata: {}, + execution_count: null, + outputs: [], + source: [source] + })) + }) +} + +function NotebookCells({ + content, + onContentChange, + onDirtyStateHint +}: { + content: string + onContentChange: (content: string) => void + onDirtyStateHint: (dirty: boolean) => void +}): React.JSX.Element { + const notebook = useMemo(() => parseIpynb(content), [content]) + const [editingCellKey, setEditingCellKey] = useState(null) + const editing = useIpynbDocumentEditing({ + content, + fileId: 'external-notebook', + notebook, + onContentChange, + onDirtyStateHint, + onDeactivateEditor: () => setEditingCellKey(null) + }) + return ( +
+ {notebook.cells.map((cell, index) => { + const key = getIpynbCellKey(cell, index) + return ( + setEditingCellKey(key)} + onDeactivate={() => setEditingCellKey(null)} + onChange={(source) => editing.updateCellSource(index, source)} + /> + ) + })} +
+ ) +} + +function activeModel(): editor.ITextModel { + const model = liveModels.at(-1) + if (!model) { + throw new Error('No notebook cell editor was created') + } + return model +} + +function appendText(model: editor.ITextModel, text: string): void { + const end = model.getFullModelRange() + act(() => { + model.pushEditOperations( + [], + [{ range: { ...end, startLineNumber: end.endLineNumber, startColumn: end.endColumn }, text }], + () => null + ) + vi.advanceTimersByTime(400) + }) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() +}) +afterEach(() => { + cleanup() + for (const model of liveModels.splice(0)) { + if (!model.isDisposed()) { + model.dispose() + } + } + vi.useRealTimers() +}) + +describe('active notebook cell external reload', () => { + it('shows the new source without dirtying the clean notebook or recreating the model', () => { + const onContentChange = vi.fn<(content: string) => void>() + const onDirtyStateHint = vi.fn<(dirty: boolean) => void>() + const initialContent = notebookContent([{ id: 'a', source: 'original source' }]) + const { rerender } = render( + + ) + fireEvent.mouseDown(screen.getByRole('button', { name: 'original source' }), { button: 0 }) + const model = activeModel() + expect(model.getValue()).toBe('original source') + expect(onDirtyStateHint).not.toHaveBeenCalled() + + rerender( + + ) + + expect(liveModels).toEqual([model]) + expect(model.getValue()).toBe('updated from disk') + expect(onContentChange).not.toHaveBeenCalled() + expect(onDirtyStateHint).not.toHaveBeenCalled() + expect(widgetCalls.focus).toHaveBeenCalledOnce() + appendText(model, '!') + const committedContent = onContentChange.mock.calls.at(-1)?.[0] + expect(committedContent).toBeDefined() + expect(parseIpynb(committedContent ?? '').cells[0]?.source).toBe('updated from disk!') + }) + + it('writes edits to the same cell after a clean external reload reorders it', () => { + const onContentChange = vi.fn<(content: string) => void>() + const onDirtyStateHint = vi.fn<(dirty: boolean) => void>() + const first = { id: 'a', source: 'active source' } + const second = { id: 'b', source: 'other source' } + const { rerender } = render( + + ) + fireEvent.mouseDown(screen.getByRole('button', { name: first.source }), { button: 0 }) + const model = activeModel() + expect(onDirtyStateHint).not.toHaveBeenCalled() + + rerender( + + ) + expect(liveModels).toEqual([model]) + expect(onDirtyStateHint).not.toHaveBeenCalled() + appendText(model, '!') + + const committedContent = onContentChange.mock.calls.at(-1)?.[0] + expect(committedContent).toBeDefined() + const cells = parseIpynb(committedContent ?? '').cells + expect(cells.map(({ id, source }) => ({ id, source }))).toEqual([ + second, + { ...first, source: 'active source!' } + ]) + }) + + it('retains undo for external source changes and treats undo as a user edit', async () => { + const onContentChange = vi.fn<(content: string) => void>() + const onDirtyStateHint = vi.fn<(dirty: boolean) => void>() + const { rerender } = render( + + ) + fireEvent.mouseDown(screen.getByRole('button', { name: 'original source' }), { button: 0 }) + const model = activeModel() + rerender( + + ) + + expect(model.canUndo()).toBe(true) + expect(onDirtyStateHint).not.toHaveBeenCalled() + await act(async () => { + await model.undo() + vi.advanceTimersByTime(400) + }) + expect(model.getValue()).toBe('original source') + expect(onDirtyStateHint).toHaveBeenCalledWith(true) + const committedContent = onContentChange.mock.calls.at(-1)?.[0] + expect(parseIpynb(committedContent ?? '').cells[0]?.source).toBe('original source') + }) + + it('updates language, wrapping and height when a clean reload changes the active cell', () => { + const onContentChange = vi.fn<(content: string) => void>() + const onDirtyStateHint = vi.fn<(dirty: boolean) => void>() + const { rerender } = render( + + ) + fireEvent.mouseDown(screen.getByRole('button', { name: 'original source' }), { button: 0 }) + const model = activeModel() + rerender( + + ) + expect(model.getLanguageId()).toBe('javascript') + expect(widgetCalls.updateOptions).toHaveBeenLastCalledWith( + expect.objectContaining({ wordWrap: 'off' }) + ) + widgetCalls.layout.mockClear() + + rerender( + + ) + expect(liveModels).toEqual([model]) + expect(model.getLanguageId()).toBe('markdown') + expect(widgetCalls.updateOptions).toHaveBeenLastCalledWith( + expect.objectContaining({ wordWrap: 'on' }) + ) + expect(widgetCalls.layout).toHaveBeenCalled() + expect(model.getValue()).toBe('**Updated**\n\nParagraph') + expect(onDirtyStateHint).not.toHaveBeenCalled() + expect(onContentChange).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/IpynbCellEditor.tsx b/src/renderer/src/components/editor/IpynbCellEditor.tsx index 228bf57f6e3..e9b29768e44 100644 --- a/src/renderer/src/components/editor/IpynbCellEditor.tsx +++ b/src/renderer/src/components/editor/IpynbCellEditor.tsx @@ -5,6 +5,7 @@ import { monaco } from '@/lib/monaco-setup' import { computeEditorFontSize, resolveEditorFontStack } from '@/lib/editor-font-zoom' import { useAppStore } from '@/store' import { installMonacoEditorFindShortcut } from './editor-shortcuts' +import { syncContentUpdate } from './monaco-content-sync' import { IPYNB_CODE_CELL_PREVIEW_MAX_LINES, getIpynbCodeCellPreviewLines @@ -155,18 +156,21 @@ function IpynbSourceEditor({ const fontSize = computeEditorFontSize(settings?.terminalFontSize ?? 13, editorFontZoomLevel) const containerRef = useRef(null) const editorRef = useRef(null) + const callbacksRef = useRef({ onChange, onDeactivate }) + const syncingSourceRef = useRef(false) + const fitHeightRef = useRef<(() => void) | null>(null) + + useLayoutEffect(() => { + callbacksRef.current = { onChange, onDeactivate } + }, [onChange, onDeactivate]) useLayoutEffect(() => { monaco.editor.setTheme(isDark ? 'vs-dark' : 'vs') }, [isDark]) - useLayoutEffect(() => { - editorRef.current?.updateOptions({ fontFamily, fontSize }) - }, [fontFamily, fontSize]) - // Why: created synchronously before paint (not via @monaco-editor/react's async loader), so the // swap from the preview never shows a placeholder, an unlaid-out editor or a guessed caret. - // Mount-once: the props it reads cannot change while the cell is being edited. + // Keep the model and undo history while external reloads update the cell below. useLayoutEffect(() => { const container = containerRef.current if (!container) { @@ -206,6 +210,7 @@ function IpynbSourceEditor({ container.style.height = `${Math.min(editorInstance.getContentHeight(), maxHeight)}px` editorInstance.layout() } + fitHeightRef.current = fitHeight fitHeight() editorInstance.onDidContentSizeChange(fitHeight) // Why: restoring a view state marks the visible lines stable, so Monaco tokenizes them now @@ -217,12 +222,16 @@ function IpynbSourceEditor({ editorInstance.setPosition(target.position) } editorInstance.focus() - model.onDidChangeContent(() => onChange(model.getValue())) - editorInstance.onDidBlurEditorWidget(onDeactivate) + model.onDidChangeContent(() => { + if (!syncingSourceRef.current) { + callbacksRef.current.onChange(model.getValue()) + } + }) + editorInstance.onDidBlurEditorWidget(() => callbacksRef.current.onDeactivate()) // Escape closes an open widget first; only a bare Escape leaves the cell. editorInstance.addCommand( monaco.KeyCode.Escape, - onDeactivate, + () => callbacksRef.current.onDeactivate(), '!suggestWidgetVisible && !findWidgetVisible && !parameterHintsVisible' ) const cleanupFindShortcut = installMonacoEditorFindShortcut(editorInstance) @@ -231,9 +240,33 @@ function IpynbSourceEditor({ editorInstance.dispose() model.dispose() editorRef.current = null + fitHeightRef.current = null } // oxlint-disable-next-line react-hooks/exhaustive-deps -- mount-once; see the Why above. }, []) + useLayoutEffect(() => { + const editorInstance = editorRef.current + const model = editorInstance?.getModel() + if (!editorInstance || !model) { + return + } + if (model.getLanguageId() !== cell.language) { + monaco.editor.setModelLanguage(model, cell.language) + } + editorInstance.updateOptions({ + fontFamily, + fontSize, + wordWrap: cell.kind === 'code' ? 'off' : 'on' + }) + syncingSourceRef.current = true + try { + syncContentUpdate(editorInstance, source) + } finally { + syncingSourceRef.current = false + } + fitHeightRef.current?.() + }, [source, cell.language, cell.kind, fontFamily, fontSize]) + return
} diff --git a/src/renderer/src/components/editor/csv-delimiter-picker.tsx b/src/renderer/src/components/editor/csv-delimiter-picker.tsx new file mode 100644 index 00000000000..d345283fd39 --- /dev/null +++ b/src/renderer/src/components/editor/csv-delimiter-picker.tsx @@ -0,0 +1,68 @@ +import { useId } from 'react' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { translate } from '@/i18n/i18n' + +export type CsvDelimiterChoice = 'auto' | 'comma' | 'semicolon' | 'tab' + +export function CsvDelimiterPicker({ + value, + detectedDelimiter, + onChange +}: { + value: CsvDelimiterChoice + detectedDelimiter: string + onChange: (choice: CsvDelimiterChoice) => void +}): React.JSX.Element { + const id = useId() + const comma = translate('auto.components.editor.CsvViewer.delimiterComma', 'Comma') + const semicolon = translate('auto.components.editor.CsvViewer.delimiterSemicolon', 'Semicolon') + const tab = translate('auto.components.editor.CsvViewer.delimiterTab', 'Tab') + const detectedName = + detectedDelimiter === ',' ? comma : detectedDelimiter === ';' ? semicolon : tab + const choices = [ + { + value: 'auto', + label: translate('auto.components.editor.CsvViewer.delimiterAuto', 'Auto ({{delimiter}})', { + delimiter: detectedName + }) + }, + { value: 'comma', label: `${comma} (,)` }, + { value: 'semicolon', label: `${semicolon} (;)` }, + { value: 'tab', label: tab } + ] as const + + return ( +
+ + +
+ ) +} diff --git a/src/renderer/src/components/editor/csv-parse.test.ts b/src/renderer/src/components/editor/csv-parse.test.ts index 267be8b1028..f2e935e4937 100644 --- a/src/renderer/src/components/editor/csv-parse.test.ts +++ b/src/renderer/src/components/editor/csv-parse.test.ts @@ -80,6 +80,130 @@ describe('detectCsvDelimiter', () => { expect(detectCsvDelimiter('data.csv', 'a,b,c\n1,2,3')).toBe(',') }) + it('sniffs semicolon exports that use commas as the decimal mark', () => { + const content = 'amount;label\n1,50;"Roe, John"\n2,75;lunch\n' + + expect(detectCsvDelimiter('expenses.csv', content)).toBe(';') + expect(parseCsv(content, detectCsvDelimiter('expenses.csv', content)).rows).toEqual([ + ['amount', 'label'], + ['1,50', 'Roe, John'], + ['2,75', 'lunch'] + ]) + }) + + it('keeps comma when semicolons only appear inside quoted fields', () => { + expect(detectCsvDelimiter('x.csv', '"a"";b;c",d,e\n1,2,3\n')).toBe(',') + }) + + it('prefers tab over semicolon when tabs dominate the first line', () => { + expect(detectCsvDelimiter('x.csv', 'a\tb;c\td\n')).toBe('\t') + }) + + it('uses tab for .tsv files that contain semicolons', () => { + expect(detectCsvDelimiter('data.TSV', 'a;b;c')).toBe('\t') + }) + + it.each([ + ['a;b,c', ','], + ['a;b,c\td', ','], + ['a;b\tc', '\t'], + ['a,b\tc', ','], + ['', ','], + ['single', ','] + ])('preserves existing delimiter precedence for %j', (content, delimiter) => { + expect(detectCsvDelimiter('x.csv', content)).toBe(delimiter) + }) + + it('sniffs semicolons after a BOM and leading whitespace-only lines', () => { + expect(detectCsvDelimiter('x.csv', '\uFEFF\n \t \r\na;b\n1;2')).toBe(';') + }) + + it('parses semicolon fields with quoted separators, escaped quotes and newlines', () => { + const content = '\uFEFFnote;amount\r\n"she said ""hi"";\nnext";1,50\r\n' + + expect(parseCsv(content, detectCsvDelimiter('x.csv', content))).toEqual({ + rows: [ + ['note', 'amount'], + ['she said "hi";\nnext', '1,50'] + ], + maxColumns: 2 + }) + }) + + it('keeps consistent comma columns when only the header has extra semicolons', () => { + const content = 'notes;one;two,value\nplain,1\nother,2' + + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + expect(parseCsv(content, detectCsvDelimiter('x.csv', content)).rows).toEqual([ + ['notes;one;two', 'value'], + ['plain', '1'], + ['other', '2'] + ]) + }) + + it.each([',', '\t'])('keeps literal quotes inside unquoted %j fields', (delimiter) => { + const content = `notes;one;two${delimiter}value\n6" bolts${delimiter}1\nplain${delimiter}2` + + expect(detectCsvDelimiter('x.csv', content)).toBe(delimiter) + expect(parseCsv(content, detectCsvDelimiter('x.csv', content)).rows).toEqual([ + ['notes;one;two', 'value'], + ['6" bolts', '1'], + ['plain', '2'] + ]) + }) + + it('keeps consistent tab columns when a header contains extra semicolons', () => { + expect(detectCsvDelimiter('x.csv', 'notes;one;two\tvalue\nplain\t1')).toBe('\t') + }) + + it('keeps a semicolon export whose unquoted comma counts vary between rows', () => { + expect(detectCsvDelimiter('x.csv', 'name;amount;note\nAda;1,50;lunch\nBo;2;plain')).toBe(';') + }) + + it('ignores separator-like punctuation in a multiline quoted field', () => { + const content = 'notes;one;two,value\n"line one\nline;two",1' + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + }) + + it('keeps first-line inference when the rows are ambiguous or ragged', () => { + expect(detectCsvDelimiter('x.csv', 'a;b;c,value\nx;y;z,1')).toBe(';') + expect(detectCsvDelimiter('x.csv', 'a;b;c\nx;y\nz')).toBe(';') + expect(detectCsvDelimiter('x.csv', '1,50;coffee\n2,75;lunch')).toBe(',') + }) + + it('uses at most eight logical records to corroborate the existing delimiter', () => { + const firstEight = ['notes;one;two,value', ...Array.from({ length: 7 }, () => 'plain,1')] + const content = [...firstEight, 'ragged,one,two,three'].join('\n') + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + }) + + it('does not use an unfinished quoted record at the scan boundary as corroboration', () => { + const prefix = 'notes;one;two,value\nplain,"' + const content = `${prefix}${'x'.repeat(CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS)}",1` + expect(detectCsvDelimiter('x.csv', content)).toBe(';') + }) + + it('does not use a partial unquoted record at the scan boundary as corroboration', () => { + const prefix = 'notes;one;two,value\nplain,' + const content = `${prefix}${'x'.repeat(CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS)}\nother,2` + expect(detectCsvDelimiter('x.csv', content)).toBe(';') + }) + + it('does not corroborate an unfinished quoted record at EOF', () => { + expect(detectCsvDelimiter('x.csv', 'notes;one;two,value\nplain,"unfinished')).toBe(';') + }) + + it('corroborates complete records with CRLF, escaped quotes and a quoted newline', () => { + const content = 'notes;one;two,value\r\n"say ""hi"";\r\nnext",1\r\nplain,2\r\n' + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + }) + + it('does not sniff semicolons beyond the first-line scan limit', () => { + const content = `${'a'.repeat(CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS)};b;c` + + expect(detectCsvDelimiter('x.csv', content)).toBe(',') + }) + it('skips leading blank lines when sniffing', () => { expect(detectCsvDelimiter('x.csv', '\n\na\tb\tc')).toBe('\t') }) diff --git a/src/renderer/src/components/editor/csv-parse.ts b/src/renderer/src/components/editor/csv-parse.ts index 009e9646320..6f52edbf5b7 100644 --- a/src/renderer/src/components/editor/csv-parse.ts +++ b/src/renderer/src/components/editor/csv-parse.ts @@ -107,9 +107,7 @@ export function detectCsvDelimiter(filePath: string, content: string): string { if (filePath.toLowerCase().endsWith('.tsv')) { return '\t' } - // Why: sniff the first non-empty line for tab vs comma to handle CSVs that - // were saved with a different extension. Semicolons/pipes are out of scope; - // this tool is a viewer, not a general data importer. + // Include semicolon spreadsheet exports without adding general importer heuristics. // Why: strip a leading UTF-8 BOM so it doesn't get counted as part of the // first cell's characters (and so BOM-prefixed TSVs still sniff correctly). let text = content @@ -121,8 +119,84 @@ export function detectCsvDelimiter(filePath: string, content: string): string { // (0 tabs vs 0 commas, tie goes to comma), misdetecting blank-leading TSVs. const firstLine = findFirstNonEmptyCsvSniffLine(text) const tabs = countDelimiterOutsideQuotes(firstLine, '\t') + const semicolons = countDelimiterOutsideQuotes(firstLine, ';') const commas = countDelimiterOutsideQuotes(firstLine, ',') - return tabs > commas ? '\t' : ',' + // Keep the existing comma/tab choice unless semicolon strictly wins. + const existingDelimiter = tabs > commas ? '\t' : ',' + if (semicolons > commas && semicolons > tabs) { + return hasConsistentExistingCsvColumns(text, existingDelimiter) ? existingDelimiter : ';' + } + return existingDelimiter +} + +// Header punctuation should not replace an otherwise consistent comma/tab table. +function hasConsistentExistingCsvColumns(text: string, delimiter: string): boolean { + const scanLength = Math.min(text.length, CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS) + const records: { delimiters: number; semicolons: number }[] = [] + let delimiters = 0 + let semicolons = 0 + let inQuotes = false + let fieldIsEmpty = true + let hasContent = false + const pushRecord = (): void => { + if (hasContent) { + records.push({ delimiters, semicolons }) + } + delimiters = 0 + semicolons = 0 + fieldIsEmpty = true + hasContent = false + } + + for (let index = 0; index < scanLength && records.length < 8; index += 1) { + const ch = text[index] + if (inQuotes) { + if (ch === '"') { + if (text[index + 1] === '"' && index + 1 < scanLength) { + fieldIsEmpty = false + index += 1 + } else { + inQuotes = false + } + } else { + fieldIsEmpty = false + } + continue + } + if (ch === '"' && fieldIsEmpty) { + inQuotes = true + hasContent = true + continue + } + if (ch === '\r' || ch === '\n') { + pushRecord() + if (ch === '\r' && text[index + 1] === '\n') { + index += 1 + } + continue + } + if (ch === delimiter) { + delimiters += 1 + fieldIsEmpty = true + } else { + fieldIsEmpty = false + } + if (ch === ';') { + semicolons += 1 + } + hasContent ||= !isCsvSniffWhitespace(text.charCodeAt(index)) + } + if (scanLength === text.length && records.length < 8 && !inQuotes) { + pushRecord() + } + const first = records[0] + return Boolean( + first && + first.delimiters > 0 && + records.length > 1 && + records.every((record) => record.delimiters === first.delimiters) && + records.some((record) => record.semicolons !== first.semicolons) + ) } function findFirstNonEmptyCsvSniffLine(text: string): string { diff --git a/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts b/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts index 68e33bc6baf..18bc3b6ce2c 100644 --- a/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts +++ b/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts @@ -22,6 +22,36 @@ function file(overrides: Partial & Pick): } describe('editor external watch path batch index', () => { + it('routes POSIX literal-backslash updates only to the matching tab', () => { + const scope = { worktreeId: 'wt-posix', worktreePath: '/repo', runtimeEnvironmentId: null } + const literal = file({ + id: 'literal', + worktreeId: scope.worktreeId, + filePath: '/repo/a\\b.txt', + relativePath: 'a\\b.txt' + }) + const nested = file({ + id: 'nested', + worktreeId: scope.worktreeId, + filePath: '/repo/a/b.txt', + relativePath: 'a/b.txt' + }) + const index = indexEditorExternalWatchBatchPaths( + { + worktreePath: scope.worktreePath, + events: [ + { kind: 'update', absolutePath: literal.filePath }, + { kind: 'update', absolutePath: nested.filePath } + ] + }, + [literal, nested], + scope + ) + expect(index.changes.map((change) => change.relativePath)).toEqual(['a\\b.txt', 'a/b.txt']) + expect(index.matchingOpenFiles(index.changes[0])).toEqual([literal]) + expect(index.matchingOpenFiles(index.changes[1])).toEqual([nested]) + }) + it('matches UNC aliases for updates, deletes, and restored tombstones', () => { const restored = file({ id: 'restored', diff --git a/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts b/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts new file mode 100644 index 00000000000..a416d6719f0 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-comment-blocks.test.ts @@ -0,0 +1,216 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Editor } from '@tiptap/core' +import type { DiffComment } from '../../../../shared/diff-comment-types' +import { createRichMarkdownExtensions } from './rich-markdown-extensions' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import { + buildRichMarkdownCommentBlocks, + getRichMarkdownCommentBlocks +} from './rich-markdown-comment-blocks' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { + createRichMarkdownHtmlSuperscriptLinkContext, + type RichMarkdownHtmlSuperscriptLinkContext +} from './rich-markdown-html-superscript-link-context' +import { + getRichMarkdownAnnotationHighlightRanges, + getRichMarkdownAnnotationHighlightRangesForComment, + getRichMarkdownAnnotationTarget, + getRichMarkdownCommentAtPos +} from './rich-markdown-review-annotations' +import { getRichMarkdownReviewRailBlocks } from './rich-markdown-review-rail-blocks' + +const editors: Editor[] = [] +const SOURCE = 'First paragraph\n\n```ts\none\n\ntwo\n```\n\nLast paragraph' + +function createEditor(source = SOURCE, context?: RichMarkdownHtmlSuperscriptLinkContext) { + const root = document.createElement('div') + document.body.append(root) + const codec = createRichMarkdownEditorCodec() + const editor = new Editor({ + element: root, + extensions: createRichMarkdownExtensions({ + codec, + htmlSuperscriptLinks: Boolean(context), + htmlSuperscriptLinkContext: context + }), + content: encodeRawMarkdownHtmlForRichEditor(source, codec, { + htmlSuperscriptLinks: Boolean(context) + }), + contentType: 'markdown' + }) + editors.push(editor) + editor.view.dispatch(editor.state.tr.setMeta('addToHistory', false)) + vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 600, 400)) + return { editor, root } +} + +function selectLastParagraph(editor: Editor, characters = 4) { + let from: number | undefined + editor.state.doc.forEach((node, offset) => { + if (node.textContent === 'Last paragraph') { + from = offset + 1 + } + }) + if (from === undefined) { + throw new Error('Last paragraph missing') + } + editor.commands.setTextSelection({ from, to: from + characters }) + const paragraph = Array.from(editor.view.dom.querySelectorAll('p')).find( + (element) => element.textContent === 'Last paragraph' + ) + const text = paragraph?.firstChild + if (!(text instanceof Text)) { + throw new Error('Last paragraph text missing') + } + const range = document.createRange() + range.setStart(text, 0) + range.setEnd(text, characters) + window.getSelection()?.removeAllRanges() + window.getSelection()?.addRange(range) + return from +} + +function comment(lineNumber = 29): DiffComment { + return { + id: 'note', + worktreeId: 'workspace', + filePath: 'notes.md', + source: 'markdown', + lineNumber, + selectedText: 'Last', + body: 'Review', + createdAt: 1, + side: 'modified' + } +} + +afterEach(() => { + for (const editor of editors.splice(0)) { + editor.destroy() + } + window.getSelection()?.removeAllRanges() + document.body.replaceChildren() + vi.restoreAllMocks() +}) + +describe('Markdown review source block reuse', () => { + it('preserves source lines when the document interaction host changes', () => { + const context = createRichMarkdownHtmlSuperscriptLinkContext({ + sourceFilePath: '/repo/notes.md', + worktreeId: 'workspace', + worktreeRoot: '/repo', + sourceOwner: { kind: 'local' } + }) + const { editor } = createEditor( + 'First [1]\n\nLast paragraph', + context + ) + const blocks = getRichMarkdownCommentBlocks(editor) + const doc = editor.state.doc + const serialize = vi.spyOn(editor.markdown!, 'serialize') + const instrumentedBlocks = getRichMarkdownCommentBlocks(editor) + serialize.mockClear() + context.update({ + sourceFilePath: '/remote/notes.md', + worktreeId: 'remote-workspace', + worktreeRoot: '/remote', + sourceOwner: { kind: 'ssh', connectionId: 'connection' } + }) + expect(editor.state.doc).toBe(doc) + expect(getRichMarkdownCommentBlocks(editor)).toBe(instrumentedBlocks) + expect(serialize).not.toHaveBeenCalled() + serialize.mockRestore() + expect(buildRichMarkdownCommentBlocks(editor)).toEqual(blocks) + }) + + it('shares one source map across highlights, comment clicks, selection targets and the rail', () => { + const { editor, root } = createEditor() + const from = selectLastParagraph(editor) + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 20, 80, 20)) + const serialize = vi.spyOn(editor.markdown!, 'serialize') + const json = vi.spyOn(editor, 'getJSON') + const note = comment() + const expected = [{ from, to: from + 4 }] + + expect(getRichMarkdownAnnotationHighlightRanges(editor, [note], 20)).toEqual(expected) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + expect(json).toHaveBeenCalledTimes(1) + serialize.mockClear() + json.mockClear() + + for (let index = 0; index < 20; index++) { + expect(getRichMarkdownAnnotationHighlightRanges(editor, [note], 20)).toEqual(expected) + expect(getRichMarkdownAnnotationHighlightRangesForComment(editor, note, 20)).toEqual(expected) + expect(getRichMarkdownCommentAtPos(editor, [note], 20, from + 2)).toBe(note) + expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({ + from, + to: from + 4, + selectedText: 'Last', + lineNumber: 9 + }) + expect(getRichMarkdownReviewRailBlocks(editor)).toBe(getRichMarkdownCommentBlocks(editor)) + } + expect(serialize).not.toHaveBeenCalled() + expect(json).not.toHaveBeenCalled() + }) + + it('updates selected text and viewport geometry without rebuilding source lines', () => { + const { editor, root } = createEditor() + const rect = vi.spyOn(Range.prototype, 'getBoundingClientRect') + rect.mockReturnValue(new DOMRect(10, 20, 80, 20)) + selectLastParagraph(editor) + const serialize = vi.spyOn(editor.markdown!, 'serialize') + expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({ + selectedText: 'Last', + buttonTop: 48, + lineNumber: 9 + }) + serialize.mockClear() + const doc = editor.state.doc + selectLastParagraph(editor, 9) + rect.mockReturnValue(new DOMRect(10, 120, 140, 20)) + expect(getRichMarkdownAnnotationTarget(editor, root)).toMatchObject({ + selectedText: 'Last para', + buttonTop: 148, + lineNumber: 9 + }) + expect(editor.state.doc).toBe(doc) + expect(serialize).not.toHaveBeenCalled() + }) + + it('rebuilds source lines after editing a multiline block and undoing it', () => { + const { editor, root } = createEditor() + vi.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(10, 20, 80, 20)) + selectLastParagraph(editor) + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) + const serialize = vi.spyOn(editor.markdown!, 'serialize') + let codeFrom: number | undefined + editor.state.doc.forEach((node, offset) => { + if (node.type.name === 'codeBlock') { + codeFrom = offset + 1 + } + }) + if (codeFrom === undefined) { + throw new Error('Code block missing') + } + editor.view.dispatch(editor.state.tr.insertText('new\n', codeFrom)) + selectLastParagraph(editor) + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(10) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + expect( + getRichMarkdownCommentAtPos(editor, [comment(30)], 20, editor.state.selection.from) + ).toEqual(comment(30)) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + serialize.mockClear() + + expect(editor.commands.undo()).toBe(true) + selectLastParagraph(editor) + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) + expect(serialize).toHaveBeenCalledTimes(2 * editor.state.doc.childCount - 1) + serialize.mockClear() + expect(getRichMarkdownAnnotationTarget(editor, root)?.lineNumber).toBe(9) + expect(serialize).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts b/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts new file mode 100644 index 00000000000..261b982f0bd --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-comment-blocks.ts @@ -0,0 +1,82 @@ +import type { Editor, JSONContent } from '@tiptap/core' +import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count' + +export type RichMarkdownCommentBlock = { + key: string + startLine: number + endLine: number + from: number + to: number +} + +function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string { + return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd() +} + +export function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownCommentBlock[] { + const jsonContent = editor.getJSON().content ?? [] + const blocks: RichMarkdownCommentBlock[] = [] + let nextLine = 1 + let previousNodeJson: JSONContent | null = null + let previousNodeLineCount = 0 + + editor.state.doc.forEach((node, nodeOffset, index) => { + const nodeJson = jsonContent[index] + if (!nodeJson) { + return + } + const nodeMarkdown = serializeRichMarkdownJson(editor, [nodeJson]) + const nodeLineCount = countRichMarkdownReviewMarkdownLines(nodeMarkdown) + if (previousNodeJson) { + const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson]) + const separatorLineCount = Math.max( + 0, + countRichMarkdownReviewMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount + ) + nextLine += separatorLineCount + } + const startLine = nextLine + const endLine = Math.max(startLine, startLine + nodeLineCount - 1) + const from = nodeOffset + 1 + blocks.push({ + key: `${index}:${startLine}-${endLine}`, + startLine, + endLine, + from, + to: from + Math.max(0, node.nodeSize - 1) + }) + nextLine = endLine + 1 + previousNodeJson = nodeJson + previousNodeLineCount = nodeLineCount + }) + + if (blocks.length === 0) { + blocks.push({ key: 'empty:1-1', startLine: 1, endLine: 1, from: 1, to: 1 }) + } + + return blocks +} + +type RichMarkdownCommentBlocksSnapshot = { + doc: Editor['state']['doc'] + markdown: Editor['markdown'] + serialize: NonNullable['serialize'] | undefined + blocks: readonly RichMarkdownCommentBlock[] +} + +// Keep only the current document per editor; scrolling changes geometry, not source lines. +const blocksByEditor = new WeakMap() + +export function getRichMarkdownCommentBlocks(editor: Editor): readonly RichMarkdownCommentBlock[] { + const doc = editor.state.doc + const markdown = editor.markdown + const serialize = markdown?.serialize + const cached = blocksByEditor.get(editor) + if (cached?.doc === doc && cached.markdown === markdown && cached.serialize === serialize) { + return cached.blocks + } + + const blocks = buildRichMarkdownCommentBlocks(editor) + blocksByEditor.set(editor, { doc, markdown, serialize, blocks }) + return blocks +} diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link-keyboard.test.ts b/src/renderer/src/components/editor/rich-markdown-doc-link-keyboard.test.ts new file mode 100644 index 00000000000..add8be94d44 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-doc-link-keyboard.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom + +import { Editor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import { NodeSelection } from '@tiptap/pm/state' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownDocLink } from './rich-markdown-doc-link' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' + +const editors: Editor[] = [] + +function docLinkEditor(): Editor { + const editor = new Editor({ + extensions: [StarterKit, createMarkdownDocLink(createRichMarkdownEditorCodec().transport)], + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'before' }, + { type: 'markdownDocLink', attrs: { target: 'Guide' } }, + { type: 'text', text: 'after' } + ] + } + ] + } + }) + editors.push(editor) + return editor +} + +function handleKey(editor: Editor, key: string, modifiers: KeyboardEventInit = {}): boolean { + const event = new KeyboardEvent('keydown', { key, ...modifiers }) + let handled = false + editor.view.someProp('handleKeyDown', (handler) => { + if (!handler(editor.view, event)) { + return false + } + handled = true + return true + }) + return handled +} + +afterEach(() => editors.splice(0).forEach((editor) => editor.destroy())) + +describe('document link arrow navigation', () => { + it.each([ + { key: 'ArrowLeft', from: 8, to: 13 }, + { key: 'ArrowLeft', from: 13, to: 8 }, + { key: 'ArrowRight', from: 7, to: 13 }, + { key: 'ArrowRight', from: 13, to: 7 } + ])('leaves $key selection $from..$to for native collapse', ({ key, from, to }) => { + const editor = docLinkEditor() + editor.commands.setTextSelection({ from, to }) + const before = editor.state.doc + const selection = editor.state.selection + expect(handleKey(editor, key)).toBe(false) + expect(editor.state.doc.eq(before)).toBe(true) + expect(editor.state.selection.eq(selection)).toBe(true) + }) + + it.each([ + { key: 'ArrowLeft', position: 8, expectedCaret: 14 }, + { key: 'ArrowRight', position: 7, expectedCaret: 9 } + ])( + 'opens an adjacent link for editing with $key at an empty caret', + ({ key, position, expectedCaret }) => { + const editor = docLinkEditor() + editor.commands.setTextSelection(position) + expect(handleKey(editor, key)).toBe(true) + expect(editor.state.doc.textContent).toBe('before[[Guide]]after') + expect(editor.state.selection.from).toBe(expectedCaret) + expect(editor.state.selection.empty).toBe(true) + } + ) + + it.each([{ shiftKey: true }, { altKey: true }, { metaKey: true }, { ctrlKey: true }])( + 'preserves modified arrow handling: %j', + (modifiers) => { + const editor = docLinkEditor() + editor.commands.setTextSelection(8) + const before = editor.state.doc + expect(handleKey(editor, 'ArrowLeft', modifiers)).toBe(false) + expect(editor.state.doc.eq(before)).toBe(true) + } + ) + + it('preserves node selection and keys away from a link', () => { + const editor = docLinkEditor() + editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 7))) + const before = editor.state.doc + expect(handleKey(editor, 'ArrowLeft')).toBe(false) + editor.commands.setTextSelection(1) + expect(handleKey(editor, 'ArrowRight')).toBe(false) + expect(editor.state.doc.eq(before)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-doc-link.ts b/src/renderer/src/components/editor/rich-markdown-doc-link.ts index 1ae68d44bfe..4b26e88ac91 100644 --- a/src/renderer/src/components/editor/rich-markdown-doc-link.ts +++ b/src/renderer/src/components/editor/rich-markdown-doc-link.ts @@ -244,7 +244,7 @@ export function createMarkdownDocLink(transport: RichMarkdownSourceTransport) { return false } const { state } = view - if (!(state.selection instanceof TextSelection)) { + if (!(state.selection instanceof TextSelection) || !state.selection.empty) { return false } const { $from } = state.selection diff --git a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts index 90bd170a5c1..f466a38b321 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-annotations.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-annotations.ts @@ -1,6 +1,5 @@ import type { Dispatch, SetStateAction } from 'react' import type { Editor } from '@tiptap/react' -import type { JSONContent } from '@tiptap/core' import type { DiffComment } from '../../../../shared/diff-comment-types' import type { RichMarkdownAnnotationHighlightRange } from './rich-markdown-annotation-highlight' import { @@ -10,7 +9,14 @@ import { import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout' import { findRichMarkdownSelectedTextRanges } from './rich-markdown-review-text-ranges' import { getRichMarkdownSelectionVisibleText } from './rich-markdown-visible-text-map' -import { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count' +import { + getRichMarkdownCommentBlocks, + type RichMarkdownCommentBlock +} from './rich-markdown-comment-blocks' +export { + buildRichMarkdownCommentBlocks, + type RichMarkdownCommentBlock +} from './rich-markdown-comment-blocks' export { countRichMarkdownReviewMarkdownLines } from './rich-markdown-review-line-count' const RICH_MARKDOWN_ANNOTATION_BUTTON_SIZE_PX = 24 @@ -22,14 +28,6 @@ const RICH_MARKDOWN_ANNOTATION_POPOVER_WIDTH_PX = 420 const RICH_MARKDOWN_ANNOTATION_POPOVER_RIGHT_OFFSET_PX = 24 const RICH_MARKDOWN_ANNOTATION_POPOVER_MIN_HEIGHT_PX = 220 -export type RichMarkdownCommentBlock = { - key: string - startLine: number - endLine: number - from: number - to: number -} - export type RichMarkdownComposerState = { lineNumber: number startLine?: number @@ -45,54 +43,6 @@ export type RichMarkdownAnnotationTarget = RichMarkdownComposerState & { buttonLeft: number } -function serializeRichMarkdownJson(editor: Editor, content: JSONContent[]): string { - return (editor.markdown?.serialize({ type: 'doc', content }) ?? '').trimEnd() -} - -export function buildRichMarkdownCommentBlocks(editor: Editor): RichMarkdownCommentBlock[] { - const jsonContent = editor.getJSON().content ?? [] - const blocks: RichMarkdownCommentBlock[] = [] - let nextLine = 1 - let previousNodeJson: JSONContent | null = null - let previousNodeLineCount = 0 - - editor.state.doc.forEach((node, nodeOffset, index) => { - const nodeJson = jsonContent[index] - if (!nodeJson) { - return - } - const nodeMarkdown = serializeRichMarkdownJson(editor, [nodeJson]) - const nodeLineCount = countRichMarkdownReviewMarkdownLines(nodeMarkdown) - if (previousNodeJson) { - const pairMarkdown = serializeRichMarkdownJson(editor, [previousNodeJson, nodeJson]) - const separatorLineCount = Math.max( - 0, - countRichMarkdownReviewMarkdownLines(pairMarkdown) - previousNodeLineCount - nodeLineCount - ) - nextLine += separatorLineCount - } - const startLine = nextLine - const endLine = Math.max(startLine, startLine + nodeLineCount - 1) - const from = nodeOffset + 1 - blocks.push({ - key: `${index}:${startLine}-${endLine}`, - startLine, - endLine, - from, - to: from + Math.max(0, node.nodeSize - 1) - }) - nextLine = endLine + 1 - previousNodeJson = nodeJson - previousNodeLineCount = nodeLineCount - }) - - if (blocks.length === 0) { - blocks.push({ key: 'empty:1-1', startLine: 1, endLine: 1, from: 1, to: 1 }) - } - - return blocks -} - export function clampRichMarkdownAnnotationTarget( editor: Editor, target: RichMarkdownAnnotationTarget @@ -122,8 +72,7 @@ export function getRichMarkdownAnnotationHighlightRanges( if (comments.length === 0) { return [] } - // Why once: block resolution re-serializes the doc; per comment it was O(n*doc). - const blocks = buildRichMarkdownCommentBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) return comments.flatMap((comment) => getRichMarkdownAnnotationHighlightRangesForComment( editor, @@ -138,10 +87,9 @@ export function getRichMarkdownAnnotationHighlightRangesForComment( editor: Editor, comment: DiffComment, markdownSourceLineOffset: number, - // Why optional: callers looping over comments pass one shared build. prebuiltBlocks?: readonly RichMarkdownCommentBlock[] ): RichMarkdownAnnotationHighlightRange[] { - const blocks = prebuiltBlocks ?? buildRichMarkdownCommentBlocks(editor) + const blocks = prebuiltBlocks ?? getRichMarkdownCommentBlocks(editor) const selectedText = comment.selectedText?.trim() if (!selectedText) { return [] @@ -173,7 +121,7 @@ export function getRichMarkdownCommentAtPos( if (comments.length === 0) { return null } - const blocks = buildRichMarkdownCommentBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) return ( comments.find((comment) => getRichMarkdownAnnotationHighlightRangesForComment( @@ -216,7 +164,7 @@ export function getRichMarkdownCommentAnchorTop( } function getRichMarkdownSelectionRange(editor: Editor): RichMarkdownComposerState { - const blocks = buildRichMarkdownCommentBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) const { from, to, empty } = editor.state.selection const selectedBlocks = empty ? blocks.filter((block) => block.from <= from && from <= block.to) diff --git a/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts b/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts index 4fdc12a51b9..b7cc8bc63e2 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-note-positioning.ts @@ -1,7 +1,7 @@ import type { Editor } from '@tiptap/react' import type { DiffComment } from '../../../../shared/diff-comment-types' import { getRichMarkdownCommentAnchorTop } from './rich-markdown-review-annotations' -import { getRichMarkdownReviewRailBlocks } from './rich-markdown-review-rail-blocks' +import { getRichMarkdownCommentBlocks } from './rich-markdown-comment-blocks' import { stackRichMarkdownReviewNotePositions, type RichMarkdownReviewNotePosition @@ -21,7 +21,7 @@ export function measureRichMarkdownReviewNotePositions({ markdownSourceLineOffset }: MeasureRichMarkdownReviewNotePositionsOptions): RichMarkdownReviewNotePosition[] { const containerRect = container.getBoundingClientRect() - const blocks = getRichMarkdownReviewRailBlocks(editor) + const blocks = getRichMarkdownCommentBlocks(editor) const nextPositions = markdownComments .map((comment): RichMarkdownReviewNotePosition | null => { const bodyLineNumber = Math.max(1, comment.lineNumber - markdownSourceLineOffset) diff --git a/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts b/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts index 7090769f71f..d64fcc667b8 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-rail-benchmark.test.ts @@ -48,7 +48,8 @@ function measureBaseline({ block, containerRect, container.scrollTop, - markdownSourceLineOffset + markdownSourceLineOffset, + buildRichMarkdownCommentBlocks(editor) ) return top === null ? null : { comment, top } }) diff --git a/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts b/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts index 35dd1bb9eb5..9cb8bf0b64d 100644 --- a/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts +++ b/src/renderer/src/components/editor/rich-markdown-review-rail-blocks.ts @@ -1,31 +1 @@ -import type { Editor } from '@tiptap/core' -import { - buildRichMarkdownCommentBlocks, - type RichMarkdownCommentBlock -} from './rich-markdown-review-annotations' - -type ReviewRailBlocks = { - doc: Editor['state']['doc'] - markdown: Editor['markdown'] - serialize: NonNullable['serialize'] | undefined - blocks: readonly RichMarkdownCommentBlock[] -} - -// Keep only the current document per editor; scrolling changes geometry, not source lines. -const blocksByEditor = new WeakMap() - -export function getRichMarkdownReviewRailBlocks( - editor: Editor -): readonly RichMarkdownCommentBlock[] { - const doc = editor.state.doc - const markdown = editor.markdown - const serialize = markdown?.serialize - const cached = blocksByEditor.get(editor) - if (cached?.doc === doc && cached.markdown === markdown && cached.serialize === serialize) { - return cached.blocks - } - - const blocks = buildRichMarkdownCommentBlocks(editor) - blocksByEditor.set(editor, { doc, markdown, serialize, blocks }) - return blocks -} +export { getRichMarkdownCommentBlocks as getRichMarkdownReviewRailBlocks } from './rich-markdown-comment-blocks' diff --git a/src/renderer/src/components/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts index f33f9a42196..45149f90858 100644 --- a/src/renderer/src/components/mobile/mobile-platform-copy.ts +++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts @@ -22,7 +22,7 @@ const IOS_CHANNEL_COPY: Record = { const ANDROID_COPY: InstallCopy = { ctaLabel: 'Download APK', - url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk' + url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.52/app-release.apk' } export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx index eb71e3e6b54..db59afdb19d 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx @@ -10,6 +10,8 @@ import { NativeChatMessageList } from './NativeChatMessageList' import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +const NO_CARDS: readonly string[] = [] + let restoreViewport = (): void => {} beforeAll(() => { restoreViewport = installNativeChatMessageListTestViewport() @@ -42,7 +44,7 @@ function transcript(items: AgentJournalRenderItem[]) { return ( } const view = (items: AgentJournalRenderItem[]) => ( Promise.resolve('exhausted' as const) function Transcript({ items }: { items: AgentJournalRenderItem[] }) { - const messages = useStructuredAgentSessionMessages(items, EMPTY, EMPTY) + const messages = useStructuredAgentSessionMessages(items, EMPTY, EMPTY, EMPTY) const session: NativeChatLiveSession = { messages, status: 'working', diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx index b01675c63a7..29f977046f6 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx @@ -88,7 +88,9 @@ function journalList( return ( {} beforeAll(() => { restoreViewport = installNativeChatMessageListTestViewport() @@ -135,7 +139,7 @@ function list(phase: Phase, scoped: boolean, outbox: StructuredAgentSessionOutbo return ( { + const LOST = agentJournalSubmissionKey('lost') + + function hostList(phase: Phase) { + const items = journal(phase, true) + const newIndex = items.findIndex((item) => item.itemId === NEW) + // Recorded between the seed's answer and the newer prompt; the next open rejected it. + items.splice(newIndex, 0, { + itemId: LOST, + revision: 0, + sequence: items[newIndex - 1]!.sequence, + sequenceIndex: 1, + observedAt: 1002.5, + body: said('user', 'LOST PROMPT'), + turnScope: { kind: 'thread' } + }) + const submissions: AgentJournalSubmission[] = [ + submission('seed', 'accepted'), + { + ...submission('lost', 'rejected'), + reason: DISPATCH_REJECTED_HOST_RESTARTED, + rejection: { kind: 'hostRestarted' } + }, + submission('new', phase === 'done' ? 'accepted' : 'pending') + ] + const settledTurns: NativeChatSettledTurns = new Map([ + [SEED, { startedAt: 1, workedSeconds: 3 }], + ...(phase === 'done' ? [[NEW, { startedAt: 2, workedSeconds: 5 }] as const] : []) + ]) + return ( + + ) + } + + it('draws it where it was sent, with its reason and no Retry, outside the newer turn', () => { + const { container, rerender } = render(hostList('running')) + expect(drawn(container)).toEqual([ + 'SEED PROMPT', + 'WORKED', + 'LOST PROMPT', + 'NEW PROMPT', + 'WORKING', + 'ACTIVITY' + ]) + expect(container.textContent).toContain('Orca restarted before this message was sent.') + expect(container.querySelector('button[aria-label="Retry"]')).toBeNull() + expect( + [...container.querySelectorAll('button')].map((button) => button.textContent) + ).not.toContain('Retry') + rerender(hostList('done')) + expect(drawn(container)).toEqual([ + 'SEED PROMPT', + 'WORKED', + 'LOST PROMPT', + 'NEW PROMPT', + 'WORKED' + ]) + expect(container.textContent).toContain('Worked for 5s') + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx new file mode 100644 index 00000000000..90abdc3ef25 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.menu-request.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment happy-dom + +// Against the real menu: a `/model` request opens the menu once, and a period while the host still +// lists models neither opens it later nor reopens one the user closed. + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + SessionOptionDescriptor, + SessionOptionsSurface +} from '../../../../shared/native-chat-session-options' + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + values + ? Object.entries(values).reduce( + (text, [name, value]) => text.replaceAll(`{{${name}}}`, String(value)), + fallback + ) + : fallback +})) + +import { TooltipProvider } from '@/components/ui/tooltip' +import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' +import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' + +function model(pending: boolean): SessionOptionDescriptor { + return { + id: 'model', + label: 'Model', + category: 'model', + kind: { + type: 'select', + currentValue: 'opus', + choices: [ + { value: 'opus', label: 'Opus 4.8' }, + { value: 'sonnet', label: 'Sonnet 5' } + ] + }, + valueSource: 'applied', + transport: 'agent-session', + settable: !pending, + ...(pending ? { choicesPending: true as const } : {}) + } +} + +const surface: SessionOptionsSurface = { + getSnapshot: () => [], + setOption: vi.fn(async () => ({ snapshot: [] })), + invokeAction: vi.fn(async () => ({ snapshot: [] })), + subscribe: () => () => {} +} + +function view(pending: boolean, request: NativeChatOptionPickerRequest | null): React.JSX.Element { + return ( + +