diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 456fa8f2831..322481ae8b3 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -63,9 +63,10 @@ CI enforces this for `src/preload/` and `src/shared/`. Each pull request should follow [`.github/pull_request_template.md`](./pull_request_template.md). In particular: +- if you are an outside contributor, link the issue your PR addresses - open with an ELI5 of the change (plain language paragraph; the PR title is the one-liner) - explain what changed and why, and stay focused on a single topic when possible -- for any UI or interaction change, attach **before and after** screenshots (or short videos); if there is no visual change, say `No visual change` and why +- for any UI or interaction change, attach **before and after** screenshots (or short videos); if there is no visual or interaction change, write `N/A` and briefly explain why - include high-quality tests when behavior changes or bug fixes warrant them - include a brief code review summary from your AI coding agent that explicitly checks cross-platform compatibility, SSH/remote/local compatibility, supported agent and integration compatibility, performance risk, UI quality when applicable, and basic security risk - mention any platform-specific, remote/SSH-specific, agent-specific, integration-specific, or git-provider-specific behavior and testing notes diff --git a/.github/scripts/check-root-directory-entries.mjs b/.github/scripts/check-root-directory-entries.mjs index 5b63326691d..a428c8cc090 100644 --- a/.github/scripts/check-root-directory-entries.mjs +++ b/.github/scripts/check-root-directory-entries.mjs @@ -13,9 +13,8 @@ function readRootEntries(sha) { return stdout.split('\0').filter(Boolean) } -// Why: the Cloud workspace import is the one reviewed root addition; it stays -// listed until it lands on main, after which the base tree carries it. -const REVIEWED_ROOT_ENTRIES = new Set(['cloud']) +// These reviewed additions stay listed until the base tree carries them. +const REVIEWED_ROOT_ENTRIES = new Set(['cloud', 'opencode.json']) function checkRootDirectoryEntries(argv) { if (argv.length !== 2) { diff --git a/.github/workflows/cloud-deploy-relay-asia-topology.yml b/.github/workflows/cloud-deploy-relay-asia-topology.yml index 07fbc68056e..3e8929376be 100644 --- a/.github/workflows/cloud-deploy-relay-asia-topology.yml +++ b/.github/workflows/cloud-deploy-relay-asia-topology.yml @@ -78,6 +78,7 @@ jobs: production:production-gce-c30) ;; production:production-gce-c31) ;; production:production-gce-c32,production-gce-c33) target_region=us-central1 ;; + production:production-gce-c34) ;; *) echo "cell-ids do not match the reviewed environment topology" >&2; exit 1 ;; esac echo "TARGET_REGION=${target_region}" >> "${GITHUB_ENV}" diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml index af66e918386..913b6a24b17 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml @@ -194,7 +194,7 @@ jobs: EXPECTED_REGION=us-central1 EXPECTED_DATABASE_POOL_MAX= ;; - c27|c28|c29|c30|c31) + c27|c28|c29|c30|c31|c34) EXPECTED_HARD_CAP=3000 EXPECTED_REGION=asia-east2 EXPECTED_DATABASE_POOL_MAX=16 diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index ae695b8e3eb..78047f09d2e 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -120,8 +120,6 @@ jobs: background: true run: node node_modules/typescript/bin/tsc --noEmit - - wait: production-types - # Why a ratchet and not the raw typecheck: mobile/tsconfig.json excludes test files, so until # tsconfig.test.json existed nothing checked them, and at introduction 127 of the 632 had # drifted. This fails when a test file that checks today stops checking, when a test leaves @@ -129,6 +127,8 @@ jobs: - name: Typecheck tests (ratchet) run: node scripts/check-tests-typecheck-ratchet.mjs + - wait: production-types + # This includes the bridged replay of the whole recording corpus, which used to be a second # step of its own behind RPC_FOUNDATION_BRIDGE=1. A gate nobody can forget to set is the point: # it fails when a divergence class grows, when a divergence lands in no class at all, or when diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b370dff9929..ed9a25732bb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -440,8 +440,12 @@ jobs: git_compatibility: name: Git compatibility - needs: [code_paths] - if: needs.code_paths.outputs.git_compatibility == 'true' + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.git_compatibility == 'true' && + needs.preflight.result == 'success' runs-on: ubuntu-latest steps: @@ -509,8 +513,12 @@ jobs: # repair stops happening. Pinned because the binary is the thing expected to drift. codex_index_heal_contract: name: Codex index-heal contract - needs: [code_paths] - if: needs.code_paths.outputs.codex_index_heal_contract == 'true' + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.codex_index_heal_contract == 'true' && + needs.preflight.result == 'success' # Why ARM: @openai/codex ships @openai/codex-linux-arm64. runs-on: ubuntu-24.04-arm env: @@ -575,8 +583,12 @@ jobs: xterm_patch_sync: name: xterm patch sync - needs: [code_paths] - if: needs.code_paths.outputs.xterm_patch_sync == 'true' + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.xterm_patch_sync == 'true' && + needs.preflight.result == 'success' # Why ARM: the patch check rebuilds 4 packages x 2 builds and byte-compares against the # checked-in bundles. Those were generated on Linux x64 and reproduce byte-for-byte on # darwin-arm64, so the output is neither host-arch nor host-OS dependent. @@ -604,8 +616,12 @@ jobs: shell_contracts: name: shell contracts - needs: [code_paths] - if: needs.code_paths.outputs.shell_contracts == 'true' + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.shell_contracts == 'true' && + needs.preflight.result == 'success' # Why ARM: fish 4.x is published for noble/arm64 and zsh is in the arm64 archive. runs-on: ubuntu-24.04-arm # Why: this job's cost is almost entirely package download, and a stalled mirror has @@ -758,8 +774,12 @@ jobs: # would pay for it on every shard to run one file in whichever shard it landed in. orcad_browser: name: orcad browser provider - needs: [code_paths] - if: needs.code_paths.outputs.orcad_browser == 'true' + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.orcad_browser == 'true' && + needs.preflight.result == 'success' runs-on: ubuntu-latest steps: @@ -811,7 +831,7 @@ jobs: # in this job loads node-pty. - uses: ./.github/actions/install-node-dependencies with: - native-runtime: node + native-runtime: none cache-dependency-path: | pnpm-lock.yaml mobile/pnpm-lock.yaml @@ -872,8 +892,12 @@ jobs: cross-version-wire: name: cross-version wire compatibility - needs: [code_paths] - if: needs.code_paths.outputs.cross-version-wire == 'true' + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.cross-version-wire == 'true' && + needs.preflight.result == 'success' # Why ARM: source-only: tagged checkout plus in-process vitest, no docker or browser. runs-on: ubuntu-24.04-arm @@ -916,8 +940,12 @@ jobs: managed_hook_node18: name: managed hooks on Node 18 - needs: [code_paths] - if: needs.code_paths.outputs.managed_hook_node18 == 'true' + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.managed_hook_node18 == 'true' && + needs.preflight.result == 'success' # Why ARM: Node 18 publishes linux-arm64; the per-platform runtime files are read as data. runs-on: ubuntu-24.04-arm @@ -1263,8 +1291,19 @@ jobs: e2e: name: e2e - needs: code_paths - if: needs.code_paths.outputs.e2e_should_run == 'true' + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.e2e_should_run == 'true' && + ( + needs.preflight.result == 'success' || + ( + needs.preflight.result == 'skipped' && + needs.code_paths.outputs.static_analysis == 'false' && + needs.code_paths.outputs.typecheck == 'false' + ) + ) # Why: reusable e2e.yml only checkouts, builds, and uploads artifacts. permissions: contents: read diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 8afae35ca12..f822dd9a53d 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -84,6 +84,17 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false + # Keep complete server/test trees; missing future imports fail the unchanged builds. + sparse-checkout: | + .github + config + native + resources + tests + src/main + src/shared + src/relay + src/types - name: Self-test the provisioning scripts before touching the machine shell: pwsh run: | diff --git a/.github/workflows/terminal-ime-e2e.yml b/.github/workflows/terminal-ime-e2e.yml index 168280f9ac2..a99e045e4b9 100644 --- a/.github/workflows/terminal-ime-e2e.yml +++ b/.github/workflows/terminal-ime-e2e.yml @@ -5,12 +5,21 @@ on: # change is worth a real ibus session. A pull_request trigger here would run it on every PR. workflow_call: workflow_dispatch: + inputs: + diagnose_wayland_input: + description: Capture passive Wayland input diagnostics while running both native lanes + required: false + type: boolean + default: false schedule: - cron: '30 9 * * *' permissions: contents: read +env: + ORCA_BACKGROUND_LAUNCH: '1' + jobs: linux-x11: name: Linux X11 terminal IME @@ -98,6 +107,7 @@ jobs: - name: Run native Wayland Hangul terminating digit env: SKIP_BUILD: '1' + ORCA_E2E_WAYLAND_INPUT_DIAGNOSTICS: ${{ inputs.diagnose_wayland_input && '1' || '' }} run: node config/scripts/run-terminal-ibus-hangul-e2e.mjs --nested-wayland - name: Upload Wayland terminal IME evidence if: always() diff --git a/cloud/apps/relay/src/assignment-cleanup-steps.test.ts b/cloud/apps/relay/src/assignment-cleanup-steps.test.ts index 0e5eac13e31..38fbb105580 100644 --- a/cloud/apps/relay/src/assignment-cleanup-steps.test.ts +++ b/cloud/apps/relay/src/assignment-cleanup-steps.test.ts @@ -16,6 +16,7 @@ function stubStore(overrides: Partial = {}) { abortExpiredRegionalRehomes: method('abortExpiredRegionalRehomes'), reapRegionalRehomeAttempts: method('reapRegionalRehomeAttempts'), releaseExpiredActivityLeases: method('releaseExpiredActivityLeases'), + pruneReleasedControlReservations: method('pruneReleasedControlReservations'), releaseExpiredActivity: method('releaseExpiredActivity'), releaseExpiredRegionPreferences: method('releaseExpiredRegionPreferences'), evacuateDeadCells: method('evacuateDeadCells'), @@ -43,6 +44,7 @@ describe('assignment cleanup steps', () => { 'abortExpiredRegionalRehomes', 'reapRegionalRehomeAttempts', 'releaseExpiredActivityLeases', + 'pruneReleasedControlReservations', 'releaseExpiredActivity', 'releaseExpiredRegionPreferences', 'evacuateDeadCells' diff --git a/cloud/apps/relay/src/assignment-cleanup-steps.ts b/cloud/apps/relay/src/assignment-cleanup-steps.ts index 31e25be3688..9edd7044313 100644 --- a/cloud/apps/relay/src/assignment-cleanup-steps.ts +++ b/cloud/apps/relay/src/assignment-cleanup-steps.ts @@ -1,9 +1,9 @@ import { runRelayBackgroundOperation } from './relay-background-operation.js' -// The eleven periodic assignment sweeps the director runs every 30s. Each step +// The twelve periodic assignment sweeps the director runs every 30s. Each step // re-derives its state from the database and is idempotent, so they carry no // intra-tick ordering dependency — which is what makes per-step isolation -// sound: one failing sweep costs one tick of itself, never the other ten. +// sound: one failing sweep costs one tick of itself, never the other eleven. // (A single poisoned rehome row once silenced the whole chained form // fleet-wide.) Sweep failures are logged, never fed into the rehome worker's // dispatch-failure budget: a sweep exception is not a dispatch failure and @@ -17,6 +17,7 @@ export type AssignmentCleanupStore = { abortExpiredRegionalRehomes(): Promise reapRegionalRehomeAttempts(): Promise releaseExpiredActivityLeases(): Promise + pruneReleasedControlReservations(): Promise releaseExpiredActivity(): Promise releaseExpiredRegionPreferences(): Promise evacuateDeadCells(): Promise @@ -34,6 +35,10 @@ function assignmentCleanupSteps( ['abort-expired-regional-rehomes', () => assignments.abortExpiredRegionalRehomes()], ['reap-regional-rehome-attempts', () => assignments.reapRegionalRehomeAttempts()], ['release-expired-activity-leases', () => assignments.releaseExpiredActivityLeases()], + [ + 'prune-released-control-reservations', + () => assignments.pruneReleasedControlReservations() + ], ['release-expired-activity', () => assignments.releaseExpiredActivity()], ['release-expired-region-preferences', () => assignments.releaseExpiredRegionPreferences()], ['evacuate-dead-cells', () => assignments.evacuateDeadCells()] diff --git a/cloud/apps/relay/src/assignment-store.ts b/cloud/apps/relay/src/assignment-store.ts index e9e9d7b507c..14c3e50a0dc 100644 --- a/cloud/apps/relay/src/assignment-store.ts +++ b/cloud/apps/relay/src/assignment-store.ts @@ -1,4 +1,5 @@ import { createDrainMigrationRowLookup } from './drain-migration-row-lookup.js' +import { HeapWindowReaper } from './heap-window-reaper.js' import { selectIdleRegionalRehomes, type IdleRegionalRehomeCandidate, @@ -498,6 +499,19 @@ export class RelayHomeCellUnavailableError extends Error { // never return otherwise starves connection headroom fleet-wide and turns // every placement into relay_capacity_exhausted. const LATE_ARRIVAL_DEBT_RETENTION_MS = 10 * 60 * 1_000 +// A released reservation is read by nothing: every reader filters it out by state, and the only +// statement that still touches it is the per-host lock, which just makes that lock set longer. A day +// is margin for forensics, not for reads. +export const RELEASED_CONTROL_RESERVATION_RETENTION_MS = 24 * 60 * 60 * 1_000 +// ~27 rows per page, so a statement deletes a few hundred rows at most. With ~9 director ticks a +// minute the row cap is ~5M rows a day: the 13.7M-row backlog drains over about three days, and a +// walk that finds nothing reads ~1 MB a tick. +const RELEASED_CONTROL_RESERVATION_REAP_BUDGET = { + pagesPerStatement: 16, + maxPagesPerTick: 128, + maxRowsPerTick: 400, + budgetMs: 250 +} const CELL_FENCE_TTL_MS = 5 * 60 * 1_000 const CELL_FENCE_ATTEMPT_TTL_MS = 60 * 60 * 1_000 const CELL_DRAIN_SEND_PERMIT_MS = 30_000 @@ -541,6 +555,11 @@ export class RelayAssignmentStore { private readonly admissionSelector: RelayCellAdmissionSelector private readonly migrationCellRegistrar: RelayMigrationCellRegistrar private readonly activityQueue = new AssignmentIdentityQueue() + private readonly releasedReservationReaper = new HeapWindowReaper( + 'relay_control_connection_reservations', + `state = 'released' AND released_at <= ?`, + RELEASED_CONTROL_RESERVATION_REAP_BUDGET + ) private assignmentTail: Promise = Promise.resolve() constructor( @@ -2944,6 +2963,10 @@ export class RelayAssignmentStore { async evacuateDeadCells(limit = 100): Promise { if (!this.requireLiveCells) return 0 const cutoff = this.now() - this.heartbeatTtlMs + const cellIds = await this.deadCellEvacuationCandidates(cutoff) + // Why: without a candidate cell the host query below walks every assignment by primary key to + // return nothing (574 ms per call in production, from stale existing-only cells it can never act on). + if (cellIds.length === 0) return 0 const rows = await this.database.query( `SELECT assignment.user_id, assignment.relay_host_id, assignment.cell_id FROM relay_assignments assignment @@ -3006,8 +3029,9 @@ export class RelayAssignmentStore { AND fence.expires_at > ? ) ) + AND assignment.cell_id IN (${cellIds.map(() => '?').join(', ')}) ORDER BY assignment.user_id, assignment.relay_host_id LIMIT ?`, - [1, cutoff, this.now(), this.now(), limit] + [1, cutoff, this.now(), this.now(), ...cellIds, limit] ) let moved = 0 for (const row of rows) { @@ -3034,6 +3058,43 @@ export class RelayAssignmentStore { return moved } + // The cell-level half of evacuateDeadCells' predicate, so it is a superset: every cell the host + // query could act on is here, and only the per-host pin checks are left out. + private async deadCellEvacuationCandidates(cutoff: number): Promise { + const now = this.now() + const rows = await this.database.query( + `SELECT cell.cell_id + FROM relay_cells cell + LEFT JOIN relay_cell_committed_fences committed ON committed.cell_id = cell.cell_id + LEFT JOIN relay_cell_fence_attempts attempt ON attempt.attempt_id = committed.attempt_id + LEFT JOIN relay_cell_fences fence ON fence.cell_id = cell.cell_id + LEFT JOIN relay_cell_runtime runtime ON runtime.cell_id = cell.cell_id + WHERE (runtime.cell_id IS NULL OR runtime.ready != ? OR runtime.last_heartbeat_at <= ?) + AND ( + ( + cell.enabled = 1 + AND NOT EXISTS ( + SELECT 1 FROM relay_cell_connection_limits limits + WHERE limits.cell_id = cell.cell_id + ) + ) + OR ( + cell.enabled = 0 + AND attempt.completed_at IS NOT NULL + AND attempt.aborted_at IS NULL + AND committed.cell_incarnation = runtime.cell_incarnation + AND fence.cell_incarnation = committed.cell_incarnation + AND committed.attested_at >= runtime.last_heartbeat_at + AND committed.expires_at > ? + AND fence.expires_at > ? + ) + ) + ORDER BY cell.cell_id`, + [1, cutoff, now, now] + ) + return rows.map((row) => text(row, 'cell_id')) + } + async configureCell( cell: RelayCellConfig, admission: boolean | CellAdmissionState @@ -7158,6 +7219,12 @@ export class RelayAssignmentStore { return aborted } + async pruneReleasedControlReservations(): Promise { + return await this.releasedReservationReaper.reap(this.database, [ + this.now() - RELEASED_CONTROL_RESERVATION_RETENTION_MS + ]) + } + async releaseExpiredActivityLeases(): Promise { const now = this.now() await this.database.query( diff --git a/cloud/apps/relay/src/credential-cleanup-sweep-postgres.test.ts b/cloud/apps/relay/src/credential-cleanup-sweep-postgres.test.ts index 45f38326d3a..1736959bd3f 100644 --- a/cloud/apps/relay/src/credential-cleanup-sweep-postgres.test.ts +++ b/cloud/apps/relay/src/credential-cleanup-sweep-postgres.test.ts @@ -1,6 +1,11 @@ import pg from 'pg' import { afterAll, beforeEach, describe, expect, it } from 'vitest' -import { RelayCredentialStore, type RelayIdentity } from './credential-store.js' +import { + AUDIT_EVENT_RETENTION_MS, + CONFIRM_RESULT_RETENTION_MS, + RelayCredentialStore, + type RelayIdentity +} from './credential-store.js' import { openRelayDatabase, type RelayDatabase } from './database.js' // The outage this guards against: the credential cleanup ran every 30s in all 23 cells and both @@ -169,9 +174,9 @@ describePostgres('credential cleanup against PostgreSQL', () => { await database.query(`ANALYZE relay_connection_bases`) const reaper = await plan( - `DELETE FROM relay_connection_bases WHERE ctid IN ( + `DELETE FROM relay_connection_bases WHERE ctid = ANY(ARRAY( SELECT ctid FROM relay_connection_bases WHERE active = ? AND deadline <= ? LIMIT 5000 - )`, + ))`, [0, NOW - DAY_MS] ) @@ -280,4 +285,55 @@ describePostgres('credential cleanup against PostgreSQL', () => { { state: 'invalidated', total: '3' } ]) }) + + it('plans the audit reaper off relay_audit_events_at when most rows are past retention', async () => { + // Unordered, a LIMIT with this many matches is cheapest as a sequential scan from page 0, which + // in production would reread every retained row once the oldest pages are reaped. + await database.query( + `INSERT INTO relay_audit_events (id, at, type, user_id, relay_host_id, detail_json) + SELECT 'old-' || n, ?, 'resume-confirmed', ?, ?, '{}' FROM generate_series(1, 20000) AS n`, + [NOW - AUDIT_EVENT_RETENTION_MS - DAY_MS, identity.userId, identity.relayHostId] + ) + await database.query(`ANALYZE relay_audit_events`) + + const reaper = await plan( + `DELETE FROM relay_audit_events WHERE ctid = ANY(ARRAY( + SELECT ctid FROM relay_audit_events WHERE at <= ? ORDER BY at LIMIT 5000 + ))`, + [NOW - AUDIT_EVENT_RETENTION_MS] + ) + + expect(reaper).toContain('relay_audit_events_at') + expect(reaper).not.toContain('Seq Scan on relay_audit_events') + }) + + it('reaps old confirm results and audit events and keeps the ones inside retention', async () => { + await database.query( + `INSERT INTO relay_confirm_results + (user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at) + SELECT ?, ?, 'old-' || n, 'basis-1', '{}', '{}', ? FROM generate_series(1, 200) AS n`, + [identity.userId, identity.relayHostId, NOW - CONFIRM_RESULT_RETENTION_MS - 1] + ) + await database.query( + `INSERT INTO relay_confirm_results + (user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at) + VALUES (?, ?, 'recent', 'basis-1', '{}', '{}', ?)`, + [identity.userId, identity.relayHostId, NOW - DAY_MS] + ) + await database.query( + `INSERT INTO relay_audit_events (id, at, type, user_id, relay_host_id, detail_json) + VALUES ('old', ?, 'resume-confirmed', ?, ?, '{}'), ('recent', ?, 'resume-confirmed', ?, ?, '{}')`, + [ + NOW - AUDIT_EVENT_RETENTION_MS - 1, identity.userId, identity.relayHostId, + NOW - DAY_MS, identity.userId, identity.relayHostId + ] + ) + + await store.cleanup() + + expect(await database.query(`SELECT req_id FROM relay_confirm_results`)).toEqual([ + { req_id: 'recent' } + ]) + expect(await database.query(`SELECT id FROM relay_audit_events`)).toEqual([{ id: 'recent' }]) + }) }) diff --git a/cloud/apps/relay/src/credential-store-cleanup.test.ts b/cloud/apps/relay/src/credential-store-cleanup.test.ts index ba9e7be7475..7d0e70a41dd 100644 --- a/cloud/apps/relay/src/credential-store-cleanup.test.ts +++ b/cloud/apps/relay/src/credential-store-cleanup.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { RelayCredentialStore, type RelayIdentity } from './credential-store.js' +import { + AUDIT_EVENT_RETENTION_MS, + CONFIRM_RESULT_RETENTION_MS, + RelayCredentialStore, + type RelayIdentity +} from './credential-store.js' import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js' const identity: RelayIdentity = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' } @@ -71,6 +76,29 @@ async function insertDirectAuthorization( ) } +async function insertConfirmResult( + database: RelayDatabase, + result: { reqId: string; committedAt: number } +): Promise { + await database.query( + `INSERT INTO relay_confirm_results + (user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at) + VALUES (?, ?, ?, 'basis-1', '{}', '{}', ?)`, + [identity.userId, identity.relayHostId, result.reqId, result.committedAt] + ) +} + +async function insertAuditEvent( + database: RelayDatabase, + event: { id: string; at: number } +): Promise { + await database.query( + `INSERT INTO relay_audit_events (id, at, type, user_id, relay_host_id, detail_json) + VALUES (?, ?, 'resume-confirmed', ?, ?, '{}')`, + [event.id, event.at, identity.userId, identity.relayHostId] + ) +} + async function remainingIds(database: RelayDatabase, table: string, column: string): Promise { const rows = await database.query(`SELECT ${column} FROM ${table} ORDER BY ${column}`) return rows.map((row) => String(row[column])) @@ -294,4 +322,46 @@ describe('credential cleanup invite reaper', () => { expect(await remainingIds(database, 'relay_direct_authorizations', 'direct_auth_id')).toEqual([]) await database.close() }) + + it('reaps confirm results and audit events only past their retention windows', async () => { + const database = await openInMemoryRelayDatabase() + const store = new RelayCredentialStore(database, () => NOW) + await insertConfirmResult(database, { reqId: 'confirm-old', committedAt: NOW - CONFIRM_RESULT_RETENTION_MS }) + await insertConfirmResult(database, { + reqId: 'confirm-recent', + committedAt: NOW - CONFIRM_RESULT_RETENTION_MS + 1 + }) + await insertAuditEvent(database, { id: 'audit-old', at: NOW - AUDIT_EVENT_RETENTION_MS }) + await insertAuditEvent(database, { id: 'audit-recent', at: NOW - AUDIT_EVENT_RETENTION_MS + 1 }) + + await store.cleanup() + + expect(await remainingIds(database, 'relay_confirm_results', 'req_id')).toEqual(['confirm-recent']) + expect(await remainingIds(database, 'relay_audit_events', 'id')).toEqual(['audit-recent']) + await database.close() + }) + + it('still replays a confirm result inside retention', async () => { + // The one reader: a retried confirm on the same basis gets the stored answer back. + const database = await openInMemoryRelayDatabase() + const store = new RelayCredentialStore(database, () => NOW) + await database.query( + `INSERT INTO relay_confirm_results + (user_id, relay_host_id, req_id, basis_conn_id, tuple_json, result_json, committed_at) + VALUES (?, ?, 'confirm-1', 'basis-1', '{}', ?, ?)`, + [identity.userId, identity.relayHostId, JSON.stringify({ v: 1, reqId: 'confirm-1' }), NOW - DAY_MS] + ) + + await store.cleanup() + + expect( + await store.confirmResume({ + ...identity, + reqId: 'confirm-1', + basisConnId: 'basis-1', + owningControlGeneration: 1 + }) + ).toEqual({ v: 1, reqId: 'confirm-1' }) + await database.close() + }) }) diff --git a/cloud/apps/relay/src/credential-store.ts b/cloud/apps/relay/src/credential-store.ts index 8d1281bb3c5..8d17ba623f3 100644 --- a/cloud/apps/relay/src/credential-store.ts +++ b/cloud/apps/relay/src/credential-store.ts @@ -6,6 +6,7 @@ import { type DeviceResumeConfirmed } from '@orca-cloud/relay-contract' import type { RelayDatabase, SqlRow } from './database.js' +import { HeapWindowReaper } from './heap-window-reaper.js' const CREDENTIAL_GRACE_MS = 24 * 60 * 60 * 1000 // Released desktops validate invite expiry against their own clock with zero @@ -20,9 +21,23 @@ const TERMINAL_INVITE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // active/unconsumed AND inside its deadline, and every deadline is set at most 30s past insert, so // a settled row can never authorize anything again. A day is margin for forensics, not for reads. const INACTIVE_AUTHORIZATION_RETENTION_MS = 24 * 60 * 60 * 1000 +// A stored confirm result only answers a retry of the same request on the same connection basis, +// and a different basis is refused as a tuple mismatch; a basis lives for one phone connection. A +// week matches the pairing support window above. +export const CONFIRM_RESULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 +// Nothing in the relay reads audit events back; 90 days covers support and incident questions. +export const AUDIT_EVENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000 // Bounded so one cycle cannot hold row locks or grow WAL without limit; the backlog drains over // however many cycles it takes. const REAP_BATCH_ROWS = 5000 +// ~14 rows per page. With ~9 director ticks a minute the row cap is ~3M rows a day, so the 7.4M-row +// backlog drains over two to three days; a walk that finds nothing reads ~1 MB a tick. +const CONFIRM_RESULT_REAP_BUDGET = { + pagesPerStatement: 16, + maxPagesPerTick: 128, + maxRowsPerTick: 250, + budgetMs: 250 +} export type RelayIdentity = { userId: string; relayHostId: string } export type CredentialReservation = RelayIdentity & { @@ -81,6 +96,13 @@ function string(row: SqlRow, field: string): string { } export class RelayCredentialStore { + // committed_at has no index, so a LIMIT delete would plan as a sequential scan of the whole table. + private readonly confirmResultReaper = new HeapWindowReaper( + 'relay_confirm_results', + 'committed_at <= ?', + CONFIRM_RESULT_REAP_BUDGET + ) + constructor( private readonly database: RelayDatabase, private readonly now: () => number = Date.now @@ -680,16 +702,29 @@ export class RelayCredentialStore { 'consumed_at IS NOT NULL AND consumed_at <= ?', [now - INACTIVE_AUTHORIZATION_RETENTION_MS] ) + await this.confirmResultReaper.reap(this.database, [now - CONFIRM_RESULT_RETENTION_MS]) + // Ordered so the plan walks relay_audit_events_at from its oldest entry: unordered, a LIMIT + // can plan as a sequential scan that rereads the retained rows before reaching the old ones. + await this.reapBatch('relay_audit_events', 'at <= ?', [now - AUDIT_EVENT_RETENTION_MS], 'at') } // ctid/rowid, not the primary key: the physical address lets the delete re-find exactly the batch - // the subquery located instead of re-matching the predicate per row. - private async reapBatch(table: string, predicate: string, params: unknown[]): Promise { - const address = this.database.dialect === 'sqlite' ? 'rowid' : 'ctid' + // the subquery located instead of re-matching the predicate per row. On Postgres an array of TIDs, + // not `IN`: IN can plan as a hash join over a sequential scan of the whole table. + private async reapBatch( + table: string, + predicate: string, + params: unknown[], + orderBy?: string + ): Promise { + const sqlite = this.database.dialect === 'sqlite' + const address = sqlite ? 'rowid' : 'ctid' + const order = orderBy ? `ORDER BY ${orderBy} ` : '' + const batch = `SELECT ${address} FROM ${table} WHERE ${predicate} ${order}LIMIT ${REAP_BATCH_ROWS}` await this.database.query( - `DELETE FROM ${table} WHERE ${address} IN ( - SELECT ${address} FROM ${table} WHERE ${predicate} LIMIT ${REAP_BATCH_ROWS} - )`, + sqlite + ? `DELETE FROM ${table} WHERE rowid IN (${batch})` + : `DELETE FROM ${table} WHERE ctid = ANY(ARRAY(${batch}))`, params ) } diff --git a/cloud/apps/relay/src/database.test.ts b/cloud/apps/relay/src/database.test.ts index 0c987f95d40..bfeff59c69f 100644 --- a/cloud/apps/relay/src/database.test.ts +++ b/cloud/apps/relay/src/database.test.ts @@ -68,7 +68,6 @@ describe('relay database', () => { 'relay_cell_connection_runtime', 'relay_cell_connection_snapshots', 'relay_cell_drain_attempt_states', - 'relay_cell_drain_attempts', 'relay_cell_drain_recovery_attempts', 'relay_cell_fence_apply_invocations', 'relay_cell_fence_attempts', @@ -80,7 +79,6 @@ describe('relay database', () => { 'relay_cell_runtime', 'relay_cells', 'relay_confirm_results', - 'relay_confirmable_splices', 'relay_connection_bases', 'relay_control_capabilities', 'relay_control_connection_reservations', @@ -88,7 +86,6 @@ describe('relay database', () => { 'relay_direct_authorizations', 'relay_install_results', 'relay_invites', - 'relay_migration_leases', 'relay_post_drain_migration_pins', 'relay_rate_windows', 'relay_region_decisions', diff --git a/cloud/apps/relay/src/database.ts b/cloud/apps/relay/src/database.ts index 6a8a1fc202a..dbc5e88df8c 100644 --- a/cloud/apps/relay/src/database.ts +++ b/cloud/apps/relay/src/database.ts @@ -94,6 +94,10 @@ export interface RelayDatabase { // Constraint swaps are matched by NAME in pg_constraint, never by body, because the CHECK list is // generated from REGION_LIST. Changing a constraint's definition under the same name therefore does // nothing on boot: an operator drops it, and the next boot adds the current definition back. +// relay_confirmable_splices, relay_cell_drain_attempts and relay_migration_leases are no longer +// created; nothing ever wrote them. Databases that have them keep them empty until a drop is safe: +// an older image still creates them at boot, and a drop racing that CREATE can fail its schema step. +// Account erasure in orca-cloud must be deployed with retired-table support (orca-cloud#493) first. const SCHEMA = ` CREATE TABLE IF NOT EXISTS relay_invites ( user_id TEXT NOT NULL, @@ -154,19 +158,6 @@ CREATE TABLE IF NOT EXISTS relay_install_results ( PRIMARY KEY (user_id, relay_host_id, relay_device_id, req_id) ); -CREATE TABLE IF NOT EXISTS relay_confirmable_splices ( - basis_conn_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - relay_host_id TEXT NOT NULL, - owning_control_generation BIGINT NOT NULL, - relay_device_id TEXT NOT NULL, - accepted_credential_version BIGINT NOT NULL, - accepted_as TEXT NOT NULL, - confirm_deadline BIGINT NOT NULL, - active BIGINT NOT NULL, - created_at BIGINT NOT NULL -); - CREATE TABLE IF NOT EXISTS relay_connection_bases ( basis_conn_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, @@ -507,15 +498,6 @@ CREATE TABLE IF NOT EXISTS relay_cell_fence_apply_invocations ( CREATE INDEX IF NOT EXISTS relay_cell_fence_apply_invocations_attempt ON relay_cell_fence_apply_invocations(attempt_id, started_at); -CREATE TABLE IF NOT EXISTS relay_cell_drain_attempts ( - cell_id TEXT PRIMARY KEY, - cell_incarnation TEXT NOT NULL, - planned_grace_ms BIGINT NOT NULL, - attempted_at BIGINT NOT NULL, - retry_after BIGINT NOT NULL, - recover_forward_attempted_at BIGINT -); - CREATE TABLE IF NOT EXISTS relay_cell_drain_attempt_states ( attempt_id TEXT PRIMARY KEY, cell_id TEXT NOT NULL, @@ -605,17 +587,6 @@ CREATE TABLE IF NOT EXISTS relay_rate_windows ( CREATE INDEX IF NOT EXISTS relay_rate_windows_started ON relay_rate_windows(window_started_at); -CREATE TABLE IF NOT EXISTS relay_migration_leases ( - user_id TEXT NOT NULL, - relay_host_id TEXT NOT NULL, - source_cell_id TEXT NOT NULL, - target_cell_id TEXT NOT NULL, - assignment_epoch BIGINT NOT NULL, - expires_at BIGINT NOT NULL, - completed_at BIGINT, - PRIMARY KEY (user_id, relay_host_id, assignment_epoch) -); - CREATE TABLE IF NOT EXISTS relay_assignment_migrations ( user_id TEXT NOT NULL, relay_host_id TEXT NOT NULL, @@ -741,7 +712,6 @@ const POSTGRES_TRANSACTION_PHASES = [ ['relay_region_rehome_', 'regional-rehome'], ['relay_assignment_activity_leases', 'activity-lease'], ['relay_assignment_migration', 'migration'], - ['relay_migration_leases', 'migration'], ['relay_post_drain_migration_pins', 'migration'], ['relay_cell_connection_runtime', 'cell-runtime'], ['relay_cell_connection_snapshots', 'cell-runtime'], @@ -753,7 +723,6 @@ const POSTGRES_TRANSACTION_PHASES = [ ['relay_admission_selector', 'admission'], ['relay_cell_admission', 'admission'], ['relay_control_connection_reservations', 'connection'], - ['relay_confirmable_splices', 'connection'], ['relay_connection_bases', 'connection'], ['relay_direct_authorizations', 'connection'], ['relay_confirm_results', 'connection'], diff --git a/cloud/apps/relay/src/dead-cell-evacuation-precheck.test.ts b/cloud/apps/relay/src/dead-cell-evacuation-precheck.test.ts new file mode 100644 index 00000000000..c7958a5b5a7 --- /dev/null +++ b/cloud/apps/relay/src/dead-cell-evacuation-precheck.test.ts @@ -0,0 +1,154 @@ +import pg from 'pg' +import { afterAll, afterEach, describe, expect, it } from 'vitest' +import { RelayAssignmentStore } from './assignment-store.js' +import type { RelayCellConfig } from './config.js' +import { + openInMemoryRelayDatabase, + openRelayDatabase, + type RelayDatabase, + type SqlRow +} from './database.js' + +// The sweep's host query walked every assignment by primary key to return nothing while stale +// existing-only cells sat in the fleet. These pin that a fleet with nothing to evacuate never +// reaches it, and that a cell the sweep can act on still does, on both dialects. +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const schema = 'relay_dead_cell_precheck_test' +const HOST_QUERY = 'FROM relay_assignments assignment' +const CELLS: RelayCellConfig[] = [ + { id: 'cell-a', url: 'https://relay-a.example.com', capacityRequests: 10 }, + { id: 'cell-b', url: 'https://relay-b.example.com', capacityRequests: 10 } +] + +class RecordingDatabase implements RelayDatabase { + readonly statements: string[] = [] + + constructor(private readonly delegate: RelayDatabase) {} + + get dialect() { + return this.delegate.dialect + } + + async query(sql: string, params: unknown[] = []): Promise { + this.statements.push(sql) + return await this.delegate.query(sql, params) + } + + async queryLocked(...args: Parameters): Promise { + return await this.delegate.queryLocked(...args) + } + + async transaction(operation: (transaction: RelayDatabase) => Promise): Promise { + return await this.delegate.transaction(operation) + } + + async close(): Promise { + await this.delegate.close() + } +} + +function scopedUrl(): string { + const url = new URL(databaseUrl!) + url.searchParams.set('options', `-c search_path=${schema}`) + return url.toString() +} + +async function onAdmin(sql: string): Promise { + const client = new pg.Client({ connectionString: databaseUrl }) + await client.connect() + try { + await client.query(sql) + } finally { + await client.end() + } +} + +const dialects: [string, () => Promise][] = [ + ['sqlite', openInMemoryRelayDatabase], + ...(databaseUrl + ? [ + [ + 'postgres', + async () => { + await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + await onAdmin(`CREATE SCHEMA ${schema}`) + return await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' }) + } + ] as [string, () => Promise] + ] + : []) +] + +describe.each(dialects)('dead-cell evacuation pre-check (%s)', (_dialect, open) => { + let database: RelayDatabase | undefined + + afterEach(async () => { + await database?.close() + database = undefined + }) + + afterAll(async () => { + if (databaseUrl) await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + }) + + async function setup(now: () => number) { + database = await open() + const recording = new RecordingDatabase(database) + const store = new RelayAssignmentStore(recording, now, { + requireLiveCells: true, + heartbeatTtlMs: 45_000 + }) + await store.reconcileCells(CELLS) + return { store, recording } + } + + async function heartbeat(store: RelayAssignmentStore, cell: RelayCellConfig): Promise { + await store.recordCellHeartbeat({ + cellId: cell.id, + cellUrl: cell.url, + cellIncarnation: '11111111-1111-4111-8111-111111111111', + startedAt: 50, + ready: true, + observedRequests: 0 + }) + } + + it('skips the host query when the only dead cell is unfenced and existing-only', async () => { + let now = 100 + const { store, recording } = await setup(() => now) + for (const cell of CELLS) await heartbeat(store, cell) + const identity = { userId: 'user-a', relayHostId: 'host000000000001' } + await store.setCellEnabled('cell-b', false) + expect(await store.assign(identity)).toMatchObject({ cellId: 'cell-a' }) + await store.setCellEnabled('cell-b', true) + await store.setCellEnabled('cell-a', false) + now += 45_001 + await heartbeat(store, CELLS[1]!) + recording.statements.length = 0 + + expect(await store.evacuateDeadCells()).toBe(0) + expect(recording.statements.some((sql) => sql.includes(HOST_QUERY))).toBe(false) + expect( + await database!.query(`SELECT cell_id FROM relay_assignments WHERE user_id = ?`, [ + identity.userId + ]) + ).toEqual([{ cell_id: 'cell-a' }]) + }) + + it('still evacuates hosts from a dead uncapped cell that admits', async () => { + let now = 100 + const { store, recording } = await setup(() => now) + for (const cell of CELLS) await heartbeat(store, cell) + const identity = { userId: 'user-a', relayHostId: 'host000000000001' } + await store.setCellEnabled('cell-b', false) + expect(await store.assign(identity)).toMatchObject({ cellId: 'cell-a' }) + await store.setCellEnabled('cell-b', true) + now += 45_001 + await heartbeat(store, CELLS[1]!) + recording.statements.length = 0 + + expect(await store.evacuateDeadCells()).toBe(1) + expect(recording.statements.some((sql) => sql.includes(HOST_QUERY))).toBe(true) + expect((await store.resolve(identity))?.cellId).toBe('cell-b') + }) +}) diff --git a/cloud/apps/relay/src/dead-relay-tables-postgres.test.ts b/cloud/apps/relay/src/dead-relay-tables-postgres.test.ts new file mode 100644 index 00000000000..101570c782e --- /dev/null +++ b/cloud/apps/relay/src/dead-relay-tables-postgres.test.ts @@ -0,0 +1,83 @@ +import pg from 'pg' +import { afterAll, describe, expect, it } from 'vitest' +import { openRelayDatabase } from './database.js' + +// Three tables were created at every boot and never written. The schema stops creating them, and +// a database that already has them must still boot, because they stay until a separate drop. +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip +const schema = 'relay_dead_tables_test' +const DEAD_TABLES = [ + 'relay_confirmable_splices', + 'relay_cell_drain_attempts', + 'relay_migration_leases' +] + +function scopedUrl(): string { + const url = new URL(databaseUrl!) + url.searchParams.set('options', `-c search_path=${schema}`) + return url.toString() +} + +async function onScoped(operation: (client: pg.Client) => Promise): Promise { + const client = new pg.Client({ connectionString: scopedUrl() }) + await client.connect() + try { + return await operation(client) + } finally { + await client.end() + } +} + +async function resetSchema(): Promise { + const client = new pg.Client({ connectionString: databaseUrl }) + await client.connect() + try { + await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + await client.query(`CREATE SCHEMA ${schema}`) + } finally { + await client.end() + } +} + +async function existingDeadTables(): Promise { + return await onScoped(async (client) => { + const rows = await client.query( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = $1 AND table_name = ANY($2) ORDER BY table_name`, + [schema, DEAD_TABLES] + ) + return rows.rows.map((row) => String(row.table_name)) + }) +} + +describePostgres('removed relay tables against PostgreSQL', () => { + afterAll(async () => { + const client = new pg.Client({ connectionString: databaseUrl }) + await client.connect() + await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + await client.end() + }) + + it('does not create the removed tables on a fresh database', async () => { + await resetSchema() + const database = await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' }) + await database.close() + + expect(await existingDeadTables()).toEqual([]) + }) + + it('boots on a database an older image created, leaving those tables alone', async () => { + await resetSchema() + await onScoped(async (client) => { + await client.query(`CREATE TABLE relay_confirmable_splices (basis_conn_id TEXT PRIMARY KEY)`) + await client.query(`CREATE TABLE relay_cell_drain_attempts (cell_id TEXT PRIMARY KEY)`) + await client.query(`CREATE TABLE relay_migration_leases (user_id TEXT NOT NULL)`) + }) + + const database = await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' }) + await database.close() + + expect(await existingDeadTables()).toEqual([...DEAD_TABLES].sort()) + }) +}) diff --git a/cloud/apps/relay/src/heap-window-reaper.ts b/cloud/apps/relay/src/heap-window-reaper.ts new file mode 100644 index 00000000000..3e7a1dd74cf --- /dev/null +++ b/cloud/apps/relay/src/heap-window-reaper.ts @@ -0,0 +1,90 @@ +import { performance } from 'node:perf_hooks' +import type { RelayDatabase, SqlRow } from './database.js' + +export type HeapWindowReapBudget = { + // Pages one DELETE may visit, so its row locks and WAL stay a few hundred rows. + pagesPerStatement: number + // Pages one tick may visit while it finds nothing to delete. + maxPagesPerTick: number + // Rows one tick deletes before it stops; this is what paces a backlog over days. + maxRowsPerTick: number + budgetMs: number +} + +// Why a TID range and not `WHERE LIMIT n`: these tables have no index on their +// retention column, so the planner answers LIMIT with a sequential scan from page 0 (production +// EXPLAIN, 2026-10-04). That scan gets longer every tick as the reaped head of the heap empties. +// A TID range bounds each statement to its own pages whatever the table holds. +export class HeapWindowReaper { + private nextPage: number | undefined + + constructor( + private readonly table: string, + private readonly predicate: string, + private readonly budget: HeapWindowReapBudget, + private readonly random: () => number = Math.random, + private readonly clock: () => number = () => performance.now() + ) {} + + async reap(database: RelayDatabase, params: unknown[]): Promise { + if (database.dialect !== 'postgres') { + return changes( + await database.query( + `DELETE FROM ${this.table} WHERE rowid IN ( + SELECT rowid FROM ${this.table} WHERE ${this.predicate} + LIMIT ${this.budget.maxRowsPerTick} + )`, + params + ) + ) + } + const pages = await heapPages(database, this.table) + if (pages === 0) return 0 + // A random first page: every director runs this sweep, and walks that all start at page 0 + // after a rollout would read the same pages in lockstep. + let page = this.nextPage ?? Math.floor(this.random() * pages) + const startedAt = this.clock() + let scanned = 0 + let deleted = 0 + while ( + scanned < this.budget.maxPagesPerTick && + deleted < this.budget.maxRowsPerTick && + this.clock() - startedAt < this.budget.budgetMs + ) { + if (page >= pages) page = 0 + const end = Math.min(page + this.budget.pagesPerStatement, pages) + // SKIP LOCKED: a row some request holds is left for a later pass rather than waited on. + // `= ANY(ARRAY(...))`, not `IN (...)`: IN can plan as a hash join over a sequential scan of + // the whole table; an array of TIDs is always a TID scan. + deleted += changes( + await database.query( + `DELETE FROM ${this.table} WHERE ctid = ANY(ARRAY( + SELECT ctid FROM ${this.table} + WHERE ctid >= CAST(? AS tid) AND ctid < CAST(? AS tid) AND ${this.predicate} + FOR UPDATE SKIP LOCKED + ))`, + [`(${page},0)`, `(${end},0)`, ...params] + ) + ) + scanned += end - page + page = end + } + this.nextPage = page + return deleted + } +} + +async function heapPages(database: RelayDatabase, table: string): Promise { + const row = ( + await database.query( + `SELECT pg_relation_size(CAST(? AS regclass)) / current_setting('block_size')::bigint + AS pages`, + [table] + ) + )[0] + return Number(row?.pages ?? 0) +} + +function changes(rows: SqlRow[]): number { + return Number(rows[0]?.changes ?? 0) +} diff --git a/cloud/apps/relay/src/postgres-drain-send-locking.test.ts b/cloud/apps/relay/src/postgres-drain-send-locking.test.ts index 024bf46db5e..26eec5013b0 100644 --- a/cloud/apps/relay/src/postgres-drain-send-locking.test.ts +++ b/cloud/apps/relay/src/postgres-drain-send-locking.test.ts @@ -42,9 +42,6 @@ describePostgres('PostgreSQL drain-send locking', () => { `DELETE FROM relay_control_connection_reservations WHERE user_id = ?`, [identity.userId] ) - await database.query(`DELETE FROM relay_migration_leases WHERE user_id = ?`, [ - identity.userId - ]) await database.query(`DELETE FROM relay_assignment_activity_leases WHERE user_id = ?`, [ identity.userId ]) diff --git a/cloud/apps/relay/src/released-reservation-prune.test.ts b/cloud/apps/relay/src/released-reservation-prune.test.ts new file mode 100644 index 00000000000..9dc81c32391 --- /dev/null +++ b/cloud/apps/relay/src/released-reservation-prune.test.ts @@ -0,0 +1,236 @@ +import pg from 'pg' +import { afterAll, afterEach, describe, expect, it } from 'vitest' +import { + RelayAssignmentStore, + RELEASED_CONTROL_RESERVATION_RETENTION_MS +} from './assignment-store.js' +import { + openInMemoryRelayDatabase, + openRelayDatabase, + type RelayDatabase +} from './database.js' +import { HeapWindowReaper } from './heap-window-reaper.js' + +// Released reservations were never deleted: 13.7M rows and 9 GB in production, every one of them +// still locked by each placement of its host. These pin what the prune may take and how it walks. +const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL +const describePostgres = databaseUrl ? describe : describe.skip +const schema = 'relay_released_reservation_prune_test' +const NOW = 100 * 24 * 60 * 60 * 1_000 +const STALE = NOW - RELEASED_CONTROL_RESERVATION_RETENTION_MS +const PREDICATE = `state = 'released' AND released_at <= ?` + +function scopedUrl(): string { + const url = new URL(databaseUrl!) + url.searchParams.set('options', `-c search_path=${schema}`) + return url.toString() +} + +async function onAdmin(sql: string): Promise { + const client = new pg.Client({ connectionString: databaseUrl }) + await client.connect() + try { + await client.query(sql) + } finally { + await client.end() + } +} + +async function openPostgres(): Promise { + await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + await onAdmin(`CREATE SCHEMA ${schema}`) + return await openRelayDatabase({ databaseUrl: scopedUrl(), dataDir: '' }) +} + +async function insertReservation( + database: RelayDatabase, + id: string, + state: string, + releasedAt: number | null +): Promise { + await database.query( + `INSERT INTO relay_control_connection_reservations + (reservation_id, idempotency_key, user_id, relay_host_id, assignment_epoch, + cell_id, state, created_at, timeout_at, released_at, updated_at) + VALUES (?, ?, 'user-1', 'host000000000001', 1, 'cell-a', ?, 1, 1, ?, 1)`, + [id, id, state, releasedAt] + ) +} + +async function remainingIds(database: RelayDatabase): Promise { + const rows = await database.query( + `SELECT reservation_id FROM relay_control_connection_reservations ORDER BY reservation_id` + ) + return rows.map((row) => String(row.reservation_id)) +} + +const dialects: [string, () => Promise][] = [ + ['sqlite', openInMemoryRelayDatabase], + ...(databaseUrl ? [['postgres', openPostgres] as [string, () => Promise]] : []) +] + +describe.each(dialects)('released reservation prune (%s)', (_dialect, open) => { + let database: RelayDatabase | undefined + + afterEach(async () => { + await database?.close() + database = undefined + }) + + afterAll(async () => { + if (databaseUrl) await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + }) + + it('deletes only released rows older than the retention window', async () => { + database = await open() + await insertReservation(database, 'a-old-released', 'released', STALE - 1) + await insertReservation(database, 'b-edge-released', 'released', STALE) + await insertReservation(database, 'c-fresh-released', 'released', STALE + 1) + await insertReservation(database, 'd-reserved', 'reserved', null) + await insertReservation(database, 'e-debt', 'late-arrival-debt', null) + await insertReservation(database, 'f-claimed', 'claimed', null) + // A row that once was released and came back is judged by its state, not its timestamp. + await insertReservation(database, 'g-claimed-old-release', 'claimed', STALE - 1) + const store = new RelayAssignmentStore(database, () => NOW) + + expect(await store.pruneReleasedControlReservations()).toBe(2) + expect(await remainingIds(database)).toEqual([ + 'c-fresh-released', + 'd-reserved', + 'e-debt', + 'f-claimed', + 'g-claimed-old-release' + ]) + }) +}) + +describePostgres('heap window reaper against PostgreSQL', () => { + let database: RelayDatabase + // 200 rows of this shape fill about two pages; 6,000 spread the table over ~60 pages. + const ROWS = 6_000 + + afterEach(async () => { + await database?.close() + }) + + afterAll(async () => { + await onAdmin(`DROP SCHEMA IF EXISTS ${schema} CASCADE`) + }) + + async function seed(): Promise { + database = await openPostgres() + // Every third row is still live, so each page holds rows the walk must keep. + await database.query( + `INSERT INTO relay_control_connection_reservations + (reservation_id, idempotency_key, user_id, relay_host_id, assignment_epoch, + cell_id, state, created_at, timeout_at, released_at, updated_at) + SELECT 'r-' || lpad(n::text, 6, '0'), 'r-' || n, 'user-1', 'host000000000001', 1, + 'cell-a', CASE WHEN n % 3 = 0 THEN 'claimed' ELSE 'released' END, + 1, 1, CASE WHEN n % 3 = 0 THEN NULL ELSE 1 END, 1 + FROM generate_series(1, ?) AS n`, + [ROWS] + ) + const pages = ( + await database.query( + `SELECT pg_relation_size('relay_control_connection_reservations') / 8192 AS pages` + ) + )[0]! + return Number(pages.pages) + } + + it('stops each tick at its row cap and drains the backlog over later ticks', async () => { + const pages = await seed() + expect(pages).toBeGreaterThan(20) + const reaper = new HeapWindowReaper( + 'relay_control_connection_reservations', + PREDICATE, + { pagesPerStatement: 2, maxPagesPerTick: 1_000, maxRowsPerTick: 300, budgetMs: 60_000 }, + () => 0.5 + ) + + const first = await reaper.reap(database, [NOW]) + // One statement past the cap at most: two pages of this shape hold well under 300 rows. + expect(first).toBeGreaterThanOrEqual(300) + expect(first).toBeLessThan(600) + + let ticks = 1 + while ((await reaper.reap(database, [NOW])) > 0) ticks += 1 + expect(ticks).toBeGreaterThan(5) + const left = await database.query( + `SELECT state, COUNT(*) AS rows FROM relay_control_connection_reservations GROUP BY state` + ) + expect(left).toEqual([{ state: 'claimed', rows: String(ROWS / 3) }]) + }) + + it('wraps from the end of the heap back to its first page', async () => { + const pages = await seed() + // Starts on the last page, so every other page is reached only after the wrap. + const reaper = new HeapWindowReaper( + 'relay_control_connection_reservations', + PREDICATE, + { pagesPerStatement: 4, maxPagesPerTick: pages + 4, maxRowsPerTick: 1_000_000, budgetMs: 60_000 }, + () => (pages - 1) / pages + ) + + await reaper.reap(database, [NOW]) + + expect( + await database.query( + `SELECT COUNT(*) AS rows FROM relay_control_connection_reservations + WHERE state = 'released'` + ) + ).toEqual([{ rows: '0' }]) + }) + + it('skips a row a request holds instead of waiting for it', async () => { + await seed() + const holder = new pg.Client({ connectionString: scopedUrl() }) + await holder.connect() + try { + await holder.query('BEGIN') + await holder.query( + `SELECT reservation_id FROM relay_control_connection_reservations + WHERE reservation_id = 'r-000001' FOR UPDATE` + ) + const reaper = new HeapWindowReaper( + 'relay_control_connection_reservations', + PREDICATE, + { pagesPerStatement: 1_000, maxPagesPerTick: 1_000, maxRowsPerTick: 1_000_000, budgetMs: 60_000 }, + () => 0 + ) + + // The pool's lock_timeout would fail this statement if it waited on the held row. + expect(await reaper.reap(database, [NOW])).toBe((ROWS * 2) / 3 - 1) + expect( + await database.query( + `SELECT reservation_id FROM relay_control_connection_reservations + WHERE state = 'released'` + ) + ).toEqual([{ reservation_id: 'r-000001' }]) + } finally { + await holder.query('ROLLBACK') + await holder.end() + } + }) + + it('plans each statement as a TID range scan, never a sequential scan', async () => { + await seed() + await database.query('ANALYZE relay_control_connection_reservations') + const client = new pg.Client({ connectionString: scopedUrl() }) + await client.connect() + try { + const plan = await client.query( + `EXPLAIN DELETE FROM relay_control_connection_reservations WHERE ctid = ANY(ARRAY( + SELECT ctid FROM relay_control_connection_reservations + WHERE ctid >= CAST($1 AS tid) AND ctid < CAST($2 AS tid) AND ${PREDICATE.replace('?', '$3')} + FOR UPDATE SKIP LOCKED))`, + ['(0,0)', '(16,0)', NOW] + ) + const text = plan.rows.map((row) => String(row['QUERY PLAN'])).join('\n') + expect(text).toContain('Tid Range Scan') + expect(text).not.toContain('Seq Scan') + } finally { + await client.end() + } + }) +}) diff --git a/cloud/dev/scripts/operate-relay-asia-admission.mjs b/cloud/dev/scripts/operate-relay-asia-admission.mjs index d5736db3ed2..c79323a9b39 100644 --- a/cloud/dev/scripts/operate-relay-asia-admission.mjs +++ b/cloud/dev/scripts/operate-relay-asia-admission.mjs @@ -21,7 +21,7 @@ const SHAPES = { domain: 'relay.onorca.dev', allCells: [ 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31', 'production-gce-c32', 'production-gce-c33' + 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', 'production-gce-c34' ], // The launch set was registered together; each later cell registers alone beside it. registrationWaves: [ @@ -29,8 +29,10 @@ const SHAPES = { ['production-gce-c30'], ['production-gce-c31'], ['production-gce-c32'], - ['production-gce-c33'] + ['production-gce-c33'], + ['production-gce-c34'] ], + // C34 is a migration-only spare: no promotion wave until a reviewed change adds one. promotionWaves: [ ['production-gce-c27'], ['production-gce-c28', 'production-gce-c29'], diff --git a/cloud/dev/scripts/operate-relay-asia-admission.test.mjs b/cloud/dev/scripts/operate-relay-asia-admission.test.mjs index ac5aa89ba9f..a1b8eb3853c 100644 --- a/cloud/dev/scripts/operate-relay-asia-admission.test.mjs +++ b/cloud/dev/scripts/operate-relay-asia-admission.test.mjs @@ -553,11 +553,13 @@ test('accepts only reviewed Asia admission waves', () => { ['rollback', 'production-gce-c30'], ['rollback', 'production-gce-c31'], ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29'], - ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33'], + ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33,production-gce-c34'], ...['production-gce-c32', 'production-gce-c33'].flatMap((cellId) => [ 'inspect', 'verify', 'register', 'registered', 'promote', 'recover-promotion', 'rollback' ].map((mode) => [mode, cellId])), - ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33'] + ...['inspect', 'verify', 'register', 'registered', 'rollback'] + .map((mode) => [mode, 'production-gce-c34']), + ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33,production-gce-c34'] ] for (const [mode, cellIds] of accepted) { assert.deepEqual( @@ -586,7 +588,12 @@ test('accepts only reviewed Asia admission waves', () => { ['promote', 'production-gce-c27,production-gce-c30'], ['promote', 'production-gce-c28,production-gce-c29,production-gce-c30'], ['promote', 'production-gce-c30,production-gce-c31'], + // The C34 spare stays migration-only: no reviewed promotion wave names it. ['promote', 'production-gce-c34'], + ['recover-promotion', 'production-gce-c34'], + ['register', 'production-gce-c33,production-gce-c34'], + ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33'], + ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33'], ['rollback', 'production-gce-c27,production-gce-c30'], ['rollback', 'production-gce-c30,production-gce-c31'], ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'], @@ -648,6 +655,29 @@ test('registers C31 alone beside the general C27-C30', async () => { assert.deepEqual(subject.selector().membership.general, general) }) +test('registers the C34 spare alone as migration-only in asia-east2', async () => { + const general = [...launchCells, 'production-gce-c30', 'production-gce-c31'] + const subject = harness({ + generation: 17, + membership: { + existingOnly: [], + migrationOnly: [], + general: [...general, 'production-gce-c32', 'production-gce-c33'] + } + }) + const result = await operateRelayAsiaAdmission({ + environment: 'production', mode: 'register', cells: ['production-gce-c34'], + expectedGeneration: 17, imageDigest: digest, attemptId: 'asia_register_c34', token: 'not-logged' + }, subject) + const request = subject.requests.find(({ path }) => path.endsWith('/add-migration-cells')) + assert.deepEqual(request.body.cells, [{ + cellId: 'production-gce-c34', cellUrl: 'https://c34.relay.onorca.dev', region: 'asia-east2', + capacityRequests: 6_000, connectionHardCap: 3_000, connectionUnobservedBound: 60 + }]) + assert.deepEqual(result.states, { 'production-gce-c34': 'migration-only' }) + assert.equal(subject.selector().membership.general.includes('production-gce-c34'), false) +}) + const usRegions = { 'production-gce-c32': 'us-central1', 'production-gce-c33': 'us-central1' } test('registers C32 and C33 one at a time in us-central1 at the Asia shape', async () => { diff --git a/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs b/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs index 5d00dbacf1a..6bbb65c50ce 100644 --- a/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs +++ b/cloud/dev/scripts/prepare-relay-asia-topology-input.mjs @@ -20,7 +20,8 @@ const SHAPES = { 'production-gce-c30': 'asia-east2-a', 'production-gce-c31': 'asia-east2-b', 'production-gce-c32': 'us-central1-a', - 'production-gce-c33': 'us-central1-b' + 'production-gce-c33': 'us-central1-b', + 'production-gce-c34': 'asia-east2-c' }, // The launch set, then each later additive cell; a plan targets one wave, never live cells. waves: [ @@ -28,7 +29,8 @@ const SHAPES = { ['production-gce-c30'], ['production-gce-c31'], // Declared together, so they plan together: a lone C32 plan would hit C33's missing template. - ['production-gce-c32', 'production-gce-c33'] + ['production-gce-c32', 'production-gce-c33'], + ['production-gce-c34'] ] } } diff --git a/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs b/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs index 0790560a318..906c8bb1ace 100644 --- a/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs +++ b/cloud/dev/scripts/prepare-relay-asia-topology-input.test.mjs @@ -14,7 +14,8 @@ const productionCells = () => Object.fromEntries([ [30, 'asia-east2-a'], [31, 'asia-east2-b'], [32, 'us-central1-a'], - [33, 'us-central1-b'] + [33, 'us-central1-b'], + [34, 'asia-east2-c'] ].map(([ordinal, zone]) => [`production-gce-c${ordinal}`, { hostname: `c${ordinal}`, region: zone.slice(0, -2), zone, machine_type: 'e2-standard-4', boot_disk_gb: 30, @@ -57,6 +58,14 @@ test('accepts the additive C31 wave in the next zone of the rotation', () => { assert.equal(result.relay_gce_cells['production-gce-c31'].zone, 'asia-east2-b') }) +test('accepts the additive C34 spare wave in asia-east2-c at the Asia pool', () => { + const result = prepareRelayAsiaTopologyInput({ existingCells: productionCells(), + existingAdditionalRegions: additionalRegions, environment: 'production', + cellIds: 'production-gce-c34', image }) + assert.equal(result.relay_gce_cells['production-gce-c34'].zone, 'asia-east2-c') + assert.equal(result.relay_gce_cells['production-gce-c34'].database_pool_max, 16) +}) + test('accepts the additive US C32+C33 wave at the default pool only', () => { const cellIds = 'production-gce-c32,production-gce-c33' for (const [cellId, zone] of [ @@ -103,7 +112,8 @@ test('matches every committed production Asia cell entry', () => { 'production-gce-c27,production-gce-c28,production-gce-c29', 'production-gce-c30', 'production-gce-c31', - 'production-gce-c32,production-gce-c33' + 'production-gce-c32,production-gce-c33', + 'production-gce-c34' ]) { const committedImage = committed[wave.split(',')[0]].image assert.doesNotThrow(() => prepareRelayAsiaTopologyInput({ @@ -116,8 +126,8 @@ test('matches every committed production Asia cell entry', () => { environment: 'production', cellIds: 'production-gce-c30', image }), /differs from the reviewed topology/) - // The US cells launch on the newest digest, the one C31 launched on. - for (const cellId of ['production-gce-c32', 'production-gce-c33']) { + // The US cells and the C34 spare launch on the newest cell digest, the one C31 launched on. + for (const cellId of ['production-gce-c32', 'production-gce-c33', 'production-gce-c34']) { assert.equal(committed[cellId].image, committed['production-gce-c31'].image, cellId) } }) @@ -152,7 +162,8 @@ test('rejects an uncommitted subnet or cell, partial wave, wrong image, and drif 'production-gce-c30,production-gce-c31', 'production-gce-c32', 'production-gce-c33', - 'production-gce-c34' + 'production-gce-c33,production-gce-c34', + 'production-gce-c35' ]) { assert.throws(() => prepareRelayAsiaTopologyInput({ existingCells: productionCells(), existingAdditionalRegions: additionalRegions, diff --git a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs index b53dbe40d26..d643f2e2178 100644 --- a/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs +++ b/cloud/dev/scripts/prepare-relay-production-capacity-canary.test.mjs @@ -101,7 +101,9 @@ describe('production Relay capacity cell admission', () => { // Migration-only canaries: the US-only capacity rollout never touches them either. 'production-gce-c17', 'production-gce-c18', // US cells at the Asia shape: the 1,000-cap capacity rollout never touches them. - 'production-gce-c32', 'production-gce-c33' + 'production-gce-c32', 'production-gce-c33', + // The migration-only Asia spare. + 'production-gce-c34' ]) { const hostname = cellId.slice('production-gce-'.length) assert.deepEqual(parseProductionCapacityCellArguments([ @@ -118,7 +120,7 @@ describe('production Relay capacity cell admission', () => { paceWindowMs: 0 }) } - for (const cellId of ['production-gce-c12', 'production-gce-c34']) { + for (const cellId of ['production-gce-c12', 'production-gce-c35']) { const hostname = cellId.slice('production-gce-'.length) assert.throws(() => parseProductionCapacityCellArguments([ '--director-origin', 'https://relay.onorca.dev', diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.mjs index 32ac89e4e6d..79dddc7e921 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.mjs @@ -2,9 +2,9 @@ import { pathToFileURL } from 'node:url' import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs' // Every cell that carries the rehome identity: the eighteen US cells and the -// five asia-east2 cells that drain mis-homed hosts back the other way. +// six asia-east2 cells that drain mis-homed hosts back the other way. const PRODUCTION_CELL = - /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29|30|31|32|33)$/ + /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29|30|31|32|33|34)$/ const DIRECTOR_ORIGIN = 'https://relay.onorca.dev' export function parseRehomeTrustProbeArguments(argv, environment = process.env) { diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs index 932fa450112..c7d8b5e5d4b 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs @@ -116,7 +116,7 @@ test('fails when both trust-probe attempts return a transient 503', async () => test('approves the asia-east2 and US 3,000 rehome sources and still rejects unlisted cells', () => { for (const cellId of [ 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31', 'production-gce-c32', 'production-gce-c33' + 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', 'production-gce-c34' ]) { const parsed = parseRehomeTrustProbeArguments( argv.map((value) => (value === 'production-gce-c7' ? cellId : value)), @@ -124,7 +124,7 @@ test('approves the asia-east2 and US 3,000 rehome sources and still rejects unli ) assert.equal(parsed.cellId, cellId) } - for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c34']) { + for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c35']) { assert.throws( () => parseRehomeTrustProbeArguments( @@ -183,7 +183,7 @@ test('approves exactly the committed production rehome source cells', () => { const sources = new Set( [...tfvars.slice(start, tfvars.indexOf(']', start)).matchAll(/"([^"]+)"/g)].map(([, cell]) => cell) ) - assert.ok(sources.has('production-gce-c33')) + assert.ok(sources.has('production-gce-c34')) for (let ordinal = 1; ordinal <= 40; ordinal++) { const cellId = `production-gce-c${ordinal}` const approved = (() => { diff --git a/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs b/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs index 0a59d8d9939..8a6eba98b69 100644 --- a/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs +++ b/cloud/dev/scripts/relay-asia-topology-workflow.test.mjs @@ -50,7 +50,8 @@ test('accepts only the reviewed Asia topology waves', () => { 'production:production-gce-c27,production-gce-c28,production-gce-c29', 'production:production-gce-c30', 'production:production-gce-c31', - 'production:production-gce-c32,production-gce-c33' + 'production:production-gce-c32,production-gce-c33', + 'production:production-gce-c34' ] ) }) diff --git a/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs b/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs index bade8de8f52..bc68bf49c69 100644 --- a/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs +++ b/cloud/dev/scripts/relay-cloud-sql-connection-budget.test.mjs @@ -7,12 +7,12 @@ import { } from './relay-cloud-sql-connection-budget.mjs' test('production shared consumers keep allowance and reserve below the ceiling', () => { - // cells: 22 pools at 10 (220, C32/C33 included) + the five asia-east2 pools at 16 (80). + // cells: 22 pools at 10 (220, C32/C33 included) + the six asia-east2 pools at 16 (96). const report = readRelayCloudSqlConnectionBudget() - assert.deepEqual(report.consumers, { cells: 300, directors: 15, auth: 20, api: 50 }) - assert.deepEqual(report.asia, { cells: 5, poolMax: 16 }) - assert.equal(report.configuredMaximum, 385) + assert.deepEqual(report.consumers, { cells: 316, directors: 15, auth: 20, api: 50 }) + assert.deepEqual(report.asia, { cells: 6, poolMax: 16 }) + assert.equal(report.configuredMaximum, 401) assert.equal(report.rolloutOverlap.relayDirectorCandidate, 30) assert.equal(report.rolloutOverlap.apiCandidate, 65) assert.equal(report.rolloutOverlap.authCandidate, 35) @@ -22,10 +22,10 @@ test('production shared consumers keep allowance and reserve below the ceiling', assert.equal(report.maintenanceAdminAllowance, 5) assert.equal(report.explicitReserve, 10) assert.equal(report.usableCeiling, 490) - assert.equal(report.operatingMaximum, 455) - assert.equal(report.remainingWithinUsableCeiling, 35) - assert.equal(report.budgetedTotal, 465) - assert.equal(report.unallocated, 35) + assert.equal(report.operatingMaximum, 471) + assert.equal(report.remainingWithinUsableCeiling, 19) + assert.equal(report.budgetedTotal, 481) + assert.equal(report.unallocated, 19) assert.equal(report.withinBudget, true) }) diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.mjs index 8d13c761f54..7313ce627d6 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.mjs @@ -4,9 +4,9 @@ import { requireSameEvidenceCode } from './relay-evidence-code-provenance.mjs' // Migration-only by policy: zero hosts and no reservation, so a wave rolls one without // displacing anybody. It enters and must leave migration-only, never general. -// C32 and C33 stay here until each one's canary promotes it; that follow-up moves it to general. +// C34 is an Asia spare that stays migration-only by policy. export const SAME_CAP_MIGRATION_ONLY_CELLS = [ - 'production-gce-c17', 'production-gce-c18', 'production-gce-c32', 'production-gce-c33' + 'production-gce-c17', 'production-gce-c18', 'production-gce-c34' ] export const SAME_CAP_CELLS = [ @@ -15,7 +15,7 @@ export const SAME_CAP_CELLS = [ 'production-gce-c19', 'production-gce-c20', 'production-gce-c21', 'production-gce-c22', 'production-gce-c23', 'production-gce-c24', 'production-gce-c25', 'production-gce-c26', 'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30', - 'production-gce-c31', + 'production-gce-c31', 'production-gce-c32', 'production-gce-c33', ...SAME_CAP_MIGRATION_ONLY_CELLS ] diff --git a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs index 8aa4ffb4c4b..c8d5ccb4f2b 100644 --- a/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs +++ b/cloud/dev/scripts/relay-production-same-cap-wave.test.mjs @@ -63,7 +63,7 @@ test('requires one canary or a bounded reviewed batch', () => { rollbackDigest, confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c31` }).cells, ['production-gce-c31']) - for (const cellId of ['production-gce-c32', 'production-gce-c33']) { + for (const cellId of ['production-gce-c32', 'production-gce-c33', 'production-gce-c34']) { assert.deepEqual(validateSameCapWave({ mode: 'canary-apply', cellIds: cellId, @@ -74,10 +74,10 @@ test('requires one canary or a bounded reviewed batch', () => { } assert.throws(() => validateSameCapWave({ mode: 'canary-apply', - cellIds: 'production-gce-c34', + cellIds: 'production-gce-c35', targetDigest, rollbackDigest, - confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c34` + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c35` }), /cells/) }) @@ -123,12 +123,16 @@ test('the wave workflow chains exactly ten serial cell jobs', () => { assert.doesNotMatch(dispatch, /\n cell_11:/) }) -test('lists C32 and C33 as migration-only beside C17 and C18 until their canaries promote them', () => { - assert.deepEqual(SAME_CAP_MIGRATION_ONLY_CELLS, [ - 'production-gce-c17', 'production-gce-c18', 'production-gce-c32', 'production-gce-c33' - ]) - assert.equal(SAME_CAP_CELLS.includes('production-gce-c30'), true) - assert.equal(SAME_CAP_CELLS.includes('production-gce-c31'), true) +test('lists the C34 spare as migration-only beside C17 and C18, and C30-C33 as general', () => { + assert.deepEqual( + SAME_CAP_MIGRATION_ONLY_CELLS, + ['production-gce-c17', 'production-gce-c18', 'production-gce-c34'] + ) + for (const cellId of [ + 'production-gce-c30', 'production-gce-c31', 'production-gce-c32', 'production-gce-c33' + ]) { + assert.equal(SAME_CAP_CELLS.includes(cellId), true, cellId) + } }) test('rolls the migration-only cells but never mixes the two classes in one wave', () => { @@ -194,15 +198,28 @@ test('rolls the migration-only cells but never mixes the two classes in one wave confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${c31Mixed}`, canaryRunId: '42' }), /all general or all migration-only/) - // Until its canary promotes it, a same-cap restore must hand a US 3,000 cell back isolated. - assert.equal(entryAdmission('production-gce-c32'), 'migration-only') - const usUnpromoted = 'production-gce-c26,production-gce-c32' - assert.throws(() => validateSameCapWave({ + // C32 and C33 are general since their 2026-10-01 promotions: they roll beside US 1k cells, + // isolate and restore (delta 2), and never share a wave with C17/C18. + for (const cellId of ['production-gce-c32', 'production-gce-c33']) { + assert.equal(entryAdmission(cellId), 'general', cellId) + assert.equal(selectorWaveDelta(cellId), 2, cellId) + } + const usPromoted = 'production-gce-c26,production-gce-c32,production-gce-c33' + assert.deepEqual(validateSameCapWave({ mode: 'batch-apply', - cellIds: usUnpromoted, + cellIds: usPromoted, targetDigest, rollbackDigest, - confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${usUnpromoted}`, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${usPromoted}`, + canaryRunId: '42' + }).cells, ['production-gce-c26', 'production-gce-c32', 'production-gce-c33']) + const usMixed = 'production-gce-c32,production-gce-c17' + assert.throws(() => validateSameCapWave({ + mode: 'batch-apply', + cellIds: usMixed, + targetDigest, + rollbackDigest, + confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${usMixed}`, canaryRunId: '42' }), /all general or all migration-only/) // A mixed wave has no single selector delta for its later cells to offset from. diff --git a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs index 27c52c401ce..7f27742ce3f 100644 --- a/cloud/dev/scripts/relay-same-cap-script-census.test.mjs +++ b/cloud/dev/scripts/relay-same-cap-script-census.test.mjs @@ -263,7 +263,7 @@ describe('same-cap roll scripts accept every same-cap cell', () => { assert.equal(String(cellShape(cellId).cap), tfvarsHardCap(cellId), cellId) } assert.equal(resolveCellShape('production-gce-c12').status, 1) - assert.equal(resolveCellShape('production-gce-c34').status, 1) + assert.equal(resolveCellShape('production-gce-c35').status, 1) }) @@ -275,11 +275,10 @@ describe('same-cap roll scripts accept every same-cap cell', () => { const trusted = SAME_CAP_CELLS.filter((cell) => REHOME_SOURCE_CELLS.has(cell)) // Only a declared rehome source may roll at a trusted protocol at all; the job refuses // the rest before it plans, and the next test covers them at protocol 0. - // C32 and C33 are already rehome sources but stay migration-only until their canaries. - const unpromotedSources = ['production-gce-c32', 'production-gce-c33'] + // The C34 spare is a rehome source that stays migration-only. assert.deepEqual( SAME_CAP_CELLS.filter((cell) => !REHOME_SOURCE_CELLS.has(cell)), - SAME_CAP_MIGRATION_ONLY_CELLS.filter((cell) => !unpromotedSources.includes(cell)) + SAME_CAP_MIGRATION_ONLY_CELLS.filter((cell) => cell !== 'production-gce-c34') ) for (const [cellId, protocol] of trusted.flatMap((cell) => [[cell, 1], [cell, 3]])) { const { cap, pool } = cellShape(cellId) diff --git a/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs b/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs index 084960d396b..adcb7807fba 100644 --- a/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs +++ b/cloud/dev/scripts/validate-relay-asia-topology-plan.mjs @@ -24,14 +24,16 @@ const CELL_SHAPES = { 'production-gce-c30': 'asia-east2-a', 'production-gce-c31': 'asia-east2-b', 'production-gce-c32': 'us-central1-a', - 'production-gce-c33': 'us-central1-b' + 'production-gce-c33': 'us-central1-b', + 'production-gce-c34': 'asia-east2-c' }, waves: [ ['production-gce-c27', 'production-gce-c28', 'production-gce-c29'], ['production-gce-c30'], ['production-gce-c31'], // Declared together, so they plan together: a lone C32 plan would hit C33's missing template. - ['production-gce-c32', 'production-gce-c33'] + ['production-gce-c32', 'production-gce-c33'], + ['production-gce-c34'] ] }, staging: { diff --git a/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs b/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs index 0e9f6e0a6a2..a4838aba046 100644 --- a/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs +++ b/cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs @@ -161,6 +161,18 @@ test('accepts the additive production C31 wave only in asia-east2-b', () => { ) }) +test('accepts the additive production C34 spare wave only in asia-east2-c', () => { + const c34Config = { ...productionConfig, cells: ['production-gce-c34'] } + assert.deepEqual( + validateRelayAsiaTopologyPlan({ resource_changes: productionWavePlan('c34', 'asia-east2-c') }, c34Config), + { environment: 'production', cells: ['production-gce-c34'], changes: 4 } + ) + assert.throws( + () => validateRelayAsiaTopologyPlan({ resource_changes: productionWavePlan('c34') }, c34Config), + /fixed-one Asia MIG shape/ + ) +}) + // A US cell joins the root region: no additional-region network, no region label or line, and // the default pool emits no line, exactly as the startup template renders a root-region cell. function usCellPlan(hostname, zone) { @@ -269,7 +281,8 @@ test('accepts only a reviewed Asia topology wave', () => { 'production-gce-c27,production-gce-c28,production-gce-c29', 'production-gce-c29,production-gce-c27,production-gce-c28', 'production-gce-c30', - 'production-gce-c31' + 'production-gce-c31', + 'production-gce-c34' ]) { assert.doesNotThrow( () => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)), @@ -286,7 +299,8 @@ test('accepts only a reviewed Asia topology wave', () => { 'production-gce-c32', 'production-gce-c33', 'production-gce-c32,production-gce-c33,production-gce-c34', - 'production-gce-c34' + 'production-gce-c33,production-gce-c34', + 'production-gce-c35' ]) { assert.throws( () => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)), diff --git a/cloud/docs/relay-workflows.md b/cloud/docs/relay-workflows.md index b214389e6b2..0d2e501f1f1 100644 --- a/cloud/docs/relay-workflows.md +++ b/cloud/docs/relay-workflows.md @@ -215,7 +215,19 @@ promote while a same-cap restore has just returned an empty general US cell: the would land there and the canary would roll the new cell back. Both cells are declared rehome sources and sit in the same-cap migration-only list until each one's canary promotes it, then move to the general list. The shadow gate's fleet pool list tracks the 16-connection Asia pools, so -whether a US cell belongs there is decided at promotion, not assumed. +whether a US cell belongs there is decided at promotion, not assumed. Both were promoted to general +on 2026-10-01, so the same-cap job now rolls them as general cells. They stay out of the fleet pool +list because their pool is the US default of 10. + +C34 is an Asia spare at the C31 shape in `asia-east2-c`, so the six Asia cells spread 2/2/2. It is +its own topology wave and registers alone as migration-only, then the director is configured with +`cell-ids` set to C34. It has no promotion wave: the Asia admission script and workflow refuse +`promote` for it, and placement and regional rehome select only general cells. It is a +migration-only landing zone that only an explicit evacuation or migration naming it can target. +Do not name it in the multi-target `promote-general-cell` or `retire-migration-cell` modes, which +accept any migration-only cell. It is a declared rehome source, sits in the same-cap migration-only +list, and stays out of the fleet pool list. Promoting it later takes its own reviewed change adding a +promotion wave and canary entry. Rollback returns Asia cells to migration-only; it does not destroy the network or use existing-only. The production topology dispatch remains unavailable until the diff --git a/cloud/infra/terraform/README.md b/cloud/infra/terraform/README.md index cf447a88dd2..70001984c7b 100644 --- a/cloud/infra/terraform/README.md +++ b/cloud/infra/terraform/README.md @@ -332,7 +332,7 @@ cell templates/MIGs/backends, and exact shared URL-map host additions. It rejects deletes, replacements, loss of an existing host route, US-resource changes, and unrelated drift. Do not add production C27-C29 until the compatible image has been published and each entry can pin its immutable -digest. A later cell, such as C30 or C31, is its own reviewed wave. The shared URL map +digest. A later cell, such as C30, C31 or the C34 spare, is its own reviewed wave. The shared URL map pulls every live cell into its plan, so the workflow plans each live cell at the image its state template already serves, and the validator rejects any change to a cell outside the wave. US C32 and C33 use the same workflow at the same diff --git a/cloud/infra/terraform/environments/production.tfvars b/cloud/infra/terraform/environments/production.tfvars index baed00d496d..a3521914b20 100644 --- a/cloud/infra/terraform/environments/production.tfvars +++ b/cloud/infra/terraform/environments/production.tfvars @@ -450,6 +450,21 @@ relay_gce_cells = { connection_hard_cap = 3000 connection_unobserved_bound = 60 } + # Asia spare: registered migration-only as a drain landing zone; c completes the 2/2/2 zone spread. + "production-gce-c34" = { + hostname = "c34" + region = "asia-east2" + zone = "asia-east2-c" + machine_type = "e2-standard-4" + boot_disk_gb = 30 + boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21" + capacity_requests = 6000 + database_pool_max = 16 # 176 ms from us-central1 Postgres saturates 10 (94-156 waiters). + image = "us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@sha256:f30b5cb1ec52b6b6145efecfa1b8be9e3d309403beffd8abcc64197a2087e269" + initially_enabled = false + connection_hard_cap = 3000 + connection_unobserved_bound = 60 + } } relay_region_rehome_source_cell_ids = [ @@ -476,7 +491,8 @@ relay_region_rehome_source_cell_ids = [ "production-gce-c30", "production-gce-c31", "production-gce-c32", - "production-gce-c33" + "production-gce-c33", + "production-gce-c34" ] # Slack #orca-relay-alerts, created out of band on 2026-08-05. Declared here because an apply diff --git a/config/localization-audit.md b/config/localization-audit.md index 77a0e15bd57..1bb39bf2017 100644 --- a/config/localization-audit.md +++ b/config/localization-audit.md @@ -95,9 +95,9 @@ not invoke tools that can overwrite an entire target catalog. The coverage gate compares current candidates against `config/localization-coverage-allowlist.json`. The committed allowlist is -small (10 reviewed entries — one test fixture title, five non-English -language-name search keywords, and four reviewed product-name search -keywords): new candidates fail the check and must be localized or added with +small (11 reviewed entries — six non-English language-name search keywords, +four reviewed product-name search keywords, and one non-UI `label` placement +prop): new candidates fail the check and must be localized or added with a reviewed reason in the same change. The script scans `src/renderer/src` by default. That is the primary UI surface. diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index 0918ed3a513..93d984594af 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -1,36 +1,15 @@ [ { - "filePath": "src/renderer/src/components/automations/automations-page-fixtures.ts", - "kind": "object-property:title", - "text": "Nightly #1", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/automations/automations-page-fixtures.ts", - "kind": "object-property:label", - "text": "Hermes", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx", - "kind": "jsx-attribute:label", - "text": "sidebar", + "filePath": "src/renderer/src/components/settings/appearance-search.ts", + "kind": "object-property:keywords", + "text": "Idioma", "dynamic": false, "count": 1 }, { "filePath": "src/renderer/src/components/settings/appearance-search.ts", "kind": "object-property:keywords", - "text": "语言", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/appearance-search.ts", - "kind": "object-property:keywords", - "text": "語言", + "text": "Langue", "dynamic": false, "count": 1 }, @@ -51,24 +30,17 @@ { "filePath": "src/renderer/src/components/settings/appearance-search.ts", "kind": "object-property:keywords", - "text": "Idioma", + "text": "語言", "dynamic": false, "count": 1 }, { "filePath": "src/renderer/src/components/settings/appearance-search.ts", "kind": "object-property:keywords", - "text": "Langue", + "text": "语言", "dynamic": false, "count": 1 }, - { - "filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts", - "kind": "object-property:keywords", - "text": "Ghostty", - "dynamic": false, - "count": 2 - }, { "filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts", "kind": "object-property:keywords", @@ -77,11 +49,11 @@ "count": 2 }, { - "filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts", + "filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts", "kind": "object-property:keywords", "text": "Ghostty", "dynamic": false, - "count": 1 + "count": 2 }, { "filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts", @@ -91,317 +63,16 @@ "count": 1 }, { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:title", - "text": "Default shell", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:description", - "text": "Shell used for new terminal panes", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", + "filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts", "kind": "object-property:keywords", - "text": "shell", + "text": "Ghostty", "dynamic": false, "count": 1 }, { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "terminal", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "fish", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "zsh", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "bash", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "nushell", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "default", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-attribute:title", - "text": "Terminal shell", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-attribute:description", - "text": "Choose what Orca opens for new local terminal panes.", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-attribute:ariaLabel", - "text": "Terminal shell", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:label", - "text": "System shell (", - "dynamic": true, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:label", - "text": "Custom shell", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-attribute:placeholder", - "text": "fish, nu, or /bin/zsh", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-attribute:aria-label", - "text": "Custom shell executable", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-text", - "text": "Enter a shell name on PATH or an executable path. Orca starts it as a login shell.", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-text", - "text": ". Switch to System shell or choose an executable on this host.", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "arguments", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "args", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "login", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "wrapper", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:keywords", - "text": "rcfile", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-text", - "text": "Advanced", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-text", - "text": "Shell arguments", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-expression", - "text": "Starts the shell as a login shell with -l.", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-expression", - "text": "Enter one argument per line. Leave it empty to pass no arguments.", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-attribute:ariaLabel", - "text": "Shell argument mode", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:label", - "text": "-l (default)", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "object-property:label", - "text": "Custom args", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-attribute:placeholder", - "text": "--rcfile /path/to/rcfile", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/TerminalPane.tsx", - "kind": "jsx-attribute:aria-label", - "text": "Shell arguments, one per line", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:title", - "text": "Terminal shell", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:description", - "text": "Shell and arguments used for new local interactive terminal panes", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "shell", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "terminal", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "fish", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "zsh", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "bash", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "nushell", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "arguments", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "args", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "login", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "wrapper", - "dynamic": false, - "count": 1 - }, - { - "filePath": "src/renderer/src/components/settings/terminal-search.ts", - "kind": "object-property:keywords", - "text": "rcfile", + "filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx", + "kind": "jsx-attribute:label", + "text": "sidebar", "dynamic": false, "count": 1 } diff --git a/config/oxlint-anti-slop.json b/config/oxlint-anti-slop.json index d951695a339..3a38be8e4a0 100644 --- a/config/oxlint-anti-slop.json +++ b/config/oxlint-anti-slop.json @@ -65,6 +65,13 @@ "anti-slop/no-module-mocking": "off" } }, + // Test-only side effect for explicit RPC registries; real catalog tests never import it. + { + "files": ["**/src/main/runtime/rpc/unused-default-rpc-methods.test-fixture.ts"], + "rules": { + "anti-slop/no-module-mocking": "off" + } + }, // The exemptions below are file-scoped rather than inline `oxlint-disable` comments // because the root lint scan does not load this plugin, so an inline directive naming // an anti-slop rule always reads back as an unused directive there. diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index faf844b5137..642918a992c 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -948,7 +948,7 @@ "invariant": "One structured-send operation id causes at most one provider dispatch. A recorded or transport-ambiguous send reuses that id across retry, caller reconnect, client remount, and journal recovery; only a terminal rejection may rotate to a first delivery.", "oracle": "Inject adapter acknowledgement loss, RPC response loss, caller replacement, logical-client close after response, auth recovery with a written request, missing journal submissions, legacy pending rows, stale fences, operation expiry, mobile remount, and durable-journal capacity. Assert one provider dispatch or one operation id for every ambiguous retry, fresh identity only after rejection, and no eviction of ambiguous mobile ids.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/mobile-structured-send-operation-journal.test.ts ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts" ], "testFiles": [ @@ -963,6 +963,7 @@ "src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx", "src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx", "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx", + "src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx", "src/renderer/src/lib/launch-structured-agent-session.test.ts", "mobile/src/session/mobile-native-chat-image-attachment.test.ts", "mobile/src/session/use-mobile-native-chat-image-attachments.test.ts", @@ -1033,13 +1034,13 @@ ], "evidenceRuns": [ { - "date": "2026-09-12", + "date": "2026-10-04", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-disposition.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "result": "passed", - "durationSeconds": 22.1, - "summary": "Thirteen focused host, shared, renderer, and orchestration files passed 148 tests." + "durationSeconds": 42.3, + "summary": "Thirteen focused host, shared, renderer, and orchestration files passed 197 tests after the delivery probe tests moved to their own file." }, { "date": "2026-09-12", @@ -16958,15 +16959,38 @@ "invariant": "Hidden-output restore, snapshot replay, metadata-only replay, and clear-before-replay must preserve order and never overlay stale bytes on newer live terminal output; restoring a snapshot onto an already-dirty pane must yield a buffer exactly equal to the snapshot frame.", "oracle": "Apply snapshots onto adversarially dirty pane states (already on the alternate screen, stale content occupying cells the new frame leaves blank, scrollback present, wide glyphs, revived sessions with restarted PTY seq counters) and assert exact buffer equality with the snapshot frame; fault injection interleaves hidden chunks, live output, metadata-only replay, and clear-before-replay, then asserts ordered terminal buffer content, clear decisions, and replay diagnostics. Marker-presence checks are not acceptable evidence on restore paths.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts" ], "testFiles": [ "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts", "src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts", "src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts" + "src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts", + "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts", + "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts" ], "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts", + "assertions": [ + "repaints a pushed image exactly and keeps the history the TUI covers", + "keeps the pane's history when the host screen has another grid", + "keeps history unduplicated when a pushed image also carries history", + "paints the whole image when the TUI started while hidden", + "reads the pane buffer after a queued alt-screen entry parses", + "repaints from the normal buffer once the host TUI has exited", + "clears a raw byte replay in place on the alt screen", + "paints a requested image from an exited TUI exactly over an alt screen" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts", + "assertions": [ + "keeps the covered history for a live TUI exactly as the host holds it", + "repaints from the normal buffer once the host TUI has exited" + ] + }, { "file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts", "assertions": [ diff --git a/config/scripts/agent-status-hot-path-benchmark.test.ts b/config/scripts/agent-status-hot-path-benchmark.ts similarity index 88% rename from config/scripts/agent-status-hot-path-benchmark.test.ts rename to config/scripts/agent-status-hot-path-benchmark.ts index ece89b89d50..310de6723fb 100644 --- a/config/scripts/agent-status-hot-path-benchmark.test.ts +++ b/config/scripts/agent-status-hot-path-benchmark.ts @@ -8,10 +8,14 @@ * Counting passes patch `Map`/`Set`/`Object.assign`/`Object.values`, which deoptimizes them, so * counts and timings are taken in separate passes and never from the same run. * + * Run: ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.agent-status-benchmark.config.ts + * * Scale mirrors the reporting user rather than the 100-worktree fixture in * docs/reference/renderer-agent-status-performance.md: 423 worktrees, 634 terminal tabs. */ import { writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import type { AppState } from '@/store/types' import type { AgentStatusBatchUpdate } from '@/store/slices/agent-status' @@ -23,6 +27,7 @@ import { TEST_REPO } from '@/store/slices/store-test-helpers' import { makePaneKey } from '../../src/shared/stable-pane-id' +import { getDefaultSettings } from '../../src/shared/constants' import { createAgentStatusPaneRoutingIndex, resolvePaneKeyFromRoutingIndex @@ -39,25 +44,24 @@ const counters = { maps: 0, sets: 0, tabComparisons: 0 } const NativeMap = globalThis.Map const NativeSet = globalThis.Set -const nativeArrayIterator = Array.prototype[Symbol.iterator] function withAllocationCounting(run: () => T): T { class CountingMap extends NativeMap { - constructor(entries?: readonly (readonly [K, V])[] | null) { + constructor(entries?: Iterable | null) { super(entries) counters.maps += 1 } } class CountingSet extends NativeSet { - constructor(values?: readonly V[] | null) { + constructor(values?: Iterable | null) { super(values) counters.sets += 1 } } counters.maps = 0 counters.sets = 0 - globalThis.Map = CountingMap as unknown as MapConstructor - globalThis.Set = CountingSet as unknown as SetConstructor + globalThis.Map = CountingMap + globalThis.Set = CountingSet try { return run() } finally { @@ -68,34 +72,33 @@ function withAllocationCounting(run: () => T): T { /** Tab list whose iteration is observable, so the nested-loop resolver's comparisons are countable. */ class CountingTabList extends Array { - [Symbol.iterator](): IterableIterator { - const inner = nativeArrayIterator.call(this) as IterableIterator - const wrapped: IterableIterator = { - next: () => { - const result = inner.next() - if (!result.done) { - counters.tabComparisons += 1 - } - return result - }, - [Symbol.iterator]: () => wrapped + [Symbol.iterator](): ArrayIterator { + const iterator = super[Symbol.iterator]() + const next = iterator.next.bind(iterator) + iterator.next = (...args) => { + const result = next(...args) + if (!result.done) { + counters.tabComparisons += 1 + } + return result } - return wrapped + return iterator } } function buildFixture(countTabIteration: boolean) { const store = createTestStore() + const settings = store.getState().settings ?? getDefaultSettings(tmpdir()) const tabsByWorktree: AppState['tabsByWorktree'] = {} const unifiedTabsByWorktree: AppState['unifiedTabsByWorktree'] = {} - const worktrees = [] + const worktrees: ReturnType[] = [] const paneKeys: string[] = [] const owners: { tabId: string; worktreeId: string }[] = [] for (let index = 0; index < WORKTREES; index += 1) { const worktreeId = `wt-${index}` worktrees.push(makeWorktree({ id: worktreeId, repoId: TEST_REPO.id })) - const tabs = [] - const unified = [] + const tabs: ReturnType[] = [] + const unified: ReturnType[] = [] for (let tab = 0; tab < (index % 2 === 0 ? 1 : 2); tab += 1) { const tabId = `tab-${index}-${tab}` tabs.push(makeTab({ id: tabId, worktreeId, title: `Terminal ${index}-${tab}` })) @@ -110,9 +113,7 @@ function buildFixture(countTabIteration: boolean) { paneKeys.push(makePaneKey(tabId, LEAF_ID)) owners.push({ tabId, worktreeId }) } - tabsByWorktree[worktreeId] = countTabIteration - ? (CountingTabList.from(tabs) as unknown as typeof tabs) - : tabs + tabsByWorktree[worktreeId] = countTabIteration ? CountingTabList.from(tabs) : tabs unifiedTabsByWorktree[worktreeId] = unified } store.setState({ @@ -122,8 +123,8 @@ function buildFixture(countTabIteration: boolean) { unifiedTabsByWorktree, terminalLayoutsByTabId: {}, setGeneratedTabTitlesFromAgentPrompts: () => {}, - settings: { ...store.getState().settings, tabAutoGenerateTitle: false } - } as Partial) + settings: { ...settings, tabAutoGenerateTitle: false } + }) return { store, paneKeys, owners } } @@ -141,7 +142,7 @@ function per1k(value: number): number { function runIndexedRouting(store: ReturnType, paneKeys: string[]): void { for (let event = 0; event < EVENTS; event += 1) { if (event % BATCH_SIZE === 0) { - store.setState({ agentStatusEpoch: event } as Partial) + store.setState({ agentStatusEpoch: event }) } const index = createAgentStatusPaneRoutingIndex(store.getState()) resolvePaneKeyFromRoutingIndex(index, paneKeys[event % paneKeys.length]) @@ -287,7 +288,8 @@ describe('agent-status hot path benchmark', () => { } const outputPath = - process.env.ORCA_AGENT_STATUS_BENCH_OUTPUT ?? '/tmp/agent-status-hot-path-benchmark.json' + process.env.ORCA_AGENT_STATUS_BENCH_OUTPUT ?? + join(tmpdir(), 'agent-status-hot-path-benchmark.json') writeFileSync( outputPath, `${JSON.stringify({ worktrees: WORKTREES, events: EVENTS, report }, null, 2)}\n` diff --git a/config/scripts/check-root-directory-entries.test.mjs b/config/scripts/check-root-directory-entries.test.mjs index 6de07a053c7..57da7adbd95 100644 --- a/config/scripts/check-root-directory-entries.test.mjs +++ b/config/scripts/check-root-directory-entries.test.mjs @@ -114,6 +114,17 @@ describe('root directory guard', () => { expect(result.status).toBe(0) }) + it('allows the reviewed repository OpenCode permission config', () => { + const fixture = makeFixture() + const head = commitFiles(fixture.root, [ + ['opencode.json', '{"permission":{"*":{"*":"allow"}}}\n'] + ]) + + const result = runGuard({ ...fixture, head }) + + expect(result.status).toBe(0) + }) + it('rejects a new top-level directory', () => { const fixture = makeFixture() const head = commitFiles(fixture.root, [['new-folder/file.txt', 'too prominent\n']]) diff --git a/config/scripts/ci-background-step-barriers.test.mjs b/config/scripts/ci-background-step-barriers.test.mjs index e005c60b989..d8ac8fd1c1f 100644 --- a/config/scripts/ci-background-step-barriers.test.mjs +++ b/config/scripts/ci-background-step-barriers.test.mjs @@ -96,13 +96,13 @@ describe('CI background step barriers', () => { expect(steps.findIndex((step) => step.id === 'changed-code-quality')).toBeGreaterThan(install) }) - it('serializes mobile pnpm entrypoints before allocating test workers', () => { + it('joins independent mobile typechecks before allocating test workers', () => { const steps = mobile.jobs.verify.steps assertJoinedBefore(steps, 'production-types', (step) => step.name === 'Test') const ratchet = steps.findIndex((step) => step.name === 'Typecheck tests (ratchet)') const join = steps.findIndex((step) => step.wait === 'production-types') expect(steps[ratchet].background).toBeUndefined() - expect(ratchet).toBeGreaterThan(join) + expect(ratchet).toBeLessThan(join) }) it('waits for WebKit and the bundle before any browser tests', () => { diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 73d0226943b..34ba98f2566 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -5375,7 +5375,7 @@ "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-restore-signal-equivalence.test.tsx": 552, "src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-scrollbar-drag.test.ts": 59, "src/renderer/src/components/editor/conflict-review-file-tree-windowing.test.tsx": 2238, - "src/renderer/src/components/editor/csv-parse.test.ts": 122, + "src/renderer/src/components/editor/csv/csv-parse.test.ts": 122, "src/renderer/src/components/editor/details-markdown-html.test.ts": 99, "src/renderer/src/components/editor/diff-editor-hide-unchanged-options.test.ts": 35, "src/renderer/src/components/editor/diff-editor-line-number-options.test.ts": 48, diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index b0f99275b43..6f4948712dd 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -2342,9 +2342,6 @@ "auto.components.settings.MobileEmulatorSettingsPane.ae1612c58c": { "ko": "가용성" }, - "auto.components.settings.MobilePane.35100bca5d": { - "ko": "휴대폰에서 terminals을 사용하는 동안 Orca는 휴대폰 화면에 맞게 terminals을 축소합니다. 앱을 닫거나 다른 곳으로 전환하면 terminals이 휴대폰 크기로 유지되는지(대화형 CLI 도구가 리플로우되지 않음) 또는 데스크탑으로 다시 크기가 조정되는지 여부가 제어됩니다. 배너에서 '이 terminals 복원' 또는 '모든 terminals 복원'을 사용해 언제든지 수동으로 크기를 조정할 수 있습니다." - }, "auto.components.settings.MobileSettingsPane.b0088412a1": { "ko": "또는 다음에서 Android APK를 받으세요" }, @@ -4886,12 +4883,6 @@ "auto.components.terminal.pane.CloseTerminalDialog.stop_command_title": { "ko": "실행 중인 명령을 중지할까요?" }, - "auto.components.terminal.pane.MobileDriverOverlay.54f7d6f69d": { - "ko": "모든 terminals 제어권 가져오기" - }, - "auto.components.terminal.pane.MobileDriverOverlay.faa367dc74": { - "ko": "휴대폰이 이 화면을 휴대폰 크기로 남겨 두었습니다" - }, "auto.components.terminal.pane.TerminalContextMenu.c2f0b72b8d": { "ko": "삽입" }, diff --git a/config/scripts/mobile-typecheck-workflow.test.mjs b/config/scripts/mobile-typecheck-workflow.test.mjs index 0ab57ad858f..f234b538df5 100644 --- a/config/scripts/mobile-typecheck-workflow.test.mjs +++ b/config/scripts/mobile-typecheck-workflow.test.mjs @@ -23,7 +23,7 @@ describe('mobile verification command ownership', () => { } }) - it('finishes installation and joins production types before checking test types', () => { + it('finishes installation and joins both independent typechecks before tests', () => { const installIndex = steps.findIndex((step) => step.name === 'Install dependencies') const productionIndex = steps.findIndex((step) => step.name === 'Typecheck') const ratchetIndex = steps.findIndex((step) => step.name === 'Typecheck tests (ratchet)') @@ -36,7 +36,10 @@ describe('mobile verification command ownership', () => { expect(steps[productionIndex].background).toBe(true) expect(productionIndex).toBeLessThan(ratchetIndex) expect(waitIndex).toBeGreaterThan(productionIndex) - expect(waitIndex).toBeLessThan(ratchetIndex) - expect(ratchetIndex).toBeLessThan(testIndex) + expect(ratchetIndex).toBeLessThan(waitIndex) + expect(waitIndex).toBeLessThan(testIndex) + expect(steps[waitIndex].if).toBeUndefined() + expect(steps[productionIndex]['continue-on-error'] ?? false).toBe(false) + expect(steps[ratchetIndex]['continue-on-error'] ?? false).toBe(false) }) }) diff --git a/config/scripts/mobile-web-app-working-spinner-render.test.mjs b/config/scripts/mobile-web-app-working-spinner-render.test.mjs new file mode 100644 index 00000000000..3ac7cdd2e37 --- /dev/null +++ b/config/scripts/mobile-web-app-working-spinner-render.test.mjs @@ -0,0 +1,145 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { chromium } from 'playwright-core' +import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import { MOBILE_WEB_APP_ROUTE_ROOT } from './mobile-web-app-route-manifest.mjs' +import { + createBundleServer, + installShellDouble, + projectDir, + readBridgeFaultGrant, + readBridgeProtocolVersion, + readShellCsp +} from './mobile-web-app-render-harness.mjs' +import { LAYOUT_SOURCE } from './mobile-web-app-terminal-probe-route.mjs' + +/** + * The working rings keep turning on the page. Each is a one-second rotation loop; a loop that + * stops after its first turn reads `rotate(360deg)` from then on. + */ + +const ROUTE = `/${MOBILE_WEB_APP_ROUTE_ROOT}/working-spinner-probe` +const SAMPLE_SECONDS = [1.3, 1.8, 2.4] +const bundles = mobileWebAppDependenciesPresent() +const describeRender = bundles ? describe : describe.skip + +function probeRouteSource({ spinnerModule, dotModule }) { + return `import { View } from 'react-native' +import { AgentSpinner } from ${JSON.stringify(spinnerModule)} +import { AgentStateDot } from ${JSON.stringify(dotModule)} + +export default function WorkingSpinnerProbeRoute() { + globalThis.__orcaWorkingSpinnerProbe = true + return ( + + + + + ) +} +` +} + +let browser = null +let origin = null +let scratch = null +let server = null + +beforeAll(async () => { + if (!bundles) { + return + } + const componentsDir = join(projectDir, 'mobile', 'src', 'components') + const cspHeader = await readShellCsp() + scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-working-spinner-')) + const appDir = join(scratch, 'app') + const routeDir = join(appDir, MOBILE_WEB_APP_ROUTE_ROOT) + await mkdir(routeDir, { recursive: true }) + await writeFile(join(routeDir, '_layout.tsx'), LAYOUT_SOURCE) + await writeFile( + join(routeDir, 'working-spinner-probe.tsx'), + probeRouteSource({ + spinnerModule: join(componentsDir, 'AgentSpinner'), + dotModule: join(componentsDir, 'AgentStateDot') + }) + ) + const built = await buildMobileWebAppBundle({ + appDir, + outDir: join(scratch, 'bundle'), + pageRoutes: [{ pathname: ROUTE, grants: [] }] + }) + const served = await createBundleServer({ outDir: built.outDir, cspHeader }) + server = served.server + origin = served.origin + const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER + browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) }) +}, 600_000) + +afterAll(async () => { + await browser?.close() + server?.close() + if (scratch) { + await rm(scratch, { recursive: true, force: true }) + } +}) + +async function openProbe() { + const faultGrant = await readBridgeFaultGrant() + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }) + await page.addInitScript(installShellDouble, { + version: await readBridgeProtocolVersion(), + sessionId: 'working-spinner-session', + buildId: 'working-spinner-build', + route: { pathname: ROUTE, params: {} }, + host: { id: 'spinner-host', name: 'Spinner Host', endpoint: 'ws://spinner', lastConnected: 1 }, + storage: {}, + faultGrant, + grants: [faultGrant], + pageRoutes: [ROUTE], + replies: {} + }) + const errors = [] + page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`)) + await page.goto(`${origin}/`, { waitUntil: 'load' }) + await page.waitForFunction( + () => + globalThis.__orcaWorkingSpinnerProbe !== undefined || + (globalThis.__orcaRenderCheckFaults ?? []).length > 0, + { timeout: 60_000, polling: 100 } + ) + expect(await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])).toEqual([]) + return { errors, page } +} + +/** The rotating ring is the only element under each wrapper that carries an inline transform. */ +const readRotations = (page) => + page.evaluate(() => + ['working-spinner', 'working-dot'].map( + (id) => + [...document.querySelectorAll(`#${id} *`)].find((node) => node.style.transform)?.style + .transform ?? null + ) + ) + +describeRender('working rings on the page', () => { + it('keeps both rings turning past the first second', async () => { + const { errors, page } = await openProbe() + const started = Date.now() + const samples = [] + for (const seconds of SAMPLE_SECONDS) { + await page.waitForTimeout(Math.max(0, started + seconds * 1000 - Date.now())) + samples.push(await readRotations(page)) + } + for (const ring of [0, 1]) { + const angles = samples.map((sample) => sample[ring]) + expect(new Set(angles).size, `ring ${ring} after 1.3 s: ${angles.join(', ')}`).toBe( + angles.length + ) + } + expect(errors).toEqual([]) + await page.close() + }, 300_000) +}) diff --git a/config/scripts/pr-auxiliary-preflight-admission.test.mjs b/config/scripts/pr-auxiliary-preflight-admission.test.mjs new file mode 100644 index 00000000000..2dd82ad5d26 --- /dev/null +++ b/config/scripts/pr-auxiliary-preflight-admission.test.mjs @@ -0,0 +1,143 @@ +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import { describe, 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 routes = [ + ['git_compatibility', 'src/shared/git-capability-cache.ts'], + ['codex_index_heal_contract', 'src/main/codex/codex-session-index-heal.ts'], + ['xterm_patch_sync', 'config/patches/xterm-upstream.json'], + ['shell_contracts', 'src/main/pty/pty-manager.ts'], + ['orcad_browser', 'src/main/orcad/orcad-browser-provider.ts'], + ['cross-version-wire', 'src/shared/orchestration-rpc-contract.ts'], + ['managed_hook_node18', 'src/shared/agent-hook-status.ts'] +] + +function evaluate(expression, context) { + return runInNewContext( + expression + .replace(/^\$\{\{\s*|\s*\}\}$/g, '') + .replaceAll('.cross-version-wire', '["cross-version-wire"]'), + context + ) +} + +function routedOutputs(files, reused) { + const scope = classifyPrJobs(files) + const context = { + steps: { + readiness: { outputs: { reused } }, + filter: { + outputs: Object.fromEntries( + Object.entries(scope).map(([key, value]) => [key, String(value)]) + ) + } + } + } + return Object.fromEntries( + ['test', 'static_analysis', 'typecheck', ...routes.map(([job]) => job)].map((name) => [ + name, + String(evaluate(workflow.jobs.code_paths.outputs[name], context)) + ]) + ) +} + +describe.each(routes)('%s preflight admission', (jobName, changedFile) => { + const job = workflow.jobs[jobName] + + it('waits for the detector and physical preflight job', () => { + expect(job.needs).toEqual(['code_paths', 'preflight']) + expect(job.if).toContain('!cancelled()') + expect(job['continue-on-error']).toBeUndefined() + }) + + it.each([ + { name: 'all prerequisites pass', admitted: true }, + { name: 'preflight fails', preflight: 'failure', admitted: false }, + { name: 'preflight is cancelled', preflight: 'cancelled', admitted: false }, + { name: 'preflight skips', preflight: 'skipped', admitted: false }, + { name: 'preflight result is missing', preflight: '', admitted: false }, + { name: 'preflight is unfinished', preflight: null, admitted: false }, + { name: 'detector fails', detector: 'failure', admitted: false }, + { name: 'detector is cancelled', detector: 'cancelled', admitted: false }, + { name: 'detector skips', detector: 'skipped', admitted: false }, + { name: 'detector result is missing', detector: '', admitted: false }, + { name: 'detector is unfinished', detector: null, admitted: false }, + { name: 'route is unselected', selected: 'false', admitted: false }, + { name: 'route is missing', selected: '', admitted: false }, + { name: 'route is absent', selected: null, admitted: false }, + { name: 'route is unknown', selected: 'unknown', admitted: false }, + { name: 'workflow is cancelled', cancelled: true, admitted: false } + ])('evaluates the actual workflow condition: $name', (scenario) => { + const admitted = evaluate(job.if, { + cancelled: () => scenario.cancelled ?? false, + needs: { + code_paths: { + result: scenario.detector === undefined ? 'success' : (scenario.detector ?? undefined), + outputs: { + [jobName]: scenario.selected === undefined ? 'true' : (scenario.selected ?? undefined) + } + }, + preflight: { + result: scenario.preflight === undefined ? 'success' : (scenario.preflight ?? undefined) + } + } + }) + expect(admitted).toBe(scenario.admitted) + }) + + it.each([ + { files: [] }, + { files: ['package.json'] }, + { files: ['.github/workflows/pr.yml'] }, + { files: [changedFile] } + ])( + 'requires unit work and preflight whenever the real classifier selects $files', + ({ files }) => { + const selected = routedOutputs(files, 'false') + expect(selected[jobName]).toBe('true') + expect(selected.test).toBe('true') + expect(selected.static_analysis).toBe('true') + expect(selected.typecheck).toBe('true') + } + ) + + it.each(['true', 'false', undefined])( + 'honors the actual readiness-masked output when reuse is %s', + (reused) => { + const outputs = routedOutputs([changedFile], reused) + expect(outputs[jobName]).toBe(String(reused !== 'true')) + expect(outputs.test).toBe(String(reused !== 'true')) + const admitted = evaluate(job.if, { + cancelled: () => false, + needs: { code_paths: { result: 'success', outputs }, preflight: { result: 'success' } } + }) + expect(admitted).toBe(reused !== 'true') + } + ) +}) + +it.each([ + { files: ['README.md'] }, + { files: ['mobile/src/App.tsx'] }, + { files: ['mobile/package.json'] }, + { files: ['cloud/apps/relay/src/index.ts'] } +])('does not add preflight consumers to a no-unit diff: $files', ({ files }) => { + const outputs = routedOutputs(files, 'false') + expect(outputs.test).toBe('false') + for (const [name] of routes) { + expect(outputs[name], name).toBe('false') + } +}) + +it('keeps the mobile-only bundle job in the first wave', () => { + const mobile = workflow.jobs.mobile_web_app + expect(mobile.needs).toEqual(['code_paths']) + expect(mobile.if).toBe("needs.code_paths.outputs.mobile_web_app == 'true'") + expect(classifyPrJobs(['mobile/src/App.tsx'])).toMatchObject({ + test: false, + mobile_web_app: true + }) +}) diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 9bcc7974513..8b6bb23b8e8 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -626,12 +626,8 @@ describe('PR Checks skip wiring', () => { 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', 'preflight'] - : ['code_paths'] - ) - expect(prWorkflow.jobs[jobName].if, jobName).toBe( + expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths', 'preflight']) + expect(prWorkflow.jobs[jobName].if, jobName).toContain( `needs.code_paths.outputs.${jobName} == 'true'` ) } diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index c661327a40a..f9fe2d7d732 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -109,8 +109,8 @@ describe('PR E2E gate contract', () => { // Why: without this the job could lose its filter and run on every PR — the // cost the path filter exists to avoid — while the gate assertions above // stay green. - expect(prWorkflow.jobs.e2e.needs).toBe('code_paths') - expect(prWorkflow.jobs.e2e.if).toBe("needs.code_paths.outputs.e2e_should_run == 'true'") + expect(prWorkflow.jobs.e2e.needs).toEqual(['code_paths', 'preflight']) + expect(prWorkflow.jobs.e2e.if).toContain("needs.code_paths.outputs.e2e_should_run == 'true'") expect(prWorkflow.jobs.code_paths.outputs.e2e_should_run).toBe( '${{ steps.e2e_filter.outputs.should_run }}' ) diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index 250ea94ffe8..4be1aa2b740 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -69,6 +69,113 @@ it('requires physical preflight success before publishing shards and admitting c expect(workflow.jobs.verify.needs).not.toContain('typecheck') }) +it.each([ + { name: 'both phases succeed', phases: ['true', 'true'], result: 'success', admitted: true }, + { name: 'static-only succeeds', phases: ['true', 'false'], result: 'success', admitted: true }, + { name: 'type-only succeeds', phases: ['false', 'true'], result: 'success', admitted: true }, + { name: 'static-only fails', phases: ['true', 'false'], result: 'failure', admitted: false }, + { name: 'type-only fails', phases: ['false', 'true'], result: 'failure', admitted: false }, + { name: 'both phases fail', phases: ['true', 'true'], result: 'failure', admitted: false }, + { name: 'preflight is cancelled', result: 'cancelled', admitted: false }, + { name: 'preflight is unfinished', result: undefined, admitted: false }, + { name: 'both phases unselected', phases: ['false', 'false'], result: 'skipped', admitted: true }, + { name: 'required static skips', phases: ['true', 'false'], result: 'skipped', admitted: false }, + { + name: 'required compiler skips', + phases: ['false', 'true'], + result: 'skipped', + admitted: false + }, + { name: 'both required phases skip', result: 'skipped', admitted: false }, + { + name: 'unselected preflight fails', + phases: ['false', 'false'], + result: 'failure', + admitted: false + }, + { + name: 'static selection missing', + phases: [undefined, 'false'], + result: 'skipped', + admitted: false + }, + { + name: 'compiler selection missing', + phases: ['false', undefined], + result: 'skipped', + admitted: false + }, + { name: 'selection unknown', phases: ['', 'false'], result: 'skipped', admitted: false }, + { + name: 'docs have no route', + phases: ['false', 'false'], + result: 'skipped', + route: 'false', + admitted: false + }, + { name: 'source has no route', result: 'success', route: 'false', admitted: false }, + { name: 'route missing', result: 'success', route: '', admitted: false }, + { name: 'path detection fails', result: 'success', pathsResult: 'failure', admitted: false }, + { name: 'path detection skips', result: 'skipped', pathsResult: 'skipped', admitted: false }, + { name: 'workflow cancelled after success', result: 'success', cancelled: true, admitted: false }, + { + name: 'workflow cancelled with unselected phases', + phases: ['false', 'false'], + result: 'skipped', + cancelled: true, + admitted: false + } +])('admits advisory E2E only after eligible preflight: $name', (scenario) => { + const [static_analysis, typecheck] = scenario.phases ?? ['true', 'true'] + expect(workflow.jobs.e2e.needs).toEqual(['code_paths', 'preflight']) + const admitted = runInNewContext(workflow.jobs.e2e.if, { + cancelled: () => scenario.cancelled ?? false, + needs: { + code_paths: { + result: scenario.pathsResult ?? 'success', + outputs: { static_analysis, typecheck, e2e_should_run: scenario.route ?? 'true' } + }, + preflight: { result: scenario.result } + } + }) + expect(admitted).toBe(scenario.admitted) +}) + +it.each(['true', 'false'])( + 'preserves advisory E2E on ready-for-review only with proven required-check reuse: %s', + (reused) => { + const scope = classifyPrJobs(['src/main/runtime/orca-runtime.ts']) + const context = { + steps: { + readiness: { outputs: { reused } }, + filter: { + outputs: Object.fromEntries( + Object.entries(scope).map(([key, value]) => [key, String(value)]) + ) + }, + e2e_filter: { outputs: { should_run: 'true' } } + } + } + const outputs = Object.fromEntries( + ['static_analysis', 'typecheck', 'e2e_should_run'].map((name) => [ + name, + String(runInNewContext(workflow.jobs.code_paths.outputs[name].slice(3, -2), context)) + ]) + ) + expect(outputs).toEqual({ + static_analysis: reused === 'true' ? 'false' : 'true', + typecheck: reused === 'true' ? 'false' : 'true', + e2e_should_run: 'true' + }) + const needs = { code_paths: { result: 'success', outputs }, preflight: { result: 'skipped' } } + expect(runInNewContext(preflight.if, { needs })).toBe(reused !== 'true') + expect(runInNewContext(workflow.jobs.e2e.if, { needs, cancelled: () => false })).toBe( + reused === 'true' + ) + expect(workflow.jobs.verify.needs).not.toContain('e2e') + } +) + 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'" diff --git a/config/scripts/run-terminal-ibus-hangul-e2e.mjs b/config/scripts/run-terminal-ibus-hangul-e2e.mjs index dead1c42f13..57d97cc2fb4 100644 --- a/config/scripts/run-terminal-ibus-hangul-e2e.mjs +++ b/config/scripts/run-terminal-ibus-hangul-e2e.mjs @@ -178,7 +178,10 @@ async function runInsideSession(evidenceDir) { ['--nested', '--wayland', `--wayland-display=${process.env.WAYLAND_DISPLAY}`], { detached: true, - env: process.env, + env: + process.env.ORCA_E2E_WAYLAND_INPUT_DIAGNOSTICS === '1' + ? { ...process.env, WAYLAND_DEBUG: 'server' } + : process.env, stdio: ['ignore', windowManagerLogFd, windowManagerLogFd] } ) @@ -275,10 +278,9 @@ async function runInsideSession(evidenceDir) { : {}), ORCA_E2E_FORWARD_APP_LOGS: '1', ORCA_E2E_NATIVE_IBUS_HANGUL: '1', + ORCA_E2E_NATIVE_IBUS_XVFB: '1', [IME_ENGAGEMENT_RECEIPT_ENV]: receiptPath, - // Why: native IBus key injection only reaches a window the window manager - // has focused, so this run opts out of the background-launch policy. - ORCA_E2E_FOREGROUND: '1' + ORCA_BACKGROUND_LAUNCH: '1' }, stdio: 'inherit' } diff --git a/config/scripts/terminal-ime-e2e-workflow.test.mjs b/config/scripts/terminal-ime-e2e-workflow.test.mjs index cb12839a2af..4bf684d8984 100644 --- a/config/scripts/terminal-ime-e2e-workflow.test.mjs +++ b/config/scripts/terminal-ime-e2e-workflow.test.mjs @@ -12,8 +12,13 @@ describe('terminal IME e2e workflow', () => { it('runs only on schedule or manual dispatch', () => { expect(workflow.on.pull_request).toBeUndefined() - expect(workflow.on.workflow_dispatch).toBeNull() + expect(workflow.on.workflow_dispatch.inputs.diagnose_wayland_input).toMatchObject({ + required: false, + type: 'boolean', + default: false + }) expect(workflow.on.schedule).toEqual([{ cron: '30 9 * * *' }]) + expect(workflow.env.ORCA_BACKGROUND_LAUNCH).toBe('1') }) it('installs native IBus Hangul and X11 input tools', () => { diff --git a/config/scripts/terminal-ime-engagement-receipt.mjs b/config/scripts/terminal-ime-engagement-receipt.mjs index 8f0732908c1..5dcb91d599a 100644 --- a/config/scripts/terminal-ime-engagement-receipt.mjs +++ b/config/scripts/terminal-ime-engagement-receipt.mjs @@ -14,7 +14,8 @@ export const IME_ENGAGEMENT_RECEIPT_ENV = 'ORCA_E2E_IME_ENGAGEMENT_RECEIPT' export const EXPECTED_NATIVE_IME_TESTS = [ 'forwards the issue exact-byte sequence without loss or duplication', 'forwards the issue sentence stress sequence without leaked ASCII', - 'a digit typed right after a Hangul syllable reaches the pty' + 'a digit typed right after a Hangul syllable reaches the pty', + 'confirms native Hangul notes before a deliberate Enter saves' ] function parseReceipts(text) { diff --git a/config/scripts/terminal-ime-engagement-receipt.test.mjs b/config/scripts/terminal-ime-engagement-receipt.test.mjs index 613abc2ffae..8f9bdfa47d4 100644 --- a/config/scripts/terminal-ime-engagement-receipt.test.mjs +++ b/config/scripts/terminal-ime-engagement-receipt.test.mjs @@ -4,7 +4,7 @@ import { verifyImeEngagementReceipts } from './terminal-ime-engagement-receipt.mjs' -const [firstTest, secondTest, thirdTest] = EXPECTED_NATIVE_IME_TESTS +const [firstTest, secondTest, thirdTest, notesTest] = EXPECTED_NATIVE_IME_TESTS function receipt(test, overrides = {}) { return JSON.stringify({ @@ -20,7 +20,7 @@ describe('verifyImeEngagementReceipts', () => { it('accepts a run where every expected test observed real composition', () => { expect( verifyImeEngagementReceipts( - `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` + `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n` ) ).toEqual([]) }) @@ -38,19 +38,34 @@ describe('verifyImeEngagementReceipts', () => { it('rejects a partial run where only one test reached the engine', () => { expect(verifyImeEngagementReceipts(`${receipt(firstTest)}\n`)).toEqual([ `no engagement receipt for "${secondTest}" — it was skipped, filtered out, or renamed`, - `no engagement receipt for "${thirdTest}" — it was skipped, filtered out, or renamed` + `no engagement receipt for "${thirdTest}" — it was skipped, filtered out, or renamed`, + `no engagement receipt for "${notesTest}" — it was skipped, filtered out, or renamed` ]) }) it('requires the digit receipt even when both original native tests passed', () => { - expect(verifyImeEngagementReceipts(`${receipt(firstTest)}\n${receipt(secondTest)}\n`)).toEqual([ + expect( + verifyImeEngagementReceipts( + `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n` + ) + ).toEqual([ `no engagement receipt for "${thirdTest}" — it was skipped, filtered out, or renamed` ]) }) + it('requires the Notes receipt even when the terminal scenarios passed', () => { + expect( + verifyImeEngagementReceipts( + `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` + ) + ).toEqual([ + `no engagement receipt for "${notesTest}" — it was skipped, filtered out, or renamed` + ]) + }) + it('rejects a run that typed keys but never opened a composition', () => { const problems = verifyImeEngagementReceipts( - `${receipt(firstTest, { compositionStart: 0 })}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` + `${receipt(firstTest, { compositionStart: 0 })}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n` ) expect(problems).toEqual([ `"${firstTest}" recorded no compositionstart — the IME never engaged` @@ -59,7 +74,7 @@ describe('verifyImeEngagementReceipts', () => { it('rejects a composition that produced no Hangul, which a latin passthrough would satisfy', () => { const problems = verifyImeEngagementReceipts( - `${receipt(firstTest, { hangulComposition: 0 })}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` + `${receipt(firstTest, { hangulComposition: 0 })}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n` ) expect(problems).toEqual([ `"${firstTest}" recorded no Hangul composition data — the engine produced no syllables` @@ -68,7 +83,7 @@ describe('verifyImeEngagementReceipts', () => { it('rejects a renamed test rather than counting it toward coverage', () => { const problems = verifyImeEngagementReceipts( - `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt('some new scenario')}\n` + `${receipt(firstTest)}\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n${receipt('some new scenario')}\n` ) expect(problems).toEqual([ 'unexpected engagement receipt for "some new scenario" — update EXPECTED_NATIVE_IME_TESTS' @@ -77,7 +92,7 @@ describe('verifyImeEngagementReceipts', () => { it('reports a truncated receipt rather than parsing around it', () => { const problems = verifyImeEngagementReceipts( - `${receipt(firstTest)}\n{"test":"trunc\n${receipt(secondTest)}\n${receipt(thirdTest)}\n` + `${receipt(firstTest)}\n{"test":"trunc\n${receipt(secondTest)}\n${receipt(thirdTest)}\n${receipt(notesTest, { onDataChunks: 0 })}\n` ) expect(problems).toEqual(['malformed receipt line: {"test":"trunc']) }) diff --git a/config/vitest.agent-status-benchmark.config.ts b/config/vitest.agent-status-benchmark.config.ts new file mode 100644 index 00000000000..4bd0561d0ed --- /dev/null +++ b/config/vitest.agent-status-benchmark.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config' +import baseConfig from './vitest.config' + +export default defineConfig({ + ...baseConfig, + test: { + ...baseConfig.test, + include: ['config/scripts/agent-status-hot-path-benchmark.ts'], + fileParallelism: false, + retry: 0 + } +}) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 3f3cf232ad1..8d69c2e1d64 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 91m + + downloads: 93m @@ -15,7 +15,7 @@ downloads downloads - 91m - 91m + 93m + 93m diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 58cef59dde5..611ef54b8cb 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -2383,3 +2383,203 @@ 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. + +## October 5 remaining import, diagnostic and checkout work + +The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37247027814) +used three alternating pairs per treatment at source +`b18b174cd463f852051dc22adc8478b82dee4d1f`. Focused tests ran as fresh one-worker +processes on the four-core, 16 GB Linux ARM runner with Node 24.21.0, pnpm 12.8.1 +and Vitest 4.1.11; persisted transforms and Node compile caches were disabled. + +| Work | Original median | Candidate median | Median paired saving | +| ------------------------------------------------- | --------------- | ---------------- | -------------------- | +| Real incumbent-process test file | 16.478s | 5.976s | 10.520s | +| Commentable-line lifecycle test file | 9.595s | 6.880s | 2.714s | +| Agent-status diagnostic plus semantic test cohort | 11.547s | 8.235s | 3.361s | +| Windows x64 SSH checkout | 22.052s | 16.884s | 5.020s | +| Windows ARM SSH checkout | 46.758s | 33.910s | 13.456s | + +The incumbent cases own distinct sockets, shim directories and process groups. +Running them concurrently preserves all nine outcomes and every real five-second +lsof deadline. Both original and candidate still reject an unreaped helper and +false claims of clean enumeration. Fault receipts prove the actual modified probe +was imported and all owned helpers, groups, sockets and directories were cleaned. +The Windows platform gate retains all nine skips. + +The renderer lifecycle tests keep their six original bodies and real decorator, +zone and model behavior. Only the unrelated saved-note delivery menu is replaced +by a typed throwing facade. Its cleanup assertion rejects unexpected use. Actual +faults in memoization, value-equal refreshes and model replacement still fail their +original assertions; a real menu call fails the facade and the cleanup guard. + +The agent-status benchmark reports counters and timings but asserts only a +nonempty status map and positive elapsed time. It remains available through +`ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.agent-status-benchmark.config.ts`. +Ordinary discovery removes exactly that reporting test. Its 15 meaningful routing, +index-retention and batch cases remain unchanged. Three real production faults +pass the old reporting test and fail those retained contracts. Manual execution +preserves its JSON schema and all nine deterministic counters at 423 worktrees, +634 tabs and 1,000 events. + +Windows SSH hosts retain complete main, shared, relay, type, configuration, +native, resource and test trees, plus root files. Six actual checkouts per +architecture pinned candidate source `ce69b84d675b0a8b4763b03329c9d6549ac723f1`; +all 16,568 retained files match the full checkout's contents, modes and index. +The original 5,209 required inputs and six further inputs added by the source +rebase are present. Local full/sparse builds match 54 generated artifacts; +explicit test discovery is identical. Missing imported source, a named test or a +required descriptor still fails. Full Windows native/provisioning lanes remain +a separate qualification gate. + +Test timings exclude dependency setup, checkout and queues. Checkout timings +include the action and shell/runner observation boundaries but exclude later +builds and provisioning. These scoped savings must not be summed or treated as +measured changes to full-shard occupancy or PR latency. + +## October 5 mobile typecheck overlap + +The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37249591578) +ran three alternating pairs on the four-core, 16 GB Linux ARM runner, with Node +24.21.0 and TypeScript 6.0.3. The production compiler and test-typecheck ratchet +use installed tools after dependency installation. Both resolved compiler +programs have `noEmit: true`, with no incremental, composite or build-info writes. +The change moves the existing unconditional production join after the foreground +ratchet and before tests; it changes no compiler command or failure policy. + +| Complete typecheck stage | Original serial | Overlapped | +| ------------------------ | --------------- | ---------- | +| Pair 1 | 68.284s | 41.582s | +| Pair 2, reversed order | 65.385s | 41.042s | +| Pair 3 | 64.240s | 40.418s | +| Median | 65.385s | 41.042s | + +The median paired saving is 24.343 seconds, or 37.2% of this stage. The stage +bracket includes native background/wait boundaries and observation overhead, +but excludes dependency installation and the later test suite. All six stages +preserve the three workers' compiler/ratchet verdicts and stdout/stderr hashes. +The test compiler's existing diagnostics remain subject to the unchanged +ratchet. Neither compiler attempted native loading or recorded filesystem +mutations. Maximum aggregate owned-process RSS sampled every 200 milliseconds +was 5.137 GiB; this is a sampled value rather than a kernel peak. The ratchet now +runs even when the concurrently running production compiler later fails. + +Two real, independent type faults still prevent tests from starting. The +production fault preserves the unaffected ratchet and child output; the test +fault preserves the production output and fails the ratchet. Their explicit +control receipts pass even though their intentionally failing jobs are allowed +to finish collecting evidence. + +The separate [external cancellation control](https://github.com/stablyai/orca/actions/runs/37251289372) +held the two real installed compiler entrypoints before checking, while retaining +the production-background/ratchet-foreground topology. All three owned workers +were observed alive 14.275 seconds before the cancellation request. Both native +step outcomes became cancelled, tests did not start, and the runner's final +cleanup log names all three exact worker PIDs. The attempted earlier assertion +of PID absence failed: GitHub performs orphan cleanup after the always-tail +observer and artifact upload. Post-cleanup absence was not observed and is not +claimed. This control supplies no compiler-completion or timing measurement. + +## October 5 explicit RPC registry test setup + +The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37251277897) +ran three alternating pairs of the complete 156-file mixed cohort on source +`d1e08ddd666e3099fc51c79f01f0305f5162186d`, using four workers on the four-core +Linux ARM runner, Node 24.21.0, pnpm 12.8.1 and Vitest 4.1.11. Persisted module +transforms and Node compile caches were disabled. All 153 candidate test files +pass explicit method lists to their dispatchers. A shared throwing fixture +prevents those tests from loading the unused default method catalog and rejects +any accidental iteration of it. The three real catalog subjects remain +unchanged and run beside the candidate subjects in every arm. + +| Complete cohort process | Original | Candidate | Paired saving | +| ----------------------- | -------- | --------- | ------------- | +| Pair 1 | 142.184s | 89.238s | 52.946s | +| Pair 2, reversed order | 142.833s | 88.933s | 53.900s | +| Pair 3 | 141.274s | 90.186s | 51.088s | +| Median | 142.184s | 89.238s | 52.946s | + +The median paired saving is 37.2% of this fixed cohort. All six arms preserve +the complete ordered ledger, including duplicate parameterized case names: +1,469 passes and one existing setup-dependent skip, totaling 1,470 outcomes. +The real catalog subjects contribute 63 of those cases. Candidate bodies and +assertions are unchanged. One file at its line limit uses the dispatch method's +parameter type in place of its equivalent type-only import; its emitted code +matches the measured candidate. + +Seventeen control invocations preserve actual terminal-handler fault detection, +reject unexpected default-catalog consumption, and still detect a missing real +catalog registration. A plain consumption counter prevents global mock-history +resets from erasing the guard; actual `clearAllMocks` and `resetAllMocks` controls +demonstrate that distinction and preserve unrelated call history. The isolated +driver restores all source files after success, faults and a real cancellation. +Independent review also confirms all 153 candidate sources, the fixture, three +catalog subjects and declared qualification inputs survive the rebase unchanged. + +These are fresh-process wall times for this cohort, excluding dependency setup, +queues and other test shards. They do not measure full-shard balance, total PR +runner demand, or a change to the dashboard's PR runtime percentiles. + +## October 5 fused terminal cursor row scans + +Readiness checks repeatedly read terminal rows to recognize composer text. +The shared reader now collects undimmed text and the first visible glyph's style +in one pass. The cursor suffix remains a separate scan; dim glyph attributes, +empty cells, wide characters and wrapped spaces keep their existing behavior. +No grid data is retained between calls. + +The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37263358478) +used source `1bec53ceb23b5f296a38ffa0e085778d32fc7849`, Linux ARM, +Node 24.21.0, pnpm 12.8.1 and one worker. All six fresh processes ran the same +49-case readiness census module, with separate empty Vite caches, filesystem +transform caching disabled and Node compile caching disabled. + +| Complete focused process | Original | Candidate | Paired saving | +| ------------------------ | -------- | --------- | ------------- | +| Pair 1 | 66.412s | 60.464s | 5.948s | +| Pair 2, reversed order | 66.880s | 61.061s | 5.819s | +| Pair 3 | 68.056s | 65.821s | 2.234s | + +The median paired saving is 5.819 seconds, or 8.7% of this fixed workload. +All 294 timed case outcomes passed. Separate qualification preserves complete +context/composer outputs on 98 recordings and the logical cursor/projection +outputs of all 192 Runtime census cases. Nine actual scanner faults fail the +intended assertions; the 89-case IME/composer slice also passes. Blank-row tests +bound cell reads to 72 instead of the original 132 for a 12-column, five-row grid, +with and without a reusable cell adapter. + +The raw timer-driven polling trace differed and was excluded from equivalence +evidence. Logical per-frame output captures match; no raw polling-count equality +is claimed. These measurements exclude setup, queues and other modules and do +not establish a change in full-shard balance, PR percentiles or runner demand. + +## October 5 Qoder test import guards + +The direct Qoder Runtime tests now import the existing unused-default-RPC guard +before their Runtime fixture. Their complete test bodies remain unchanged. +The guard rejects an unexpected registry access instead of loading the full +default-method graph. The three real registry catalogs remain unmocked. + +The [hosted comparison](https://github.com/stablyai/orca/actions/runs/37266875139) +used fixed source `1bec53ceb23b5f296a38ffa0e085778d32fc7849`, Linux ARM, +Node 24.21.0, pnpm 12.8.1 and four isolated fork workers. Every fresh process ran +both Qoder files and all three catalogs: 85 cases across five files. Each invocation +used a distinct empty Vite cache, with results, filesystem transform and Node +compile caching disabled. All 510 timed outcomes passed. + +| Complete five-file process | Original | Candidate | Paired saving | +| -------------------------- | -------- | --------- | ------------- | +| Pair 1 | 17.677s | 16.323s | 1.354s | +| Pair 2, reversed order | 16.827s | 16.021s | 0.806s | +| Pair 3 | 17.324s | 16.211s | 1.113s | + +The median paired saving is 1.113 seconds, or 6.4% of this fixed workload. +Separate actual faults in retained launch recipes, Qoder command selection and +method registration fail the same intended assertions before and after the +imports. Generated launch IDs and shifted stack lines differ in the raw failure +messages; they were preserved and are not claimed byte-identical. + +The single hosted trial occupied 134 runner-seconds including all six samples, +shared setup and upload. That is trial cost, not a production saving. These +focused process measurements do not establish full-shard savings, PR runtime +percentiles, queue relief or a change in the organization's runner allowance. diff --git a/docs/reference/ime-regression-checklist.md b/docs/reference/ime-regression-checklist.md index f7c7c2bed78..bad36127dd5 100644 --- a/docs/reference/ime-regression-checklist.md +++ b/docs/reference/ime-regression-checklist.md @@ -100,3 +100,47 @@ Each fix must pass all of these checks: regenerate its bundle patch and lockfile together. - Prefer deterministic replay or state-transition tests. Native evidence is a second layer, never a substitute for regression coverage. + +## Enter in application text fields (#25035) + +Use `Input`, `Textarea`, or `CommandInput` for styled fields. Existing unstyled +fields with keyboard actions use `ImeInput` / `ImeTextarea` from +`lib/ime-text-field.tsx`; those preserve the DOM element, styles, refs, and +composition callbacks. They share `useImeEnterGestureOwnership` and keep +IME-owned keys out of both field actions and bubbling form/menu shortcuts. +Overlay primitives also reject IME-marked Escape in document capture, where +field-level propagation guards cannot intercept dismissal. +Do not add a second tracker at a call site already using a guarded field. +Native Chat and the File Explorer inline name field retain their existing +trackers because they also own specialized composition or element lifetimes. + +Required cases: + +- `isComposing`, `keyCode: 229` without `isComposing`, and `Process/229` must + never submit, choose a suggestion, or dismiss the field. +- The unmarked Enter redispatch stays owned on either side of keyup, including + a `Process/229` release. A + subsequent ordinary typing/navigation key ends that carry immediately; + hidden renderers may defer animation frames, and typing a filename suffix + must not cause the next deliberate Enter to disappear. +- Composition callbacks, blur, refs, and keyed remount cleanup still work. + Normal Enter, modifier submits, and Shift+Enter newlines remain available. +- Test the actual shared field when a consumer delegates IME handling to it; + a mock that replaces `CommandInput` with a raw input removes the protection. + +`ime-text-field.test.tsx` covers primitives, raw fields, parent handlers, and +command selection. File Explorer component tests cover all three operations +and input replacement. `file-explorer-ime-enter.spec.ts` drives Chromium +composition in New File, New Folder, and Rename in a folder workspace, then +checks the complete name in the Explorer and on disk, with both continued typing +and a redispatch followed by deliberate Enter. Overlay tests cover IME Escape +and ordinary dismissal; Markdown tests preserve an unmarked save shortcut while +composition state lingers. These are CDP event +contracts, not native OS keyboard evidence. + +The audit also covers settings and title fields, issue/review creation and +pickers, comments and annotations, search fields, Native Chat questions, +notebook execution shortcuts, and Markdown menu handlers. Terminal input keeps +its existing xterm/PTY ownership; mobile native fields use `onSubmitEditing` +instead of desktop DOM keydown actions. Remote workspaces use the same renderer +fields; file-operation routing and mixed-version wire contracts are unchanged. diff --git a/docs/site/content/docs/cli/orchestration.mdx b/docs/site/content/docs/cli/orchestration.mdx index d83d27836cf..88355cb8c82 100644 --- a/docs/site/content/docs/cli/orchestration.mdx +++ b/docs/site/content/docs/cli/orchestration.mdx @@ -44,7 +44,7 @@ orca orchestration worker-start --task --worktree new-child --name bill orca orchestration worker-start --task --worktree current --agent claude --model --effort high --json ``` -`--agent` takes any Orca agent ID enabled on the worker server, for example `claude`, `codex`, `cursor`, `antigravity`, `muse`, `opencode`, or `opencode2`. `--model` accepts opaque provider model IDs for Claude, Codex, Cursor, Antigravity, and Muse (for example `--agent muse --model muse-spark-1.3`); other agents, including opencode, run the model from their own config. `--effort` requires `--model` and only applies when that agent/model supports the level. Neither flag can combine with `--terminal` (reuse an existing pane). Overrides apply to that launch only and show under `launch.requested` / `launch.effective` in the start receipt. Federated starts need a worker host that advertises launch-preference support. +`--agent` takes any Orca agent ID enabled on the worker server, for example `claude`, `codex`, `cursor`, `antigravity`, `muse`, `opencode`, or `opencode2`. `--model` accepts opaque provider model IDs for Claude, Codex, Cursor, Antigravity, and Muse (for example `--agent muse --model muse-spark-1.3`); OpenCode accepts a per-launch model only in an existing worktree, when the execution host verifies its CLI version and model availability; OpenCode effort remains unsupported. Other agents run the model from their own config. `--effort` requires `--model` and only applies when that agent/model supports the level. Neither flag can combine with `--terminal` (reuse an existing pane). Overrides apply to that launch only and show under `launch.requested` / `launch.effective` in the start receipt. Federated starts need a worker host that advertises launch-preference support. Wait for completions (process every message in a Delivery, then ack): diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index 1b9ce53fa41..4720705bf91 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -279,7 +279,7 @@ export default function TerminalSettingsScreen() { While you're using a terminal on your phone, Orca shrinks it to fit your screen. When you close the app or switch away, this controls whether it stays at phone size (so interactive CLI tools don't reflow) or resizes back to your desktop. You can always - use Restore this terminal or Restore all terminals on the banner to resize manually. + use Restore on the desktop banner to resize manually. {hosts.length === 0 ? ( diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index dde5593278e..e3e13505134 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -1,13 +1,4 @@ -import { optionalSettingsRead } from '../transport/settings-read-operations' -import { interpretOrThrowRefusalMessage } from '../transport/rpc-refusal-message' -import { rpcPayloadMember } from '../transport/rpc-reader-payload' -import { readAcceptedResumeList } from './resume-metadata-lists' -import { - resumeFolderWorkspaceListRead, - resumeProjectGroupListRead, - resumeRepoListRead, - resumeWorktreeListRead -} from './mobile-agent-history-operations' +import { loadMobileResumeMetadata } from './mobile-agent-history-resume-metadata' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' @@ -15,22 +6,17 @@ import { useRouteHandoff } from '../navigation/route-handoff' import { ChevronLeft, RefreshCw } from 'lucide-react-native' import { colors } from '../theme/mobile-theme' import { useHostClient } from '../transport/client-context' -import type { RpcClient } from '../transport/rpc-client' -import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform' import { worktreeCatalogRead } from '../worktree/worktree-catalog-operations' import { getWorktreeLabel } from '../session/worktree-label' import { buildMobileAiVaultResumeLaunch, createMobileAiVaultResumeMutationRegistry, + readMobileAiVaultResumeHost, readMobileRuntimeTerminalWindowsShell, resolveMobileAiVaultResumePlatform, - resumeAiVaultSessionInTerminal, - type MobileAiVaultResumeSettings + resumeAiVaultSessionInTerminal } from '../session/ai-vault-resume-launch' -import { - prepareMobileAiVaultSessionResume, - RESUME_RPC_TIMEOUT_MS -} from '../session/ai-vault-resume-preparation' +import { prepareMobileAiVaultSessionResume } from '../session/ai-vault-resume-preparation' import { triggerError, triggerSuccess } from '../platform/haptics' import type { AiVaultScope, AiVaultSession } from '../../../src/shared/ai-vault-types' import type { Worktree } from '../worktree/workspace-list-types' @@ -38,15 +24,11 @@ import { useMobileAgentHistoryState } from './use-mobile-agent-history-state' import { buildMobileAgentHistorySections } from './agent-history-sections' import { shouldShowMobileCurrentWorktreeBadge } from './agent-history-current-worktree-badge' import { MobileAgentSessionHistoryList } from './MobileAgentSessionHistoryList' -import { - resolveMobileAiVaultSessionResumeTarget, - type MobileAiVaultResumeFolderWorkspace, - type MobileAiVaultResumeProjectGroup, - type MobileAiVaultResumeRepo -} from './agent-history-resume-target' +import { resolveMobileAiVaultSessionResumeTarget } from './agent-history-resume-target' import { buildMobileAgentHistoryResumeActionState } from './agent-history-session-card' import { styles } from './agent-history-styles' import { useNow } from '../hooks/use-now' +import { useMobileResumeOperationOwnership } from './use-mobile-resume-operation-ownership' export type MobileAgentSessionHistoryPanelProps = { hostId: string @@ -69,6 +51,12 @@ export function MobileAgentSessionHistoryPanel({ // screen, and the session it resumes into is a native route the shell has to push. const router = useRouteHandoff() const { client, state: connState } = useHostClient(hostId) + const claimResumeOwnership = useMobileResumeOperationOwnership( + hostId, + worktreeId, + client, + connState + ) const [worktrees, setWorktrees] = useState([]) const [worktreesLoaded, setWorktreesLoaded] = useState(false) const [query, setQuery] = useState('') @@ -147,8 +135,8 @@ export function MobileAgentSessionHistoryPanel({ [sessions, query, scope, scopeFilterPaths, activeWorktreePath, now] ) - const hostPlatform = useMemo( - () => readMobileRuntimeHostPlatform(hostStatusResult), + const resumeHost = useMemo( + () => readMobileAiVaultResumeHost(hostStatusResult), [hostStatusResult] ) const hostTerminalWindowsShell = useMemo( @@ -177,6 +165,7 @@ export function MobileAgentSessionHistoryPanel({ return } + const assertCurrentOwner = claimResumeOwnership() resumeLaunchInFlightRef.current = true setResumingSessionId(session.id) setResumeMessage(null) @@ -188,6 +177,7 @@ export function MobileAgentSessionHistoryPanel({ settings, worktrees: freshWorktrees } = await loadMobileResumeMetadata(client) + assertCurrentOwner() const target = resolveMobileAiVaultSessionResumeTarget({ session, activeWorktreeId: worktreeId, @@ -207,7 +197,7 @@ export function MobileAgentSessionHistoryPanel({ const platform = resolveMobileAiVaultResumePlatform( target.targetStatus, - hostPlatform, + resumeHost.platform, target.workspacePath, target.terminalPlatform ) @@ -218,18 +208,31 @@ export function MobileAgentSessionHistoryPanel({ } const preparedSession = await prepareMobileAiVaultSessionResume(client, session) + assertCurrentOwner() const launch = buildMobileAiVaultResumeLaunch({ session: preparedSession, hostPlatform: platform, hostTerminalWindowsShell, settings }) - await resumeAiVaultSessionInTerminal(client, target.worktreeId, { - ...launch, - clientMutationId: resumeMutationRegistryRef.current.claim(session.id) - }) + await resumeAiVaultSessionInTerminal( + client, + target.worktreeId, + { + ...launch, + hostCapabilities: resumeHost.capabilities, + clientMutationId: resumeMutationRegistryRef.current.claim(session.id) + }, + assertCurrentOwner + ) resumeMutationRegistryRef.current.releaseOnSuccess(session.id) triggerSuccess() + // The host accepted the resume; a cutover now only stops navigation. + try { + assertCurrentOwner() + } catch { + return + } setResumeMessage('Agent session queued.') router.push( `/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(target.worktreeId)}` as Parameters< @@ -248,11 +251,12 @@ export function MobileAgentSessionHistoryPanel({ client, connState, hostId, - hostPlatform, + resumeHost, hostTerminalWindowsShell, router, worktreeId, - worktrees + worktrees, + claimResumeOwnership ] ) @@ -377,57 +381,6 @@ export function MobileAgentSessionHistoryPanel({ const EMPTY_SESSIONS: AiVaultSession[] = [] const EMPTY_ISSUES: { agent: AiVaultSession['agent']; path: string; message: string }[] = [] -async function loadMobileResumeMetadata(client: RpcClient): Promise<{ - repos: MobileAiVaultResumeRepo[] - folderWorkspaces: MobileAiVaultResumeFolderWorkspace[] - projectGroups: MobileAiVaultResumeProjectGroup[] - settings: MobileAiVaultResumeSettings | null - worktrees: Worktree[] | null -}> { - // Why: repo.list can enrich repo remote identities, so fetch resume-only - // metadata after explicit user intent instead of delaying history browsing. - // timeoutMs: without it a socket drop parks these on the reconnect waiter - // for minutes, pinning the resume spinner (see RESUME_RPC_TIMEOUT_MS). - const [repoReply, folderWorkspaceReply, projectGroupReply, settingsReply, worktreeReply] = - await Promise.all([ - resumeRepoListRead.request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }), - resumeFolderWorkspaceListRead - .request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) - .catch(() => null), - resumeProjectGroupListRead - .request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) - .catch(() => null), - optionalSettingsRead - .request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) - .catch(() => null), - resumeWorktreeListRead - .request(client, { limit: 10000 }, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) - .catch(() => null) - ]) - const repoResult = interpretOrThrowRefusalMessage( - () => resumeRepoListRead.interpret(repoReply), - 'Unable to load workspace metadata.' - ) - const folderWorkspaceResult = - folderWorkspaceReply && resumeFolderWorkspaceListRead.interpret(folderWorkspaceReply) - const projectGroupResult = - projectGroupReply && resumeProjectGroupListRead.interpret(projectGroupReply) - const settingsResult = settingsReply ? optionalSettingsRead.interpret(settingsReply) : null - const settings = settingsResult?.accepted - ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - (settingsResult.value as MobileAiVaultResumeSettings | null | undefined) - : null - const worktreeResult = worktreeReply && resumeWorktreeListRead.interpret(worktreeReply) - return { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - repos: (rpcPayloadMember(repoResult, 'repos') as MobileAiVaultResumeRepo[] | undefined) ?? [], - folderWorkspaces: readAcceptedResumeList(folderWorkspaceResult, 'folderWorkspaces') ?? [], - projectGroups: readAcceptedResumeList(projectGroupResult, 'groups') ?? [], - settings: settings ?? null, - worktrees: readAcceptedResumeList(worktreeResult, 'worktrees') ?? null - } -} - function createMobileAiVaultResumeMutationId(sessionId: string): string { const sessionPart = sessionId.replace(/[^a-zA-Z0-9_.:-]/g, '_').slice(0, 64) || 'session' const randomPart = Math.random().toString(36).slice(2, 10) diff --git a/mobile/src/agent-history/agent-history-current-host-capability.test.tsx b/mobile/src/agent-history/agent-history-current-host-capability.test.tsx new file mode 100644 index 00000000000..51d4a91f955 --- /dev/null +++ b/mobile/src/agent-history/agent-history-current-host-capability.test.tsx @@ -0,0 +1,196 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState, RpcResponse } from '../transport/types' +import type { Worktree } from '../worktree/workspace-list-types' +import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from '../../../src/shared/qoder-terminal-create-capability' + +const connection = vi.hoisted((): { client: RpcClient | null; state: ConnectionState } => ({ + client: null, + state: 'connected' +})) +vi.mock('../transport/client-context', () => ({ + useHostClient: () => connection, + useForceReconnect: () => vi.fn() +})) +import { + useMobileAgentHistoryState, + type MobileAgentHistoryState +} from './use-mobile-agent-history-state' +import { + buildMobileAiVaultResumeLaunch, + readMobileAiVaultResumeHost, + resumeAiVaultSessionInTerminal +} from '../session/ai-vault-resume-launch' + +function fakeClient(sendRequest: RpcClient['sendRequest'], generation?: () => number): RpcClient { + return { + sendRequest, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => connection.state, + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {}, + ...(generation ? { getGeneration: generation } : {}) + } +} + +it.each([ + 'host/client replacement', + 'host replacement', + 'same-client generation', + 'legacy-client reconnect' +])('requires current host evidence before owned resume: %s', async (scenario) => { + const cap = QODER_OWNED_TERMINAL_CREATE_CAPABILITY + const worktrees: Worktree[] = [] + const observed: { current: MobileAgentHistoryState | null } = { current: null } + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + let generation = 1 + let hostId = 'owned-host-a' + const read = () => { + if (!observed.current) { + throw new Error('missing history state') + } + return observed.current + } + function Probe() { + observed.current = useMobileAgentHistoryState({ + hostId, + worktreeId: 'owned-worktree', + worktrees, + worktreesLoaded: true + }) + return null + } + const firstStatus: RpcResponse = { + id: 'owned-reply', + ok: true, + result: { hostPlatform: 'darwin', capabilities: ['aiVault.v1', cap] } + } + const initialSend = vi.fn(async (method) => + method === 'status.get' + ? firstStatus + : { id: 'owned-reply', ok: true, result: { sessions: [], issues: [] } } + ) + const initialClient = fakeClient( + initialSend, + scenario === 'legacy-client reconnect' ? undefined : () => generation + ) + connection.client = initialClient + connection.state = 'connected' + await act(async () => { + rendered.tree = create(createElement(Probe)) + }) + try { + expect(read().screenState.kind).toBe('ready') + expect(readMobileAiVaultResumeHost(read().hostStatusResult).capabilities).toContain(cap) + let resolveRetired: (reply: RpcResponse) => void = () => {} + const retiredStatus = new Promise((resolve) => { + resolveRetired = resolve + }) + initialSend.mockImplementation(async () => retiredStatus) + let refresh: Promise | undefined + await act(async () => { + refresh = read().onRefresh() + }) + let resolveStatus: (reply: RpcResponse) => void = () => {} + const pendingStatus = new Promise((resolve) => { + resolveStatus = resolve + }) + const currentSend = vi.fn(async (method) => { + if (method === 'status.get') { + return pendingStatus + } + if (method === 'session.tabs.createTerminal') { + return { + id: 'owned-reply', + ok: true, + result: { + tab: { type: 'terminal', id: 'owned-tab', terminal: 'owned-pty', title: 'Terminal' } + } + } + } + if (method === 'terminal.send') { + return { id: 'owned-reply', ok: true, result: { send: { accepted: true } } } + } + return { id: 'owned-reply', ok: true, result: { sessions: [], issues: [] } } + }) + let currentClient = initialClient + if (scenario === 'host/client replacement') { + currentClient = fakeClient(currentSend, () => 2) + connection.client = currentClient + hostId = 'owned-host-b' + } else { + initialSend.mockImplementation(currentSend) + if (scenario === 'host replacement') { + hostId = 'owned-host-b' + } else if (scenario === 'same-client generation') { + generation = 2 + } else { + connection.state = 'reconnecting' + await act(async () => { + rendered.tree?.update(createElement(Probe)) + }) + connection.state = 'connected' + } + } + await act(async () => { + rendered.tree?.update(createElement(Probe)) + }) + expect(read().screenState.kind).toBe('ready') + const launch = buildMobileAiVaultResumeLaunch({ + session: { + agent: 'qoder', + sessionId: 'owned-session', + cwd: '/owned/workspace', + codexHome: null + }, + hostPlatform: 'darwin' + }) + await act(async () => { + resolveRetired(firstStatus) + await refresh + }) + const pendingHost = readMobileAiVaultResumeHost(read().hostStatusResult) + expect(pendingHost.capabilities?.includes(cap) ?? false).toBe(false) + await resumeAiVaultSessionInTerminal(currentClient, 'owned-worktree', { + ...launch, + hostCapabilities: pendingHost.capabilities, + clientMutationId: 'pending-probe-resume' + }) + const firstCreate = currentSend.mock.calls.find( + ([method]) => method === 'session.tabs.createTerminal' + ) + expect(firstCreate?.[1]).not.toHaveProperty('command') + expect(currentSend.mock.calls.map(([method]) => method)).toContain('terminal.send') + await act(async () => { + resolveStatus({ + id: 'owned-reply', + ok: true, + result: { hostPlatform: 'linux', capabilities: ['aiVault.v1', cap] } + }) + }) + expect(readMobileAiVaultResumeHost(read().hostStatusResult)).toEqual({ + platform: 'linux', + capabilities: ['aiVault.v1', cap] + }) + currentSend.mockClear() + await resumeAiVaultSessionInTerminal(currentClient, 'owned-worktree', { + ...launch, + hostCapabilities: readMobileAiVaultResumeHost(read().hostStatusResult).capabilities, + clientMutationId: 'current-probe-resume' + }) + expect( + currentSend.mock.calls.find(([method]) => method === 'session.tabs.createTerminal')?.[1] + ).toEqual(expect.objectContaining({ command: launch.command })) + expect(currentSend.mock.calls.map(([method]) => method)).not.toContain('terminal.send') + } finally { + await act(async () => { + rendered.tree?.unmount() + }) + } +}) diff --git a/mobile/src/agent-history/agent-history-pending-resume-ownership.test.tsx b/mobile/src/agent-history/agent-history-pending-resume-ownership.test.tsx new file mode 100644 index 00000000000..ecfdd4bae9c --- /dev/null +++ b/mobile/src/agent-history/agent-history-pending-resume-ownership.test.tsx @@ -0,0 +1,350 @@ +import { expect, it, vi } from 'vitest' +import React from 'react' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import { CreateTerminalTab } from '../../../src/shared/rpc-contract/session-tabs-schemas-params' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState, RpcResponse } from '../transport/types' +import { createFakeRpcClient } from '../mobile-web-shell/bridge-host-test-fakes' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +const state = vi.hoisted( + (): { + generation: number + client: RpcClient | null + connection: ConnectionState + legacy: boolean + completedBoundary: boolean + session: AiVaultSession | null + onResume?: (session: AiVaultSession) => Promise + } => ({ + generation: 1, + client: null, + connection: 'connected', + legacy: false, + completedBoundary: false, + session: null + }) +) +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Pressable: 'Pressable', + RefreshControl: 'RefreshControl', + SectionList: 'SectionList', + Text: 'Text', + TextInput: 'TextInput', + View: 'View', + Platform: { OS: 'web', select: (choices: Record) => choices.web }, + AppState: { currentState: 'active', addEventListener: () => ({ remove() {} }) }, + StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 } +})) +vi.mock('react-native-safe-area-context', () => ({ + SafeAreaView: 'SafeAreaView', + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' })) +vi.mock('lucide-react-native', () => ({ ChevronLeft: 'Icon', Play: 'Icon', RefreshCw: 'Icon' })) +const notifications = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), push: vi.fn() })) +vi.mock('../platform/haptics', () => ({ + triggerError: notifications.error, + triggerSuccess: notifications.success +})) +vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: () => null })) +vi.mock('../navigation/route-handoff', () => ({ + useRouteHandoff: () => ({ push: notifications.push, back: vi.fn(), canGoBack: () => false }) +})) +vi.mock('../transport/client-context', () => ({ + useHostClient: () => ({ client: state.client, state: state.connection }), + useForceReconnect: () => vi.fn() +})) +vi.mock('./use-mobile-agent-history-state', () => ({ + useMobileAgentHistoryState: () => ({ + scope: 'workspace', + screenState: { kind: 'ready', sessions: [state.session], issues: [] }, + refreshing: false, + hostStatusResult: + state.generation === 1 + ? { + hostPlatform: 'darwin', + capabilities: state.legacy ? [] : ['aiVault.v1', 'session.tabs.qoderOwnedCreate.v1'] + } + : state.completedBoundary + ? { hostPlatform: 'darwin', capabilities: [] } + : null, + activeWorktreePath: '/owned/workspace', + scopeFilterPaths: ['/owned/workspace'], + onSelectScope: vi.fn(), + onRefresh: vi.fn(), + retry: vi.fn() + }) +})) +vi.mock('./MobileAgentSessionHistoryList', () => ({ + MobileAgentSessionHistoryList: (props: { + onResume: (session: AiVaultSession) => Promise + }) => { + state.onResume = props.onResume + return null + } +})) +import { MobileAgentSessionHistoryPanel } from './MobileAgentSessionHistoryPanel' + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) +it.each([ + ['stable', 'metadata'], + ['generation', 'metadata'], + ['generation without render', 'metadata'], + ['client', 'metadata'], + ['host', 'metadata'], + ['legacy reconnect', 'metadata'], + ['unmount', 'metadata'], + ['stable', 'preparation'], + ['generation', 'preparation'], + ['disconnect', 'preparation'], + ['stable', 'terminal creation'], + ['generation', 'terminal creation'], + ['stable', 'accepted send'], + ['generation', 'accepted send'], + ['generation without render', 'accepted send'], + ['host', 'accepted send'], + ['unmount', 'accepted send'], + ['generation', 'owned create accepted'] +])('pending resume keeps its owner: %s during %s', async (scenario, boundary) => { + const cutover = scenario !== 'stable' + let hostId = 'owned-host' + notifications.error.mockClear() + notifications.success.mockClear() + notifications.push.mockClear() + state.legacy = boundary === 'accepted send' + state.completedBoundary = state.legacy || boundary === 'owned create accepted' + state.generation = 1 + state.connection = 'connected' + state.session = { + id: 'qoder:owned-session', + executionHostId: 'local', + agent: 'qoder', + sessionId: 'owned-session', + title: 'Owned session', + cwd: '/owned/workspace', + branch: null, + model: null, + filePath: '/owned/qoder-session.json', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-10-04T00:00:00.000Z', + messageCount: 2, + totalTokens: 10, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null + } + if (boundary === 'preparation' || boundary === 'terminal creation') { + state.session.agent = 'codex' + state.session.codexHome = '/Users/ada/Library/Application Support/orca/codex-runtime-home/home' + } + const worktrees = [ + { + worktreeId: 'owned-worktree', + repoId: 'owned-repo', + repo: 'Owned', + path: '/owned/workspace', + branch: 'main', + displayName: 'Owned', + liveTerminalCount: 0, + hasAttachedPty: false, + preview: '', + unread: false, + isPinned: false, + linkedPR: null + } + ] + const calls: { method: string; params: unknown; generation: number }[] = [] + let rejectOptional: (error: Error) => void = () => {} + let resolveOptional: (reply: RpcResponse) => void = () => {} + const pendingOptional = new Promise((resolve, reject) => { + rejectOptional = reject + resolveOptional = resolve + }) + const sendRequest: RpcClient['sendRequest'] = vi.fn( + async (method, params) => { + calls.push({ method, params, generation: state.generation }) + if (method === 'repo.list') { + return { + id: 'owned-reply', + ok: true, + result: { repos: [{ id: 'owned-repo', path: '/owned/workspace', connectionId: null }] } + } + } + if (method === 'folderWorkspace.list') { + return boundary === 'metadata' + ? pendingOptional + : { id: 'owned-reply', ok: true, result: { workspaces: [] } } + } + if (method === 'aiVault.prepareSessionResume') { + return boundary === 'preparation' + ? pendingOptional + : { id: 'owned-reply', ok: true, result: { useRealCodexHome: true } } + } + if (method === 'projectGroup.list') { + return { id: 'owned-reply', ok: true, result: { groups: [] } } + } + if (method === 'worktree.ps') { + return { id: 'owned-reply', ok: true, result: { worktrees } } + } + if (method === 'settings.get') { + return { id: 'owned-reply', ok: true, result: {} } + } + if (method === 'session.tabs.createTerminal') { + return boundary === 'terminal creation' || boundary === 'owned create accepted' + ? pendingOptional + : { + id: 'owned-reply', + ok: true, + result: { + tab: { type: 'terminal', id: 'owned-tab', terminal: 'owned-pty', title: 'Terminal' } + } + } + } + if (method === 'terminal.send') { + if (boundary === 'accepted send') { + return pendingOptional + } + return { id: 'owned-reply', ok: true, result: { send: { accepted: true } } } + } + throw new Error(`Unexpected method ${method}`) + } + ) + const client = { + ...createFakeRpcClient( + scenario === 'legacy reconnect' ? {} : { getGeneration: () => state.generation } + ), + sendRequest + } + state.client = client + const rendered: { tree: ReactTestRenderer | null } = { tree: null } + let resume: Promise | undefined + const panel = () => + React.createElement( + React.StrictMode, + null, + React.createElement(MobileAgentSessionHistoryPanel, { hostId, worktreeId: 'owned-worktree' }) + ) + await act(async () => { + rendered.tree = create(panel()) + }) + try { + await act(async () => { + if (!rendered.tree) { + throw new Error('missing panel') + } + if (!state.session || !state.onResume) { + throw new Error('missing resume callback') + } + resume = state.onResume(state.session) + }) + expect(calls.some((call) => call.method === 'repo.list')).toBe(true) + expect(calls.some((call) => call.method === 'folderWorkspace.list')).toBe(true) + if ( + boundary === 'terminal creation' || + boundary === 'owned create accepted' || + boundary === 'accepted send' + ) { + expect(calls.some((call) => call.method === 'session.tabs.createTerminal')).toBe(true) + } + if (boundary === 'preparation') { + expect(calls.some((call) => call.method === 'aiVault.prepareSessionResume')).toBe(true) + } + if (scenario === 'generation' || scenario === 'generation without render') { + state.generation = 2 + } + if (scenario === 'client') { + state.client = { ...createFakeRpcClient(), sendRequest } + } + if (scenario === 'host') { + hostId = 'replacement-host' + } + if (scenario === 'legacy reconnect' || scenario === 'disconnect') { + state.connection = 'reconnecting' + await act(async () => { + rendered.tree?.update(panel()) + }) + if (scenario === 'legacy reconnect') { + state.connection = 'connected' + } + } + if (scenario === 'unmount') { + await act(async () => { + rendered.tree?.unmount() + }) + } else if (scenario !== 'generation without render' && cutover) { + await act(async () => { + rendered.tree?.update(panel()) + }) + } + await act(async () => { + if (boundary === 'terminal creation' || boundary === 'owned create accepted') { + resolveOptional({ + id: 'owned-reply', + ok: true, + result: { + tab: { type: 'terminal', id: 'owned-tab', terminal: 'owned-pty', title: 'Terminal' } + } + }) + } else if (boundary === 'accepted send') { + resolveOptional({ id: 'owned-reply', ok: true, result: { send: { accepted: true } } }) + } else if (boundary === 'preparation' && !cutover) { + resolveOptional({ id: 'owned-reply', ok: true, result: { useRealCodexHome: true } }) + } else { + rejectOptional(new Error('client_disconnected')) + } + await resume + }) + const createCall = calls.find((call) => call.method === 'session.tabs.createTerminal') + if (boundary === 'accepted send' || boundary === 'owned create accepted') { + expect(createCall).toBeDefined() + expect(calls.filter((call) => call.method === 'terminal.send')).toHaveLength( + state.legacy ? 1 : 0 + ) + const completion = { + errors: notifications.error.mock.calls.length, + successes: notifications.success.mock.calls.length, + navigations: notifications.push.mock.calls.length + } + if (scenario !== 'unmount') { + await act(async () => { + if (!state.session || !state.onResume) { + throw new Error('missing retry') + } + await state.onResume(state.session) + }) + const creates = calls.filter((call) => call.method === 'session.tabs.createTerminal') + expect(creates).toHaveLength(2) + const originalCreate = CreateTerminalTab.parse(createCall?.params) + const retryCreate = CreateTerminalTab.parse(creates[1].params) + expect(originalCreate.clientMutationId).toEqual(expect.any(String)) + expect(retryCreate.clientMutationId).toEqual(expect.any(String)) + expect(retryCreate.clientMutationId).not.toBe(originalCreate.clientMutationId) + } + expect(completion).toEqual({ errors: 0, successes: 1, navigations: cutover ? 0 : 1 }) + } else if (cutover) { + if (boundary === 'terminal creation') { + expect(createCall).toBeDefined() + } else { + expect(createCall).toBeUndefined() + } + expect(calls.some((call) => call.method === 'terminal.send')).toBe(false) + } else { + expect(createCall, JSON.stringify(calls)).toBeDefined() + if (boundary === 'metadata') { + expect(createCall?.params).toEqual( + expect.objectContaining({ command: expect.stringContaining('owned-session') }) + ) + } else { + expect(createCall?.params).not.toHaveProperty('command') + expect(calls.some((call) => call.method === 'terminal.send')).toBe(true) + } + } + } finally { + await act(async () => rendered.tree?.unmount()) + } +}) diff --git a/mobile/src/agent-history/mobile-agent-history-resume-metadata.ts b/mobile/src/agent-history/mobile-agent-history-resume-metadata.ts new file mode 100644 index 00000000000..5b09eddb703 --- /dev/null +++ b/mobile/src/agent-history/mobile-agent-history-resume-metadata.ts @@ -0,0 +1,70 @@ +import { optionalSettingsRead } from '../transport/settings-read-operations' +import { interpretOrThrowRefusalMessage } from '../transport/rpc-refusal-message' +import { rpcPayloadMember } from '../transport/rpc-reader-payload' +import { readAcceptedResumeList } from './resume-metadata-lists' +import { + resumeFolderWorkspaceListRead, + resumeProjectGroupListRead, + resumeRepoListRead, + resumeWorktreeListRead +} from './mobile-agent-history-operations' +import type { RpcClient } from '../transport/rpc-client' +import type { Worktree } from '../worktree/workspace-list-types' +import type { MobileAiVaultResumeSettings } from '../session/ai-vault-resume-launch' +import { RESUME_RPC_TIMEOUT_MS } from '../session/ai-vault-resume-preparation' +import type { + MobileAiVaultResumeRepo, + MobileAiVaultResumeFolderWorkspace, + MobileAiVaultResumeProjectGroup +} from './agent-history-resume-target' + +export async function loadMobileResumeMetadata(client: RpcClient): Promise<{ + repos: MobileAiVaultResumeRepo[] + folderWorkspaces: MobileAiVaultResumeFolderWorkspace[] + projectGroups: MobileAiVaultResumeProjectGroup[] + settings: MobileAiVaultResumeSettings | null + worktrees: Worktree[] | null +}> { + // Why: repo.list can enrich repo remote identities, so fetch resume-only + // metadata after explicit user intent instead of delaying history browsing. + // timeoutMs: without it a socket drop parks these on the reconnect waiter + // for minutes, pinning the resume spinner (see RESUME_RPC_TIMEOUT_MS). + const [repoReply, folderWorkspaceReply, projectGroupReply, settingsReply, worktreeReply] = + await Promise.all([ + resumeRepoListRead.request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }), + resumeFolderWorkspaceListRead + .request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .catch(() => null), + resumeProjectGroupListRead + .request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .catch(() => null), + optionalSettingsRead + .request(client, undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .catch(() => null), + resumeWorktreeListRead + .request(client, { limit: 10000 }, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .catch(() => null) + ]) + const repoResult = interpretOrThrowRefusalMessage( + () => resumeRepoListRead.interpret(repoReply), + 'Unable to load workspace metadata.' + ) + const folderWorkspaceResult = + folderWorkspaceReply && resumeFolderWorkspaceListRead.interpret(folderWorkspaceReply) + const projectGroupResult = + projectGroupReply && resumeProjectGroupListRead.interpret(projectGroupReply) + const settingsResult = settingsReply ? optionalSettingsRead.interpret(settingsReply) : null + const settings = settingsResult?.accepted + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + (settingsResult.value as MobileAiVaultResumeSettings | null | undefined) + : null + const worktreeResult = worktreeReply && resumeWorktreeListRead.interpret(worktreeReply) + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + repos: (rpcPayloadMember(repoResult, 'repos') as MobileAiVaultResumeRepo[] | undefined) ?? [], + folderWorkspaces: readAcceptedResumeList(folderWorkspaceResult, 'folderWorkspaces') ?? [], + projectGroups: readAcceptedResumeList(projectGroupResult, 'groups') ?? [], + settings: settings ?? null, + worktrees: readAcceptedResumeList(worktreeResult, 'worktrees') ?? null + } +} diff --git a/mobile/src/agent-history/use-mobile-agent-history-state.ts b/mobile/src/agent-history/use-mobile-agent-history-state.ts index d69262acff6..f73ec61a8a1 100644 --- a/mobile/src/agent-history/use-mobile-agent-history-state.ts +++ b/mobile/src/agent-history/use-mobile-agent-history-state.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useHostClient, useForceReconnect } from '../transport/client-context' +import type { RpcClient } from '../transport/rpc-client' import { connectionRetryAction } from '../transport/connection-retry-action' import type { AiVaultScanIssue, @@ -43,7 +44,20 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams const forceReconnect = useForceReconnect() const [scope, setScope] = useState('workspace') const [screenState, setScreenState] = useState({ kind: 'loading' }) - const [hostStatusResult, setHostStatusResult] = useState(null) + const clientGeneration = client?.getGeneration?.() ?? 0 + const [hostStatusProbe, setHostStatusProbe] = useState<{ + hostId: string + client: RpcClient + generation: number + result: unknown + } | null>(null) + const hostStatusResult = + connState === 'connected' && + hostStatusProbe?.hostId === hostId && + hostStatusProbe.client === client && + hostStatusProbe.generation === clientGeneration + ? hostStatusProbe.result + : null const [refreshing, setRefreshing] = useState(false) const generationRef = useRef(0) const mountedRef = useRef(true) @@ -72,10 +86,14 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams async (options: { scope: AiVaultScope; force: boolean }): Promise => { const generation = generationRef.current + 1 generationRef.current = generation - const isCurrent = () => mountedRef.current && generationRef.current === generation + const isCurrent = () => + mountedRef.current && + generationRef.current === generation && + (client?.getGeneration?.() ?? 0) === clientGeneration if (!client || connState !== 'connected') { if (isCurrent()) { + setHostStatusProbe(null) // Why: keep the stale list visible through transient reconnects // (connState flips re-run the load effect) instead of tearing it // down to a full-screen error, matching the host list screen. @@ -98,7 +116,7 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams () => agentHistoryHostStatusRead.interpret(statusReply), 'Unable to reach host' ) - setHostStatusResult(status) + setHostStatusProbe({ hostId, client, generation: clientGeneration, result: status }) if (!status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY)) { setScreenState({ kind: 'unsupported' }) return @@ -140,11 +158,11 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams return } const message = err instanceof Error ? err.message : 'Unable to load agent sessions' - setHostStatusResult(null) + setHostStatusProbe(null) setScreenState({ kind: 'error', message }) } }, - [activeWorktree, client, connState, worktrees, worktreesLoaded] + [activeWorktree, client, clientGeneration, connState, hostId, worktrees, worktreesLoaded] ) // Initial + reconnect load. Why: scope switches reuse the host's 15s cache diff --git a/mobile/src/agent-history/use-mobile-resume-operation-ownership.ts b/mobile/src/agent-history/use-mobile-resume-operation-ownership.ts new file mode 100644 index 00000000000..bf93486483d --- /dev/null +++ b/mobile/src/agent-history/use-mobile-resume-operation-ownership.ts @@ -0,0 +1,37 @@ +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' + +export function useMobileResumeOperationOwnership( + hostId: string, + worktreeId: string, + client: RpcClient | null, + connection: ConnectionState +) { + const generation = client?.getGeneration?.() + const owner = useMemo( + () => ({ hostId, worktreeId, client, connection, generation }), + [hostId, worktreeId, client, connection, generation] + ) + const currentOwner = useRef(owner) + useLayoutEffect(() => { + currentOwner.current = owner + return () => { + currentOwner.current = null + } + }, [owner]) + return useCallback( + () => () => { + if ( + currentOwner.current !== owner || + owner.connection !== 'connected' || + !owner.client || + owner.client.getState() !== 'connected' || + owner.client.getGeneration?.() !== owner.generation + ) { + throw new Error('Host connection changed. Retry resume.') + } + }, + [owner] + ) +} diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx index c05e5d2a489..38e4674d53d 100644 --- a/mobile/src/components/AgentSpinner.tsx +++ b/mobile/src/components/AgentSpinner.tsx @@ -1,7 +1,7 @@ -import { useEffect, useRef } from 'react' import { Activity } from 'lucide-react-native' -import { Animated, Easing, StyleSheet, View } from 'react-native' +import { Animated, StyleSheet, View } from 'react-native' import type { AgentWorkingMode } from '../../../src/shared/agent-status-types' +import { useWorkingRingRotation } from './use-working-ring-rotation' type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive' @@ -26,24 +26,8 @@ export function AgentSpinner({ status: WorktreeStatus workingMode?: AgentWorkingMode }) { - const spinValue = useRef(new Animated.Value(0)).current const monitoring = status === 'working' && workingMode === 'monitoring' - - useEffect(() => { - if (status === 'working' && !monitoring) { - const animation = Animated.loop( - Animated.timing(spinValue, { - toValue: 1, - duration: 1000, - easing: Easing.linear, - useNativeDriver: true - }) - ) - animation.start() - return () => animation.stop() - } - spinValue.setValue(0) - }, [monitoring, status, spinValue]) + const rotate = useWorkingRingRotation(status === 'working' && !monitoring) const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive @@ -56,10 +40,6 @@ export function AgentSpinner({ } if (status === 'working') { - const rotate = spinValue.interpolate({ - inputRange: [0, 1], - outputRange: ['0deg', '360deg'] - }) return ( diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 6912f6c1b2a..0450afa8166 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -1,8 +1,8 @@ -import { useEffect, useRef } from 'react' import { Activity } from 'lucide-react-native' -import { Animated, Easing, StyleSheet, View } from 'react-native' +import { Animated, StyleSheet, View } from 'react-native' import type { AgentDotState } from '../worktree/agent-row-display' import { colors } from '../theme/mobile-theme' +import { useWorkingRingRotation } from './use-working-ring-rotation' // Per-agent state indicator, 1:1 with desktop AgentStateDot // (src/renderer/src/components/AgentStateDot.tsx): yellow spinner for 'working', @@ -22,27 +22,9 @@ const DOT_COLORS: Record, strin const WORKING_COLOR = '#eab308' export function AgentStateDot({ state }: { state: AgentDotState }) { - const spinValue = useRef(new Animated.Value(0)).current - - useEffect(() => { - if (state === 'working') { - const animation = Animated.loop( - Animated.timing(spinValue, { - toValue: 1, - duration: 1000, - easing: Easing.linear, - useNativeDriver: true - }) - ) - animation.start() - return () => animation.stop() - } - spinValue.setValue(0) - return undefined - }, [state, spinValue]) + const rotate = useWorkingRingRotation(state === 'working') if (state === 'working') { - const rotate = spinValue.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }) return ( diff --git a/mobile/src/components/MobileMarkdown.file-links.test.ts b/mobile/src/components/MobileMarkdown.file-links.test.ts index 28a3155203a..c2243cd6cc3 100644 --- a/mobile/src/components/MobileMarkdown.file-links.test.ts +++ b/mobile/src/components/MobileMarkdown.file-links.test.ts @@ -7,6 +7,7 @@ const openURL = vi.fn(() => Promise.resolve()) vi.mock('react-native', () => ({ Linking: { openURL: (url: string) => openURL(url) }, + Platform: { OS: 'ios' }, Pressable: 'Pressable', ScrollView: 'ScrollView', StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index 3e3d7a8b906..0022daf8d73 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -1,5 +1,6 @@ import { openExternalLink } from '../platform/external-link' import { createMarkdownInlineMatcher, type MarkdownInlineMatch } from './markdown-inline-matcher' +import { INLINE_TEXT_SELECTION } from './inline-text-selection' import { MobileSelectableText } from './MobileSelectableText' import { Fragment, @@ -33,6 +34,8 @@ type Props = { fallback?: string /** Enables iOS range selection for native-chat transcript prose. */ rangeSelectable?: boolean + /** Forward long presses from interactive Android transcript spans to the message. */ + onLongPress?: () => void /** Multiplier for prose font size (paragraphs, lists, quotes). Defaults to 1; * the chat view passes >1 so agent prose reads larger than the compact base. */ textScale?: number @@ -47,11 +50,28 @@ const MAX_TABLE_ROWS = 40 const MAX_TABLE_COLUMNS = 8 /** Prose base size — passed to MermaidDiagram fallback mono text. */ const MERMAID_BASE = 13 -const MarkdownTextContext = createContext>(NativeText) +type MarkdownTextSetup = { + TextComponent: ComponentType + /** Disable native selection only within the Android transcript. */ + androidTranscript: boolean + onLongPress?: () => void +} +const MarkdownTextContext = createContext({ + TextComponent: NativeText, + androidTranscript: false +}) function MarkdownText(props: TextProps): React.JSX.Element { - const TextComponent = useContext(MarkdownTextContext) - return createElement(TextComponent, props) + const { TextComponent, androidTranscript, onLongPress } = useContext(MarkdownTextContext) + if (!androidTranscript) { + return createElement(TextComponent, props) + } + // Override selection without changing the nested spans' inherited behavior. + return createElement(TextComponent, { + ...props, + ...(props.selectable === true ? { selectable: false } : {}), + ...(onLongPress && props.onPress ? { onLongPress } : {}) + }) } // Web/mail hrefs open the system handler; file-target hrefs (file: URIs and @@ -215,6 +235,9 @@ function MobileMarkdownContent({ textScale = 1, onOpenFile }: Props) { + // Interactive children own their touches and must forward the row action. + const setup = useContext(MarkdownTextContext) + const rowLongPress = setup.androidTranscript ? setup.onLongPress : undefined const text = content?.trim() ?? '' const previewText = useMemo(() => normalizeMobileMarkdownPreviewHtml(text), [text]) const blocks = useMemo(() => parseMobileMarkdown(previewText), [previewText]) @@ -288,6 +311,7 @@ function MobileMarkdownContent({ key={index} style={styles.imageFrame} onPress={() => openMarkdownHref(block.url, onOpenFile)} + onLongPress={rowLongPress} > {block.alt || 'Open image'} @@ -380,9 +404,19 @@ function MobileMarkdownContent({ } function MobileMarkdownInner(props: Props): React.JSX.Element | null { - const TextComponent = props.rangeSelectable ? MobileSelectableText : NativeText + const { rangeSelectable = false, onLongPress } = props + // Other Markdown surfaces retain their existing selection behavior. + const androidTranscript = rangeSelectable && !INLINE_TEXT_SELECTION + const setup = useMemo( + () => ({ + TextComponent: rangeSelectable && !androidTranscript ? MobileSelectableText : NativeText, + androidTranscript, + ...(onLongPress ? { onLongPress } : {}) + }), + [rangeSelectable, androidTranscript, onLongPress] + ) return ( - + ) 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} ({ timing: animationTiming }, Easing: { linear: 'linear' }, + Platform: { OS: 'ios' }, StyleSheet: { create: (styles: T) => styles }, View: 'View' })) @@ -87,6 +88,22 @@ describe('mobile monitoring indicators', () => { expect(animationLoop).toHaveBeenCalledOnce() }) + it('keeps the native driver for both working rings on native', async () => { + await act(async () => { + renderer = create( + createElement('View', null, [ + createElement(AgentSpinner, { key: 'spinner', status: 'working' }), + createElement(AgentStateDot, { key: 'dot', state: 'working' }) + ]) + ) + }) + + expect(animationTiming).toHaveBeenCalledTimes(2) + for (const call of animationTiming.mock.calls) { + expect(call).toEqual([expect.anything(), expect.objectContaining({ useNativeDriver: true })]) + } + }) + it.each([ // A user's Stop is not news: muted, never the fault red a failure draws. ['interrupted', colors.textMuted], diff --git a/mobile/src/components/inline-text-selection.test.ts b/mobile/src/components/inline-text-selection.test.ts new file mode 100644 index 00000000000..72fcad28afe --- /dev/null +++ b/mobile/src/components/inline-text-selection.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('react-native', () => ({ Platform: { OS: 'android' } })) + +const { INLINE_TEXT_SELECTION, inlineTextSelectionAllowed } = + await import('./inline-text-selection') + +describe('inline text selection policy', () => { + it('keeps inline selection everywhere but Android', () => { + expect(inlineTextSelectionAllowed('ios')).toBe(true) + expect(inlineTextSelectionAllowed('web')).toBe(true) + expect(inlineTextSelectionAllowed('android')).toBe(false) + }) + + it('resolves the running platform once', () => { + expect(INLINE_TEXT_SELECTION).toBe(false) + }) +}) diff --git a/mobile/src/components/inline-text-selection.ts b/mobile/src/components/inline-text-selection.ts new file mode 100644 index 00000000000..9e9165709a1 --- /dev/null +++ b/mobile/src/components/inline-text-selection.ts @@ -0,0 +1,8 @@ +import { Platform } from 'react-native' + +// Android double-tap selection can mistake successive scroll flicks for word selection. +export function inlineTextSelectionAllowed(os: string): boolean { + return os !== 'android' +} + +export const INLINE_TEXT_SELECTION = inlineTextSelectionAllowed(Platform.OS) diff --git a/mobile/src/components/mobile-markdown-android-selection.test.tsx b/mobile/src/components/mobile-markdown-android-selection.test.tsx new file mode 100644 index 00000000000..9567ae796bb --- /dev/null +++ b/mobile/src/components/mobile-markdown-android-selection.test.tsx @@ -0,0 +1,88 @@ +import { createElement } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileMarkdown } from './MobileMarkdown' + +// Why Android here: the sibling selectable test runs as iOS and proves prose IS selectable there; +// this one proves the transcript carries no selectable span on Android, where a selectable +// TextView selects words while the transcript scrolls — and that other surfaces are untouched. +vi.mock('react-native', () => ({ + Linking: { openURL: () => Promise.resolve() }, + Platform: { OS: 'android' }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' })) + +const CONTENT = [ + '# Heading', + 'A paragraph with **bold**, `code` and https://example.com/link.', + '> quoted', + '- item one', + '| a | b |', + '| - | - |', + '| 1 | 2 |', + '```', + 'fenced()', + '```' +].join('\n') + +describe('MobileMarkdown on Android', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function render(props: Parameters[0]): ReactTestInstance[] { + act(() => { + renderer = create(createElement(MobileMarkdown, props)) + }) + return renderer!.root.findAll((node) => String(node.type) === 'Text') + } + + const tappable = (nodes: ReactTestInstance[]) => + nodes.filter((node) => typeof node.props.onPress === 'function') + + it('renders the transcript with no selectable span, leaving untouched spans alone', () => { + const all = render({ content: CONTENT, rangeSelectable: true }) + expect(all.length).toBeGreaterThan(5) + expect(all.filter((node) => node.props.selectable === true)).toHaveLength(0) + // Inline spans (bold, code, links) never asked for selection; writing `false` onto them + // would map to `userSelect: none` on the web, so the gate must leave them alone. + const links = tappable(all) + expect(links.length).toBeGreaterThan(0) + for (const node of links) { + expect(node.props.selectable).toBeUndefined() + } + }) + + it('routes a long press on a tappable span or image to the row, so neither swallows it', () => { + const onLongPress = vi.fn() + const content = `${CONTENT}\n\n![diagram](https://example.com/diagram.png)` + const all = render({ content, rangeSelectable: true, onLongPress }) + const links = tappable(all) + expect(links.length).toBeGreaterThan(0) + for (const node of links) { + expect(node.props.onLongPress).toBe(onLongPress) + } + expect(all.filter((node) => !node.props.onPress && node.props.onLongPress)).toHaveLength(0) + const images = renderer!.root.findAll( + (node) => String(node.type) === 'Pressable' && typeof node.props.onPress === 'function' + ) + expect(images.length).toBeGreaterThan(0) + for (const node of images) { + expect(node.props.onLongPress).toBe(onLongPress) + } + }) + + it('keeps other surfaces (task comments, previews) selectable as before', () => { + const all = render({ content: CONTENT }) + expect(all.filter((node) => node.props.selectable === true).length).toBeGreaterThan(0) + }) +}) diff --git a/mobile/src/components/mobile-markdown-mermaid-routing.test.ts b/mobile/src/components/mobile-markdown-mermaid-routing.test.ts index c89dfa00853..833064ee3fc 100644 --- a/mobile/src/components/mobile-markdown-mermaid-routing.test.ts +++ b/mobile/src/components/mobile-markdown-mermaid-routing.test.ts @@ -5,6 +5,7 @@ import { MobileMarkdown } from './MobileMarkdown' vi.mock('react-native', () => ({ Linking: { openURL: vi.fn() }, + Platform: { OS: 'ios' }, Pressable: 'Pressable', ScrollView: 'ScrollView', StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }, diff --git a/mobile/src/components/mobile-markdown-selectable.test.tsx b/mobile/src/components/mobile-markdown-selectable.test.tsx index 2b42fc70715..d581a9ebb5f 100644 --- a/mobile/src/components/mobile-markdown-selectable.test.tsx +++ b/mobile/src/components/mobile-markdown-selectable.test.tsx @@ -5,6 +5,7 @@ import { MobileMarkdown } from './MobileMarkdown' vi.mock('react-native', () => ({ Linking: { openURL: () => Promise.resolve() }, + Platform: { OS: 'ios' }, Pressable: 'Pressable', ScrollView: 'ScrollView', StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, diff --git a/mobile/src/components/use-working-ring-rotation.ts b/mobile/src/components/use-working-ring-rotation.ts new file mode 100644 index 00000000000..b78b223e991 --- /dev/null +++ b/mobile/src/components/use-working-ring-rotation.ts @@ -0,0 +1,27 @@ +import { useEffect, useRef } from 'react' +import { Animated, Easing, Platform } from 'react-native' + +/** The working ring's rotation: one turn per second while `active`, reset to 0 otherwise. */ +export function useWorkingRingRotation(active: boolean): Animated.AnimatedInterpolation { + const spinValue = useRef(new Animated.Value(0)).current + + useEffect(() => { + if (active) { + const animation = Animated.loop( + Animated.timing(spinValue, { + toValue: 1, + duration: 1000, + easing: Easing.linear, + // Why: web has no native driver, so a loop over a native-driver timing plays it once. + useNativeDriver: Platform.OS !== 'web' + }) + ) + animation.start() + return () => animation.stop() + } + spinValue.setValue(0) + return undefined + }, [active, spinValue]) + + return spinValue.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }) +} diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index 8399d516136..2a14df7fdee 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -12,7 +12,8 @@ import { hostNewWorktreeSessionRoute } from '../host-route-action-state' import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity' import { WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS, - WORKSPACE_SORT_OPTIONS as SORT_OPTIONS + WORKSPACE_SORT_OPTIONS as SORT_OPTIONS, + WORKSPACE_VIEW_SHARED_NOTE } from '../worktree/workspace-list-picker-options' import { isWorktreePinned } from '../worktree/workspace-list-sections' import { hostScreenStyles as styles } from './host-screen-styles' @@ -37,6 +38,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr 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/host-screen/host-show-pinned-in-groups.test.ts b/mobile/src/host-screen/host-show-pinned-in-groups.test.ts new file mode 100644 index 00000000000..29e6c8db826 --- /dev/null +++ b/mobile/src/host-screen/host-show-pinned-in-groups.test.ts @@ -0,0 +1,135 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('expo-router', () => ({ useFocusEffect: () => {} })) +vi.mock('../cache/worktree-cache', () => ({ + getCachedWorktrees: () => null, + setCachedWorktrees: () => {} +})) +vi.mock('../storage/preferences', () => ({ + loadPinnedIds: async () => new Set(), + savePinnedIds: async () => {} +})) +vi.mock('../transport/host-store', () => ({ + loadHosts: async () => [], + updateLastConnected: async () => {} +})) +vi.mock('../worktree/host-worktree-refresh', () => ({ startHostWorktreeRefresh: () => () => {} })) +vi.mock('../transport/use-worktree-resync', () => ({ + useWorktreeResync: () => ({ refreshing: false, onRefresh: async () => {} }) +})) + +import type { RpcClient } from '../transport/rpc-client' +import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes' +import type { RpcResponse } from '../transport/types' +import { useHostScreenIdentity } from './use-host-screen-identity' +import { useHostWorktreeCatalog } from './use-host-worktree-catalog' + +function settingsReply(settings: unknown): RpcResponse { + return { id: 'reply', ok: true, result: { settings }, _meta: { runtimeId: 'runtime' } } +} + +const noop = () => {} + +type ClientRef = { current: RpcClient | null } + +function screenState(clientRef: ClientRef, applied: boolean[]) { + return { + clientRef, + fetchWorktreesInFlightRef: { current: false }, + newWorktreeModalVisibleRef: { current: false }, + repoMetadataFetchedAtRef: { current: 0 }, + worktreeCatalogRef: { current: null }, + setShowPinnedInGroups: (show: boolean) => applied.push(show), + ...Object.fromEntries( + [ + 'setActionError', + 'setCatalogError', + 'setError', + 'setHostLabelById', + 'setHostName', + 'setHostPlatform', + 'setHostStoredDescriptor', + 'setLastKnownWorktrees', + 'setOptimisticActiveWorktreeIdentity', + 'setPinnedIds', + 'setRepoColorsByName', + 'setRepoHostIdByRepoId', + 'setRepoIconsByName', + 'setSleptIds', + 'setWorktrees', + 'setWorktreesLoaded' + ].map((setter) => [setter, noop]) + ) + } +} + +// Mounting the embedded catalog runs its refresh, which issues the placement read. +async function refreshWith( + reply: Promise, + onSent: (clientRef: ClientRef) => void = noop +): Promise { + const client = new FakeSession('connected') + const clientRef: ClientRef = { current: client } + client.sendRequest.mockImplementation(() => { + onSent(clientRef) + return reply + }) + const applied: boolean[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mount-time refresh reads clientRef and setShowPinnedInGroups; the other members are inert stubs. + const args = { + client, + connState: 'connected', + embedded: true, + fetchRepoMetadata: async () => {}, + hostId: 'host-1', + state: screenState(clientRef, applied), + syncViewSettingsFromDesktop: async () => {} + } as unknown as Parameters[0] + function Probe(): null { + useHostWorktreeCatalog(args) + return null + } + await act(async () => { + create(createElement(Probe)) + await reply.catch(noop) + }) + expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual(['settings.get']) + return applied +} + +describe('the host list mirrors the desktop pinned-placement setting', () => { + it('applies the setting the host reports on refresh, absent reading as off', async () => { + const on = settingsReply({ showPinnedWorktreesInGroups: true }) + expect(await refreshWith(Promise.resolve(on))).toEqual([true]) + expect(await refreshWith(Promise.resolve(settingsReply({})))).toEqual([false]) + }) + + it('drops a reply that lands after the screen moved to another client', async () => { + const on = settingsReply({ showPinnedWorktreesInGroups: true }) + const moveOn = (clientRef: ClientRef) => { + clientRef.current = new FakeSession('connected') + } + expect(await refreshWith(Promise.resolve(on), moveOn)).toEqual([]) + }) + + it('resets to off when the screen is reused for another host', async () => { + const applied: boolean[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the identity hook reads only the refs and setters screenState provides. + const identityState = screenState({ current: null }, applied) as unknown as Parameters< + typeof useHostScreenIdentity + >[0]['state'] + function Probe({ hostId }: { hostId: string }): null { + useHostScreenIdentity({ client: null, hostId, state: identityState }) + return null + } + const renderer = create(createElement(Probe, { hostId: 'host-1' })) + await act(async () => {}) + applied.length = 0 + await act(async () => { + renderer.update(createElement(Probe, { hostId: 'host-2' })) + }) + expect(applied).toEqual([false]) + }) +}) diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts index 66ab8119941..34efd5e71ac 100644 --- a/mobile/src/host-screen/use-host-screen-controller.ts +++ b/mobile/src/host-screen/use-host-screen-controller.ts @@ -131,6 +131,7 @@ export function useHostScreenController({ search: state.search, groupMode: state.groupMode, pinnedIds: state.pinnedIds, + showPinnedInGroups: state.showPinnedInGroups, repoIdsByName: state.repoIdsByName, repoColorsByName: state.repoColorsByName, collapsedGroups: state.collapsedGroups, diff --git a/mobile/src/host-screen/use-host-screen-identity.ts b/mobile/src/host-screen/use-host-screen-identity.ts index 324f2d11c6d..25cc71eeade 100644 --- a/mobile/src/host-screen/use-host-screen-identity.ts +++ b/mobile/src/host-screen/use-host-screen-identity.ts @@ -26,6 +26,7 @@ export function useHostScreenIdentity(args: { setRepoColorsByName, setRepoHostIdByRepoId, setRepoIconsByName, + setShowPinnedInGroups, setWorktrees, setWorktreesLoaded } = state @@ -62,6 +63,7 @@ export function useHostScreenIdentity(args: { setRepoHostIdByRepoId(new Map()) setHostLabelById(new Map()) setHostPlatform(null) + setShowPinnedInGroups(false) repoMetadataFetchedAtRef.current = 0 // Why: useState initializer runs only on first mount, so re-seed the cache when Expo Router reuses this screen for a new hostId. const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null diff --git a/mobile/src/host-screen/use-host-screen-state.ts b/mobile/src/host-screen/use-host-screen-state.ts index 8e020fba446..6625119f205 100644 --- a/mobile/src/host-screen/use-host-screen-state.ts +++ b/mobile/src/host-screen/use-host-screen-state.ts @@ -84,6 +84,8 @@ export function useHostScreenState(hostId: string | undefined, action: string | const [sleptIds, setSleptIds] = useState>(new Set()) const [pinnedIds, setPinnedIds] = useState>(new Set()) const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) + // Desktop's "Also show pinned worktrees in their original lists"; off by default. + const [showPinnedInGroups, setShowPinnedInGroups] = useState(false) // Why: ref so the ui.get merge and ui.set writes read the latest values without re-creating callbacks on every state change. const viewStateRef = useRef({ groupMode: 'repo', @@ -149,6 +151,7 @@ export function useHostScreenState(hostId: string | undefined, action: string | setRouteActionState, setSearch, setShowFilterModal, + setShowPinnedInGroups, setShowGroupPicker, setShowSearch, setShowSortPicker, @@ -158,6 +161,7 @@ export function useHostScreenState(hostId: string | undefined, action: string | setWorktrees, setWorktreesLoaded, showFilterModal, + showPinnedInGroups, showGroupPicker, showSearch, showSortPicker, diff --git a/mobile/src/host-screen/use-host-worktree-catalog.ts b/mobile/src/host-screen/use-host-worktree-catalog.ts index 2fb95241a53..07ea8c4192f 100644 --- a/mobile/src/host-screen/use-host-worktree-catalog.ts +++ b/mobile/src/host-screen/use-host-worktree-catalog.ts @@ -4,6 +4,7 @@ import { setCachedWorktrees } from '../cache/worktree-cache' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import { RpcIncompatibleReplyError } from '../transport/rpc-incompatible-reply-error' +import { showPinnedWorktreesInGroupsRead } from '../transport/settings-read-operations' import { useWorktreeResync } from '../transport/use-worktree-resync' import { startHostWorktreeRefresh } from '../worktree/host-worktree-refresh' import { areWorktreeListsEqual } from '../worktree/worktree-list-snapshot' @@ -42,6 +43,7 @@ export function useHostWorktreeCatalog(args: { setLastKnownWorktrees, setOptimisticActiveWorktreeIdentity, setPinnedIds, + setShowPinnedInGroups, setSleptIds, setWorktrees, setWorktreesLoaded, @@ -145,13 +147,36 @@ export function useHostWorktreeCatalog(args: { }, []) ) + const syncShowPinnedInGroups = useCallback(async (requestClient: RpcClient) => { + try { + const reply = await showPinnedWorktreesInGroupsRead.request(requestClient) + if (clientRef.current !== requestClient) { + return + } + const read = showPinnedWorktreesInGroupsRead.interpret(reply) + if (read.accepted) { + setShowPinnedInGroups(read.value) + } + } catch { + // Best-effort: keep the current placement until the next focus/connect. + } + }, []) + const startWorktreeRefresh = useCallback(() => { if (!client || connState !== 'connected') { return } void syncViewSettingsFromDesktop() + void syncShowPinnedInGroups(client) return startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata }) - }, [client, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop]) + }, [ + client, + connState, + fetchWorktrees, + fetchRepoMetadata, + syncViewSettingsFromDesktop, + syncShowPinnedInGroups + ]) useFocusEffect( useCallback(() => { diff --git a/mobile/src/session/MobileNativeChatMessage.android.test.ts b/mobile/src/session/MobileNativeChatMessage.android.test.ts new file mode 100644 index 00000000000..fff2a8d9eba --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessage.android.test.ts @@ -0,0 +1,121 @@ +import { createElement, type ReactNode } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +// Why a separate file: the sibling suite runs as iOS, where the row has no long press at all. +// This one runs as Android and pins the wiring from the bubble's long press to the actions sheet. +vi.mock('react-native', async () => { + const React = await import('react') + const Text = ({ children, ...props }: { children?: ReactNode }): ReactNode => + React.createElement('Text', props, children) + return { + ActivityIndicator: 'ActivityIndicator', + Animated: { + Text, + Value: class { + setValue(): void {} + }, + loop: (animation: unknown) => animation, + sequence: () => ({ start: vi.fn(), stop: vi.fn() }), + timing: () => ({ start: vi.fn(), stop: vi.fn() }) + }, + Image: 'Image', + Platform: { OS: 'android' }, + Pressable: 'Pressable', + Text, + View: ({ children, ...props }: { children?: ReactNode }) => + React.createElement('View', props, children), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } + } +}) +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) +vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', + ChevronDown: 'ChevronDown', + Copy: 'Copy', + SquareChevronRight: 'SquareChevronRight', + SquareTerminal: 'SquareTerminal', + Wrench: 'Wrench', + ChevronRight: 'ChevronRight' +})) +vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) +vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ + MobileNativeChatMessageActionsSheet: 'MessageActionsSheet' +})) + +import { MobileNativeChatMessage } from './MobileNativeChatMessage' + +const message: NativeChatMessage = { + id: 'a1', + role: 'assistant', + blocks: [{ type: 'text', text: 'Read https://example.com then reply.' }], + timestamp: null, + source: 'transcript' +} + +describe('MobileNativeChatMessage on Android', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function byType(type: string): ReactTestInstance[] { + return renderer!.root.findAll((node) => String(node.type) === type) + } + + it('opens the actions sheet from a long press on the bubble, and closes it again', () => { + act(() => { + renderer = create(createElement(MobileNativeChatMessage, { message })) + }) + expect(byType('MessageActionsSheet')).toHaveLength(0) + + const [bubble] = byType('Pressable') + expect(typeof bubble!.props.onLongPress).toBe('function') + // The markdown gets the same handler, so a link span or image under the finger opens the sheet. + const [markdown] = byType('MobileMarkdown') + expect(markdown!.props.onLongPress).toBe(bubble!.props.onLongPress) + expect(markdown!.props.rangeSelectable).toBe(true) + + act(() => bubble!.props.onLongPress()) + const [sheet] = byType('MessageActionsSheet') + expect(sheet!.props.message).toBe(message) + + act(() => sheet!.props.onClose()) + expect(byType('MessageActionsSheet')).toHaveLength(0) + }) + + it('disables inline selection for host notices too', () => { + act(() => { + renderer = create( + createElement(MobileNativeChatMessage, { + message: { + ...message, + role: 'system', + blocks: [{ type: 'text', text: 'fallback', presentation: 'history-item-too-large' }] + } + }) + ) + }) + const texts = byType('Text').filter((node) => node.props.selectable !== undefined) + expect(texts).toHaveLength(1) + expect(texts[0]!.props.selectable).toBe(false) + act(() => byType('Pressable')[0]!.props.onLongPress()) + expect(byType('MessageActionsSheet')).toHaveLength(1) + }) + + it('renders the user bubble without inline selection', () => { + act(() => { + renderer = create( + createElement(MobileNativeChatMessage, { message: { ...message, id: 'u1', role: 'user' } }) + ) + }) + const texts = byType('Text').filter((node) => node.props.selectable !== undefined) + expect(texts.length).toBeGreaterThan(0) + for (const node of texts) { + expect(node.props.selectable).toBe(false) + } + }) +}) diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index c8193641fef..135469d8917 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -22,6 +22,7 @@ vi.mock('react-native', async () => { timing: () => ({ start: vi.fn(), stop: vi.fn() }) }, Image: 'Image', + Platform: { OS: 'ios' }, Pressable: 'Pressable', Text, View: ({ children, ...props }: { children?: unknown }) => @@ -40,6 +41,9 @@ vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' })) vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) +vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ + MobileNativeChatMessageActionsSheet: 'MessageActionsSheet' +})) import { MobileNativeChatMessage } from './MobileNativeChatMessage' diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 40635166281..83f93266d6f 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -1,6 +1,8 @@ import { MobileSelectableText as Text } from '../components/MobileSelectableText' -import { memo } from 'react' -import { Image, Text as NativeText, View } from 'react-native' +import { memo, useCallback, useState, type ComponentProps, type ReactNode } from 'react' +import { Image, Text as NativeText, Pressable, View } from 'react-native' +import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection' +import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet' import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity' import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types' @@ -20,17 +22,23 @@ function Prose({ block, invert, fontScale, - onOpenFile + onOpenFile, + onLongPress }: { block: NativeChatBlock invert?: boolean fontScale: number onOpenFile?: (relativePath: string) => void + /** Android only: routes a long press on a link span to the row's actions sheet. */ + onLongPress?: () => void }): React.JSX.Element | null { if (isTextBlock(block)) { if (isAgentSessionHostStatusPresentation(block.presentation)) { return ( - + {AGENT_SESSION_HOST_STATUS_COPY[block.presentation]} ) @@ -39,7 +47,10 @@ function Prose({ // markdown renderer's light-on-dark palette. if (invert) { return ( - + {block.text} ) @@ -50,6 +61,7 @@ function Prose({ rangeSelectable textScale={1.25 * fontScale} onOpenFile={onOpenFile} + onLongPress={onLongPress} /> ) } @@ -76,6 +88,25 @@ function Prose({ return null } +// Keep the existing responder hierarchy on platforms with inline selection. +function Content({ + onLongPress, + style, + children +}: { + onLongPress?: () => void + style: ComponentProps['style'] + children: ReactNode +}): React.JSX.Element { + return onLongPress ? ( + + {children} + + ) : ( + {children} + ) +} + function MobileNativeChatMessageImpl({ message, toolsExpanded = false, @@ -129,6 +160,11 @@ function MobileNativeChatMessageImpl({ !turnExpanded && !toolsExpanded const showToolRun = tools.length > 0 && !settledToolsHidden + // Mount selection UI only for the message being copied. + const [actionsOpen, setActionsOpen] = useState(false) + // Keep the memoized Markdown context stable as the message streams. + const openActions = useCallback(() => setActionsOpen(true), []) + const onLongPress = INLINE_TEXT_SELECTION ? undefined : openActions const statusRow = turnStatus ? ( - {prose.map((block, index) => ( @@ -154,6 +191,7 @@ function MobileNativeChatMessageImpl({ invert={isUser} fontScale={fontScale} onOpenFile={onOpenFile} + onLongPress={onLongPress} /> ))} {showToolRun ? ( @@ -168,8 +206,14 @@ function MobileNativeChatMessageImpl({ onOpenFile={onOpenFile} /> ) : null} - + + {actionsOpen ? ( + setActionsOpen(false)} + /> + ) : null} {turnStatusAbove ? null : statusRow} ) diff --git a/mobile/src/session/MobileNativeChatMessageActionsSheet.test.tsx b/mobile/src/session/MobileNativeChatMessageActionsSheet.test.tsx new file mode 100644 index 00000000000..34d4089af9b --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessageActionsSheet.test.tsx @@ -0,0 +1,126 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet' + +const { writeText, alert } = vi.hoisted(() => ({ writeText: vi.fn(), alert: vi.fn() })) +vi.mock('react-native', () => ({ + Alert: { alert }, + ActivityIndicator: 'ActivityIndicator', + Modal: 'Modal', + Pressable: 'Pressable', + ScrollView: 'ScrollView', + Text: 'Text', + View: 'View', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } +})) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 24, bottom: 16, left: 0, right: 0 }) +})) +vi.mock('lucide-react-native', () => ({ + Copy: 'Copy', + TextSelect: 'TextSelect', + X: 'X', + Edit3: 'Edit3', + Trash2: 'Trash2' +})) +vi.mock('../platform/clipboard', () => ({ useClipboardWriter: () => ({ writeText }) })) +// Keep the real drawer lifecycle; only native animation completion is driven by the test. +vi.mock('../components/mounted-bottom-drawer', () => ({ MountedBottomDrawer: 'MountedDrawer' })) + +const message: NativeChatMessage = { + id: 'reply', + source: 'transcript', + role: 'assistant', + timestamp: null, + blocks: [{ type: 'text', text: 'First words' }] +} + +describe('message selection actions', () => { + let renderer: ReactTestRenderer | null = null + const onClose = vi.fn() + const nodes = (type: string) => renderer!.root.findAll((node) => String(node.type) === type) + const render = (value = message) => { + act(() => { + renderer = create( + createElement(MobileNativeChatMessageActionsSheet, { message: value, onClose }) + ) + }) + } + const pressAction = (index: number) => act(() => nodes('Pressable')[index]!.props.onPress()) + const finishClosing = () => act(() => nodes('MountedDrawer')[0]!.props.onHidden()) + + beforeEach(() => { + vi.clearAllMocks() + writeText.mockResolvedValue(undefined) + }) + afterEach(() => act(() => renderer?.unmount())) + + it('waits for drawer removal, then selects a stable snapshot while the reply streams', () => { + render() + act(() => + renderer!.update( + createElement(MobileNativeChatMessageActionsSheet, { + message: { + ...message, + blocks: [{ type: 'text', text: 'First words plus streamed text' }] + }, + onClose + }) + ) + ) + pressAction(1) + expect(nodes('MountedDrawer')[0]!.props.visible).toBe(false) + expect(nodes('Modal')).toHaveLength(0) + expect(onClose).not.toHaveBeenCalled() + finishClosing() + expect(nodes('MountedDrawer')).toHaveLength(0) + expect(nodes('Modal')).toHaveLength(1) + const selected = nodes('Text').find((node) => node.props.selectable) + expect(selected?.props.children).toBe('First words') + act(() => nodes('Modal')[0]!.props.onRequestClose()) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('closes selection with the accessible close button', () => { + render() + pressAction(1) + finishClosing() + const close = nodes('Pressable').find((node) => node.props.accessibilityLabel === 'Close') + expect(close?.props.accessibilityRole).toBe('button') + act(() => close!.props.onPress()) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('copies prose and dismisses only after the drawer closes', () => { + render() + pressAction(0) + expect(writeText).toHaveBeenCalledWith('First words') + expect(onClose).not.toHaveBeenCalled() + finishClosing() + expect(onClose).toHaveBeenCalledOnce() + expect(nodes('Modal')).toHaveLength(0) + }) + + it('surfaces clipboard rejection', async () => { + writeText.mockRejectedValue(new Error('Clipboard unavailable')) + render() + await act(async () => pressAction(0)) + expect(alert).toHaveBeenCalledWith('Copy failed', 'Clipboard unavailable') + }) + + it('dismisses without selecting when the drawer is cancelled', () => { + render() + act(() => nodes('MountedDrawer')[0]!.props.onClose()) + finishClosing() + expect(onClose).toHaveBeenCalledOnce() + expect(nodes('Modal')).toHaveLength(0) + }) + + it('disables actions when the message has no copyable prose', () => { + render({ ...message, blocks: [{ type: 'image-ref', path: '/image.png' }] }) + expect(nodes('Pressable').map((node) => node.props.disabled)).toEqual([true, true]) + expect(writeText).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx b/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx new file mode 100644 index 00000000000..4d9cf71c6ba --- /dev/null +++ b/mobile/src/session/MobileNativeChatMessageActionsSheet.tsx @@ -0,0 +1,119 @@ +import { useRef, useState } from 'react' +import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { Copy, TextSelect, X } from 'lucide-react-native' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { ActionSheetContent } from '../components/ActionSheetModal' +import { BottomDrawer } from '../components/BottomDrawer' +import { useClipboardWriter } from '../platform/clipboard' +import { colors, spacing, typography } from '../theme/mobile-theme' +import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text' + +type Props = { + /** The long-pressed message. The owner mounts this only while the sheet is open. */ + message: NativeChatMessage + onClose: () => void +} + +export function MobileNativeChatMessageActionsSheet({ + message, + onClose +}: Props): React.JSX.Element { + const clipboard = useClipboardWriter() + const [sheetVisible, setSheetVisible] = useState(true) + const [selecting, setSelecting] = useState(false) + // Wait for the drawer to unmount before presenting another native modal. + const selectRequested = useRef(false) + // Streaming updates must not reset an active native text selection. + const [text] = useState(() => nativeChatMessagePlainText(message)) + const closeSheet = () => setSheetVisible(false) + + return ( + <> + (selectRequested.current ? setSelecting(true) : onClose())} + dragContentToDismiss + > + { + void clipboard.writeText(text).catch((error: unknown) => { + Alert.alert( + 'Copy failed', + error instanceof Error ? error.message : 'The clipboard rejected the text.' + ) + }) + } + }, + { + label: 'Select text', + icon: TextSelect, + disabled: text.length === 0, + onPress: () => { + selectRequested.current = true + } + } + ]} + /> + + {selecting ? : null} + + ) +} + +function SelectTextScreen({ + text, + onClose +}: { + text: string + onClose: () => void +}): React.JSX.Element { + const insets = useSafeAreaInsets() + return ( + + + + Select text + + + + + + + {text} + + + + + ) +} + +const styles = StyleSheet.create({ + screen: { flex: 1, backgroundColor: colors.bgBase }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + title: { color: colors.textPrimary, fontSize: typography.titleSize, fontWeight: '600' }, + close: { padding: spacing.xs }, + body: { padding: spacing.lg }, + text: { color: colors.textPrimary, fontSize: typography.bodySize, lineHeight: 22 } +}) diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index d2069a134b2..a0393996cec 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -78,14 +78,15 @@ describe('MobileNativeChatTurnStatus', () => { expect(onToggleExpanded).toHaveBeenCalledOnce() }) - it('heads a turn a crash cut off as failed, and a turn the user stopped as interrupted', () => { + // The crash's notice row explains the cut; the turn bar reads like any finished turn. + it('heads a turn a crash cut off as worked, and a turn the user stopped as interrupted', () => { const crashed = render({ startedAt: Date.now(), workedSeconds: 12, verdict: 'interruption', onToggleExpanded: vi.fn() }) - expect(labels(crashed.root)).toEqual(['Failed after 12s']) + expect(labels(crashed.root)).toEqual(['Worked for 12s']) act(() => crashed.unmount()) const stopped = render({ startedAt: Date.now(), diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts index d588aea30a2..5f709d5aaf2 100644 --- a/mobile/src/session/ai-vault-resume-launch.test.ts +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -5,6 +5,7 @@ import { buildMobileAiVaultResumeLaunch, buildMobileAiVaultResumeCommand, createMobileAiVaultResumeMutationRegistry, + readMobileAiVaultResumeHost, readMobileRuntimeTerminalWindowsShell, resolveMobileAiVaultResumePlatform, resumeAiVaultSessionInTerminal @@ -242,6 +243,75 @@ describe('buildMobileAiVaultResumeLaunch', () => { }) describe('resumeAiVaultSessionInTerminal', () => { + it.each(['darwin', 'linux', 'win32'] as const)( + 'lets the %s execution host select the Qoder resume command at creation', + async (hostPlatform) => { + const launch = buildMobileAiVaultResumeLaunch({ + session: session({ agent: 'qoder', sessionId: 'same-qoder-session' }), + hostPlatform + }) + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } } + }) + .mockResolvedValueOnce({ ok: true, result: { send: { accepted: true } } }) + await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { + ...launch, + clientMutationId: 'qoder-resume', + hostCapabilities: ['session.tabs.qoderOwnedCreate.v1'] + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest).toHaveBeenCalledWith( + 'session.tabs.createTerminal', + expect.objectContaining({ + command: launch.command, + launchAgent: 'qoder', + launchConfig: launch.launchConfig + }), + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + expect(launch.command).toContain('same-qoder-session') + } + ) + + it.each( + [undefined, [], ['aiVault.v1']].flatMap((hostCapabilities) => + (['darwin', 'linux', 'win32'] as const).map((hostPlatform) => ({ + hostCapabilities, + hostPlatform + })) + ) + )( + 'keeps the acknowledged-create fallback on an older $hostPlatform host', + async ({ hostCapabilities, hostPlatform }) => { + const launch = buildMobileAiVaultResumeLaunch({ + session: session({ agent: 'qoder', sessionId: 'legacy-session' }), + hostPlatform + }) + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } } + }) + .mockResolvedValueOnce({ ok: true, result: { send: { accepted: true } } }) + await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { + ...launch, + hostCapabilities, + clientMutationId: 'same-legacy-resume' + }) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(sendRequest.mock.calls[0]?.[1]).not.toHaveProperty('command') + expect(sendRequest.mock.calls[1]).toEqual([ + 'terminal.send', + { terminal: 'pty-1', text: launch.command, enter: true }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ]) + } + ) + it('creates a fresh terminal and sends the command with Enter', async () => { const sendRequest = vi .fn() @@ -401,3 +471,19 @@ describe('resume platform helpers', () => { expect(resolveMobileAiVaultResumePlatform('runtime', 'linux')).toBeNull() }) }) + +it.each([null, {}, { capabilities: 'session.tabs.qoderOwnedCreate.v1' }, { capabilities: [1] }])( + 'does not infer safe Qoder creation from unreadable host capabilities', + (status) => { + expect(readMobileAiVaultResumeHost(status).capabilities).toBeUndefined() + } +) + +it('reads the execution platform and advertised safe-create capability together', () => { + expect( + readMobileAiVaultResumeHost({ + hostPlatform: 'win32', + capabilities: ['session.tabs.qoderOwnedCreate.v1'] + }) + ).toEqual({ platform: 'win32', capabilities: ['session.tabs.qoderOwnedCreate.v1'] }) +}) diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts index fb99f199b79..95005666872 100644 --- a/mobile/src/session/ai-vault-resume-launch.ts +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -21,6 +21,17 @@ import { interpretOrThrowRefusalMessage } from '../transport/rpc-refusal-message import { reviewTerminalCreateRun, reviewTerminalSendRun } from './mobile-review-terminal-operations' import type { MobileReviewTerminalTab } from './review-terminal-reply-schema' import type { MobileAiVaultResumeTargetStatus } from '../agent-history/agent-history-resume-target' +import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from '../../../src/shared/qoder-terminal-create-capability' +import { agentHistoryHostStatusSchema } from '../agent-history/agent-history-reply-schema' +import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform' + +export function readMobileAiVaultResumeHost(statusResult: unknown) { + const status = agentHistoryHostStatusSchema.safeParse(statusResult) + return { + platform: readMobileRuntimeHostPlatform(statusResult), + capabilities: status.success ? status.data.capabilities : undefined + } +} export function buildMobileAiVaultResumeCommand(args: { session: Pick & @@ -151,14 +162,25 @@ function normalizeMobileAiVaultResumeCommandOverrides( export async function resumeAiVaultSessionInTerminal( client: RpcOperationSender, worktreeId: string, - launch: MobileAiVaultResumeLaunch & { clientMutationId?: string } + launch: MobileAiVaultResumeLaunch & { + clientMutationId?: string + hostCapabilities?: readonly string[] + }, + assertCurrentOwner?: () => void ): Promise { + assertCurrentOwner?.() + // Qoder's execution host must select its installed executable before the resume starts. + const launchAtCreate = + launch.launchAgent === 'qoder' && + Boolean(launch.clientMutationId) && + launch.hostCapabilities?.includes(QODER_OWNED_TERMINAL_CREATE_CAPABILITY) === true // Each request is awaited outside its catch so a transport drop propagates as the original error // object; only a refusal is rewritten into this step's own copy. const created = await reviewTerminalCreateRun.request( client, { worktree: `id:${worktreeId}`, + ...(launchAtCreate ? { command: launch.command } : {}), ...(launch.env ? { env: launch.env } : {}), ...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {}), ...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}), @@ -175,6 +197,10 @@ export async function resumeAiVaultSessionInTerminal( () => reviewTerminalCreateRun.interpret(created), 'Failed to create terminal' ) + if (launchAtCreate) { + return terminalTab + } + assertCurrentOwner?.() const sent = await reviewTerminalSendRun.request( client, { diff --git a/mobile/src/session/mobile-native-chat-message-plain-text.test.ts b/mobile/src/session/mobile-native-chat-message-plain-text.test.ts new file mode 100644 index 00000000000..b115cab8268 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-plain-text.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows' +import type { NativeChatBlock } from '../../../src/shared/native-chat-types' +import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text' + +describe('nativeChatMessagePlainText', () => { + it('joins the prose blocks, keeps their whitespace, and leaves tool calls out', () => { + const blocks: NativeChatBlock[] = [ + { type: 'text', text: ' indented first line\n' }, + { type: 'tool-call', name: 'Bash', input: { command: 'ls' } }, + { type: 'text', text: ' \n' }, + { type: 'text', text: 'Second, with `code`.' } + ] + expect(nativeChatMessagePlainText({ blocks })).toBe( + ' indented first line\n\n\nSecond, with `code`.' + ) + }) + + it('copies the displayed host notice instead of its fallback wire text', () => { + expect( + nativeChatMessagePlainText({ + blocks: [{ type: 'text', text: 'fallback', presentation: 'history-item-too-large' }] + }) + ).toBe(AGENT_SESSION_HOST_STATUS_COPY['history-item-too-large']) + }) + + it('is empty for a message with no prose', () => { + expect( + nativeChatMessagePlainText({ blocks: [{ type: 'tool-call', name: 'Read', input: {} }] }) + ).toBe('') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-message-plain-text.ts b/mobile/src/session/mobile-native-chat-message-plain-text.ts new file mode 100644 index 00000000000..40ca75dfaf6 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-message-plain-text.ts @@ -0,0 +1,20 @@ +import { + AGENT_SESSION_HOST_STATUS_COPY, + isAgentSessionHostStatusPresentation +} from '../../../src/shared/agent-session-host-status-rows' +import { isTextBlock } from '../../../src/shared/native-chat-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +/** Copy the displayed prose, excluding tool activity and image attachments. */ +export function nativeChatMessagePlainText(message: Pick): string { + // Preserve indentation in code and nested lists. + return message.blocks + .filter(isTextBlock) + .map((block) => + isAgentSessionHostStatusPresentation(block.presentation) + ? AGENT_SESSION_HOST_STATUS_COPY[block.presentation] + : block.text + ) + .filter((text) => text.trim().length > 0) + .join('\n\n') +} diff --git a/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx new file mode 100644 index 00000000000..37a29458c07 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session.cut-turn-notice.test.tsx @@ -0,0 +1,132 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() } +})) + +const SCOPE = { kind: 'turn' as const, turnItemId: 'turn-1' } +const ITEMS: AgentJournalRenderItem[] = [ + { + itemId: 'user-1', + revision: 0, + sequence: 1, + observedAt: 1, + turnScope: { kind: 'thread' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] } + }, + { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 2, + turnScope: { kind: 'thread' }, + body: { + kind: 'turn', + turnId: 'turn-1', + userItemId: 'user-1', + state: 'interrupted', + startedAt: 2, + completedAt: 9 + } + }, + { + itemId: 'reply-1', + revision: 0, + sequence: 3, + observedAt: 3, + turnScope: SCOPE, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'half' }] } + } +] + +function snapshot(items: AgentJournalRenderItem[]): AgentSessionSubscribeEvent { + return { + type: 'snapshot', + sessionId: 'session-1', + fence: 3, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + fence: 3, + direction: 'tail', + items, + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-1', sequence: 1 }, + newest: { epoch: 'epoch-1', sequence: 3 }, + nextCursor: { epoch: 'epoch-1', sequence: 4 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 3 }, + hasOlder: false, + hasNewer: false + } + } +} + +describe('useMobileStructuredAgentSession transcript', () => { + let renderer: ReactTestRenderer | null = null + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('shows a cut turn no row explains with the one notice desktop shows, in that turn', async () => { + let listener: ((value: unknown) => void) | null = null + const client: Pick = { + sendRequest: vi.fn(async (): Promise => ({ + id: 'response-1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + })), + subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + listener = onData + return () => {} + }) + } + const rendered: { hook: ReturnType | null } = { + hook: null + } + function Harness(): null { + rendered.hook = useMobileStructuredAgentSession({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest and subscribe, both provided. + client: client as RpcClient, + sessionId: 'session-1', + enabled: true, + connected: true, + hostSupport: null, + agent: 'codex', + onSendError: vi.fn() + }) + return null + } + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + + act(() => listener?.(snapshot(ITEMS))) + + await vi.waitFor(() => + expect(rendered.hook?.session.messages.at(-1)).toMatchObject({ + role: 'system', + blocks: [ + { + text: 'Codex stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' + } + ] + }) + ) + // The turn bars place rows by the same items, so the notice joins the cut turn. + expect(rendered.hook?.turnJournal.items.at(-1)).toMatchObject({ turnScope: SCOPE }) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 653d7fbaaa1..b0ecfe9832c 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef } from 'react' import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' +import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice' +import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' import { activeStructuredAgentSessionTurnId, @@ -157,12 +159,28 @@ export function useMobileStructuredAgentSession(args: { onSendError }) + // What the transcript reads, as desktop does: the journal plus the one notice a cut turn with no + // row gets. + const transcriptItems = useMemo( + () => + withNativeChatCutTurnNotices(state.items, { + agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude'] + }), + [agent, state.items] + ) const messages = useMemo( - () => projectStructuredAgentSessionMessages(state.items, [], state.submissions), - [state.items, state.submissions] + // Off: the phone hands a rejected message back to its composer, so a row would show it twice. + () => + projectStructuredAgentSessionMessages(transcriptItems, [], state.submissions, { + rejectedInPlace: false + }), + [transcriptItems, state.submissions] ) const turnId = activeStructuredAgentSessionTurnId(state.items) - const turnTiming = useMobileStructuredAgentTurnTiming(state, turnId) + const turnTiming = useMobileStructuredAgentTurnTiming( + { ...state, items: transcriptItems }, + turnId + ) const activityText = selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null const thinking = isStructuredAgentSessionThinking(state.items) diff --git a/mobile/src/test-support/rpc-recording/adapters/settings-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/settings-mount-adapters.ts index 5c6fe0ef14d..3de45356c27 100644 --- a/mobile/src/test-support/rpc-recording/adapters/settings-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/settings-mount-adapters.ts @@ -1,16 +1,11 @@ +import type * as ResumeMetadata from '../../../agent-history/mobile-agent-history-resume-metadata' import type { OperationExposure } from '../operation-module-loader' import type { MountAdapter } from '../recording-scenario' import { hookMount } from '../hook-mount' import { observableModel, projectObservable } from '../observable-model' import { operationModuleLoader } from '../operation-module-loader' -/** `loadMobileResumeMetadata` is module-private in the panel; exposing it beats editing pinned source. */ -export const settingsMountExposures: readonly OperationExposure[] = [ - [ - 'MobileAgentSessionHistoryPanel.tsx', - '\nexports.loadMobileResumeMetadata = loadMobileResumeMetadata;' - ] -] +export const settingsMountExposures: readonly OperationExposure[] = [] export function settingsMountAdapters( modules: ReturnType @@ -110,8 +105,8 @@ export function settingsMountAdapters( } }, 'settings.resume-metadata': ({ client }) => { - const load = modules.load( - 'mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx' + const load = modules.load( + 'mobile/src/agent-history/mobile-agent-history-resume-metadata.ts' ).loadMobileResumeMetadata return { action: () => load(client), state: () => ({}), dispose: () => {} } }, diff --git a/mobile/src/transport/settings-read-operations.test.ts b/mobile/src/transport/settings-read-operations.test.ts index 87243dee245..de288de3b6c 100644 --- a/mobile/src/transport/settings-read-operations.test.ts +++ b/mobile/src/transport/settings-read-operations.test.ts @@ -9,7 +9,8 @@ import { optionalSettingsRead, botOverridesRead, newTabSettingsRead, - terminalCopyTrimsGutterRead + terminalCopyTrimsGutterRead, + showPinnedWorktreesInGroupsRead } from './settings-read-operations' import type { RpcResponse } from './types' @@ -110,6 +111,30 @@ describe('settings historical acceptance', () => { expect(terminalCopyTrimsGutterRead.interpret(refused)).toEqual({ accepted: false }) }) + it('reads pinned placement, treating an absent or malformed key as the desktop default', async () => { + const showFor = async (settings: unknown) => + showPinnedWorktreesInGroupsRead.interpret( + await showPinnedWorktreesInGroupsRead.request(replyWith(success(settings))) + ) + expect(await showFor({ settings: { showPinnedWorktreesInGroups: true } })).toEqual({ + accepted: true, + value: true + }) + expect(await showFor({ settings: { showPinnedWorktreesInGroups: false } })).toEqual({ + accepted: true, + value: false + }) + // A host predating the setting sends no key; the desktop default is off. + expect(await showFor({ settings: {} })).toEqual({ accepted: true, value: false }) + expect(await showFor({ settings: { showPinnedWorktreesInGroups: 'true' } })).toEqual({ + accepted: true, + value: false + }) + expect(await showFor(null)).toEqual({ accepted: true, value: false }) + const refused = await showPinnedWorktreesInGroupsRead.request(replyWith(refusal())) + expect(showPinnedWorktreesInGroupsRead.interpret(refused)).toEqual({ accepted: false }) + }) + it('does not read a stale payload until its caller permits interpretation', async () => { const read = vi.fn(() => ({})) const reply = await settingsRead.request( diff --git a/mobile/src/transport/settings-read-operations.ts b/mobile/src/transport/settings-read-operations.ts index d33e66458d2..a2dc885795b 100644 --- a/mobile/src/transport/settings-read-operations.ts +++ b/mobile/src/transport/settings-read-operations.ts @@ -112,6 +112,31 @@ export const terminalCopyTrimsGutterRead = bindDeferredRpcOperation( }) ) +const showPinnedInGroupsReader: RpcCompatibleReader = ( + raw +) => { + const settings = raw == null ? undefined : settingsMember(raw) + const show: unknown = + settings == null ? undefined : settingsField(settings, 'showPinnedWorktreesInGroups') + return { + compatible: true, + variant: 'show-pinned-in-groups', + // Why `=== true`: a host predating the setting sends no key; desktop's default is off. + value: show === true, + salvage: { droppedPaths: [], droppedCount: 0 } + } +} + +export const showPinnedWorktreesInGroupsRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'settings.show-pinned-worktrees-in-groups-or-skip', + method: 'settings.get', + acceptance: 'success-result-or-skip', + barrier: 'after-caller-barrier', + read: showPinnedInGroupsReader + }) +) + export const botOverridesRead = bindDeferredRpcOperation( defineRpcOperation({ name: 'settings.bot-logins-or-skip', diff --git a/mobile/src/worktree/mobile-workspace-lineage.ts b/mobile/src/worktree/mobile-workspace-lineage.ts index 9ffe1d731c6..f9fa2e6d7ac 100644 --- a/mobile/src/worktree/mobile-workspace-lineage.ts +++ b/mobile/src/worktree/mobile-workspace-lineage.ts @@ -22,16 +22,14 @@ function hasValidLineageParent(worktree: Worktree, parent: Worktree): boolean { ) } -export function applyMobileWorkspaceLineage( - worktrees: readonly Worktree[], - collapsedGroups: ReadonlySet = new Set() -): Worktree[] { - const visibleIds = new Set(worktrees.map((worktree) => getWorktreeRowIdentity(worktree))) +/** Children keyed by parent row identity, over valid edges between the given rows only. */ +export function getMobileWorkspaceLineageChildren( + worktrees: readonly Worktree[] +): Map { const worktreeById = new Map( worktrees.map((worktree) => [getWorktreeRowIdentity(worktree), worktree]) ) const childrenByParentId = new Map() - const childIds = new Set() for (const worktree of worktrees) { const worktreeId = getWorktreeRowIdentity(worktree) @@ -43,17 +41,26 @@ export function applyMobileWorkspaceLineage( if ( !parentIdentity || parentIdentity === worktreeId || - !visibleIds.has(parentIdentity) || !parent || !hasValidLineageParent(worktree, parent) ) { continue } - childIds.add(worktreeId) const children = childrenByParentId.get(parentIdentity) ?? [] children.push(worktree) childrenByParentId.set(parentIdentity, children) } + return childrenByParentId +} + +export function applyMobileWorkspaceLineage( + worktrees: readonly Worktree[], + collapsedGroups: ReadonlySet = new Set() +): Worktree[] { + const childrenByParentId = getMobileWorkspaceLineageChildren(worktrees) + const childIds = new Set( + [...childrenByParentId.values()].flat().map((worktree) => getWorktreeRowIdentity(worktree)) + ) const result: Worktree[] = [] const emitted = new Set() diff --git a/mobile/src/worktree/use-workspace-sections.ts b/mobile/src/worktree/use-workspace-sections.ts index d28546b80bb..0183efb78a2 100644 --- a/mobile/src/worktree/use-workspace-sections.ts +++ b/mobile/src/worktree/use-workspace-sections.ts @@ -22,6 +22,7 @@ export function useWorkspaceSections(args: { search: string groupMode: MobileGroupMode pinnedIds: Set + showPinnedInGroups: boolean repoIdsByName: Map repoColorsByName: Map collapsedGroups: Set @@ -39,6 +40,7 @@ export function useWorkspaceSections(args: { search, groupMode, pinnedIds, + showPinnedInGroups, repoIdsByName, repoColorsByName, collapsedGroups, @@ -74,7 +76,8 @@ export function useWorkspaceSections(args: { pinnedIds, repoIdsByName, workspaceStatuses, - collapsedGroups + collapsedGroups, + showPinnedInGroups ), [ displayWorktrees, @@ -85,7 +88,8 @@ export function useWorkspaceSections(args: { pinnedIds, repoIdsByName, workspaceStatuses, - collapsedGroups + collapsedGroups, + showPinnedInGroups ] ) 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-list-pinned-lineage.test.ts b/mobile/src/worktree/workspace-list-pinned-lineage.test.ts new file mode 100644 index 00000000000..18fd225fd56 --- /dev/null +++ b/mobile/src/worktree/workspace-list-pinned-lineage.test.ts @@ -0,0 +1,101 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { Worktree } from './workspace-list-sections' +import { buildSections } from './workspace-list-sections' +import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from './mobile-workspace-statuses' + +function worktree(overrides: Partial = {}): Worktree { + const worktreePath = join('/tmp', 'orca', 'worktrees', 'feature') + return { + workspaceKind: 'git', + worktreeId: `repo-1::${worktreePath}`, + repoId: 'repo-1', + repo: 'orca', + branch: 'feature/mobile-parity', + displayName: 'feature', + path: worktreePath, + liveTerminalCount: 0, + hasAttachedPty: false, + preview: '', + unread: false, + isPinned: false, + linkedPR: null, + status: 'inactive', + agents: [], + ...overrides + } +} + +describe('a pinned parent with an unpinned child', () => { + const parent = worktree({ worktreeId: 'parent', displayName: 'parent', isPinned: true }) + const child = worktree({ + worktreeId: 'child', + displayName: 'child', + parentWorktreeId: 'parent' + }) + + function nesting(showPinnedInGroups: boolean) { + const sections = buildSections( + [child, parent], + 'name', + { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false }, + '', + 'repo', + new Set(), + new Map(), + DEFAULT_MOBILE_WORKSPACE_STATUSES, + new Set(), + showPinnedInGroups + ) + return sections.map((section) => ({ + key: section.key, + rows: section.data.map((row) => [row.worktreeId, row.lineageDepth]) + })) + } + + const nested = [ + ['parent', 0], + ['child', 1] + ] + + it('moves the child into Pinned under its parent by default', () => { + expect(nesting(false)).toEqual([{ key: 'pinned', rows: nested }]) + }) + + it('nests the child under its parent in both sections when the desktop setting is on', () => { + expect(nesting(true)).toEqual([ + { key: 'pinned', rows: nested }, + { key: 'repo:orca', rows: nested } + ]) + }) +}) + +it('follows a pin through a child hidden by search, like desktop', () => { + const root = worktree({ worktreeId: 'root', displayName: 'alpha-root', isPinned: true }) + const middle = worktree({ + worktreeId: 'middle', + displayName: 'middle', + parentWorktreeId: 'root' + }) + const leaf = worktree({ + worktreeId: 'leaf', + displayName: 'alpha-sub', + parentWorktreeId: 'middle' + }) + + const sections = buildSections( + [root, middle, leaf], + 'name', + { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false }, + 'alpha', + 'repo', + new Set() + ) + + expect( + sections.map((section) => ({ + key: section.key, + rows: section.data.map((row) => row.worktreeId) + })) + ).toEqual([{ key: 'pinned', rows: ['root', 'leaf'] }]) +}) diff --git a/mobile/src/worktree/workspace-list-sections.test.ts b/mobile/src/worktree/workspace-list-sections.test.ts index 48227fff78f..700e6023e05 100644 --- a/mobile/src/worktree/workspace-list-sections.test.ts +++ b/mobile/src/worktree/workspace-list-sections.test.ts @@ -689,28 +689,68 @@ describe('buildSections', () => { expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['progress']) }) - it('keeps pinned worktrees in their canonical status group like desktop', () => { - const pinned = worktree({ - worktreeId: 'pinned', - workspaceStatus: 'in-progress', - isPinned: true - }) + describe.each(['none', 'repo', 'workspaceStatus', 'prStatus'] as const)( + 'pinned placement in %s grouping', + (groupMode) => { + const sibling = worktree({ worktreeId: 'sibling', workspaceStatus: 'in-progress' }) + const hostPinned = worktree({ + worktreeId: 'host-pinned', + workspaceStatus: 'in-progress', + isPinned: true + }) + const localPinned = worktree({ worktreeId: 'local-pinned', workspaceStatus: 'in-progress' }) + + function placement(showPinnedInGroups: boolean) { + const sections = buildSections( + [sibling, hostPinned, localPinned], + 'name', + { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false }, + '', + groupMode, + new Set(['local-pinned']), + new Map(), + DEFAULT_MOBILE_WORKSPACE_STATUSES, + new Set(), + showPinnedInGroups + ) + const ids = (keep: (key: string) => boolean) => + sections + .filter((section) => keep(section.key)) + .flatMap((section) => section.data.map((row) => row.worktreeId)) + .sort() + return { pinned: ids((key) => key === 'pinned'), groups: ids((key) => key !== 'pinned') } + } + + it('shows host and local pins only in Pinned by default', () => { + expect(placement(false)).toEqual({ + pinned: ['host-pinned', 'local-pinned'], + groups: ['sibling'] + }) + }) + + it('also keeps them in their groups when the desktop setting is on', () => { + expect(placement(true)).toEqual({ + pinned: ['host-pinned', 'local-pinned'], + groups: ['host-pinned', 'local-pinned', 'sibling'] + }) + }) + } + ) + + it('drops a repo group whose only worktree is pinned, like desktop', () => { + const pinned = worktree({ worktreeId: 'pinned', isPinned: true }) const sections = buildSections( [pinned], - 'manual', + 'name', { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false }, '', - 'workspaceStatus', + 'repo', new Set(), - new Map(), - DEFAULT_MOBILE_WORKSPACE_STATUSES + new Map([['orca', 'repo-1']]) ) - expect(withoutSectionListKeys(sections)).toEqual([ - { key: 'pinned', title: 'Pinned', icon: 'pin', data: [pinned] }, - { key: 'workspace-status:in-progress', title: 'In progress', data: [pinned] } - ]) + expect(sections.map((section) => section.key)).toEqual(['pinned']) }) it('renders one sorted All section when grouping is off like desktop', () => { diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts index 16ef9ae9f5d..d231d81ba54 100644 --- a/mobile/src/worktree/workspace-list-sections.ts +++ b/mobile/src/worktree/workspace-list-sections.ts @@ -5,7 +5,10 @@ import { getMobileWorkspaceStatus, getMobileWorkspaceStatusGroupKey } from './mobile-workspace-statuses' -import { applyMobileWorkspaceLineage } from './mobile-workspace-lineage' +import { + applyMobileWorkspaceLineage, + getMobileWorkspaceLineageChildren +} from './mobile-workspace-lineage' import { getPRGroupKey, PR_GROUP_LABELS, PR_GROUP_ORDER } from './workspace-pr-status-groups' import type { FilterState, Section, Worktree } from './workspace-list-types' import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings' @@ -19,10 +22,10 @@ function makeSection( key: string, title: string, data: Worktree[], - icon?: 'pin', - collapsedGroups?: ReadonlySet + collapsedGroups: ReadonlySet, + icon?: 'pin' ): Section { - const rows = collapsedGroups ? applyMobileWorkspaceLineage(data, collapsedGroups) : data + const rows = applyMobileWorkspaceLineage(data, collapsedGroups) return { key, title, @@ -120,6 +123,25 @@ export function isWorktreePinned(w: Worktree, localPins: Set): boolean { return w.isPinned || localPins.has(w.worktreeId) } +// Visible pinned rows plus their lineage descendants, walking through filtered-out rows as desktop does. +function getPinnedSectionIdentities( + worktrees: readonly Worktree[], + visible: readonly Worktree[], + pinnedIds: Set +): Set { + const childrenByParentId = getMobileWorkspaceLineageChildren(worktrees) + const identities = new Set( + visible.filter((w) => isWorktreePinned(w, pinnedIds)).map((w) => getWorktreeRowIdentity(w)) + ) + // A Set iterates entries added mid-loop and ignores repeats, so this walks descendants and stops on cycles. + for (const identity of identities) { + for (const child of childrenByParentId.get(identity) ?? []) { + identities.add(getWorktreeRowIdentity(child)) + } + } + return identities +} + export function buildSections( worktrees: Worktree[], sortMode: MobileSortMode, @@ -129,24 +151,25 @@ export function buildSections( pinnedIds: Set, repoIdsByName: ReadonlyMap = new Map(), workspaceStatuses: readonly WorkspaceStatusDefinition[] = DEFAULT_MOBILE_WORKSPACE_STATUSES, - collapsedGroups: ReadonlySet = new Set() + collapsedGroups: ReadonlySet = new Set(), + showPinnedInGroups = false ): Section[] { const filtered = filterWorktrees(worktrees, filters, search) const sorted = sortWorktrees(filtered, sortMode) - const pinned = sorted.filter((w) => isWorktreePinned(w, pinnedIds)) - // Why: desktop treats Pinned as an overlay. Keeping pinned rows in canonical - // groups preserves exact cross-surface order and literal section counts. - const canonicalGroupWorktrees = sorted + const pinnedIdentities = getPinnedSectionIdentities(worktrees, sorted, pinnedIds) + const isInPinned = (w: Worktree) => pinnedIdentities.has(getWorktreeRowIdentity(w)) + const pinned = sorted.filter(isInPinned) + const canonicalGroupWorktrees = showPinnedInGroups ? sorted : sorted.filter((w) => !isInPinned(w)) const sections: Section[] = [] if (pinned.length > 0) { - sections.push(makeSection('pinned', 'Pinned', pinned, 'pin')) + sections.push(makeSection('pinned', 'Pinned', pinned, collapsedGroups, 'pin')) } if (groupMode === 'none') { if (canonicalGroupWorktrees.length > 0) { - sections.push(makeSection('all', 'All', canonicalGroupWorktrees, undefined, collapsedGroups)) + sections.push(makeSection('all', 'All', canonicalGroupWorktrees, collapsedGroups)) } } else if (groupMode === 'repo') { const byRepo = new Map() @@ -177,9 +200,7 @@ export function buildSections( } for (const [repo, items] of byRepo) { const key = `repo:${repoIdsByName.get(repo) ?? repo}` - sections.push( - makeSection(key, repo, orderMainWorktreeFirst(items), undefined, collapsedGroups) - ) + sections.push(makeSection(key, repo, orderMainWorktreeFirst(items), collapsedGroups)) } } else if (groupMode === 'workspaceStatus') { const renderableWorkspaceStatuses = coerceMobileWorkspaceStatuses(workspaceStatuses) @@ -201,7 +222,6 @@ export function buildSections( getMobileWorkspaceStatusGroupKey(status.id), status.label, items, - undefined, collapsedGroups ) ) @@ -222,13 +242,7 @@ export function buildSections( const items = byGroup.get(groupKey) if (items && items.length > 0) { sections.push( - makeSection( - `pr:${groupKey}`, - PR_GROUP_LABELS[groupKey], - items, - undefined, - collapsedGroups - ) + makeSection(`pr:${groupKey}`, PR_GROUP_LABELS[groupKey], items, collapsedGroups) ) } } 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/opencode.json b/opencode.json new file mode 100644 index 00000000000..030babec933 --- /dev/null +++ b/opencode.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "*": { + "*": "allow" + }, + "read": { + "*.env": "ask", + "*.env.*": "ask", + "*.env.example": "allow" + }, + "external_directory": { + "*": "ask" + }, + "doom_loop": "ask" + } +} diff --git a/skill-guides/orchestration/references/coordinator-loop.md b/skill-guides/orchestration/references/coordinator-loop.md index aec13667645..164f91e4548 100644 --- a/skill-guides/orchestration/references/coordinator-loop.md +++ b/skill-guides/orchestration/references/coordinator-loop.md @@ -31,9 +31,12 @@ ORCA orchestration worker-start --task --worktree current --agent clau ORCA orchestration worker-start --task --worktree current --agent muse --model muse-spark-1.3 --json ``` -Other agents, including `opencode`, reject `--model`; they run the model set in -their own config, so a coordinator wanting a same-model opencode worker relies -on that config. +OpenCode also accepts `--model` in an existing worktree when the execution host +verifies its CLI version and model availability. Creating a new worktree with an +OpenCode model override is unsupported; use `--worktree current` or an existing +worktree selector, or omit `--model`. OpenCode effort is unsupported. Omit the +model to inherit its configuration; unsupported or unknown hosts refuse the +override explicitly. `--effort` requires `--model`; neither option combines with `--terminal`. A connected worker server must advertise launch-preference support before Orca diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index cea9256e75d..f23fe070c1f 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -69,10 +69,10 @@ const ORCA_PER_WORKSPACE_ENV_WINDOWS_SCRIPTS_REFERENCE_MARKDOWN = "# Windows loc const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Coordinate supervised Orca workers: threaded messages, blocking ask/reply,\n task dispatch, worker_done/escalation waits, task DAGs, decision gates,\n coordinator loops, and decomposing work across agents. Use `orca-cli` for full\n ownership handoffs — \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", \"another worktree\" — unless asked to supervise, monitor, or coordinate\n a DAG, and for terminal control, lightweight terminal prompts, shell commands,\n Orca worktree management, and reading or waiting on terminals.\n---\n\n# Orca orchestration\n\nOrchestration is Orca's structured coordination layer. It records who owns work,\nwhich attempt is authoritative, and when supervised work has settled.\n\n## Outcome\n\n**Result:** every in-scope Task has one explicit outcome and every settled worker\nterminal has a next owner or cleanup decision. **Next consumer:** the user who\nrequested supervision. **Done:** all expected Dispatches have settled, every\ndelivered message was processed before acknowledgment, each settled worker was\nreused, explicitly retained, or released, and the turn ends only when the report\nto that user names, per Task, its outcome, the evidence behind it, and any\nunresolved blocker.\n\n**Safe failure:** preserve work and authority and report the state as unknown or\n`unverifiable`. Only positive proof of exit authorizes stop, abandon, or retry,\nand only an accepted settlement authorizes release. Every other observation,\nabsence included, is a checkpoint.\n\n## Classify the role\n\n| Current context | Role | Route |\n| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ |\n| The user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use a decision gate, or manage ask/reply | Coordinator | Use the supervised loop below |\n| The current prompt contains a live injected preamble with Task and Dispatch IDs | Dispatched worker | Follow the preamble and the worker obligations below |\n| The user asks to hand off ownership or start another agent/worktree without supervision | Handoff owner | Use `orca-cli`; create no Run, Task, or Dispatch and do not monitor completion |\n| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |\n| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |\n\nModel or effort selection does not make a handoff supervised. Never substitute a\nnon-Orca subagent tool when Orca orchestration provenance was requested.\n\n## Authority and safety floor\n\n- A Run is a durable namespace and coordinator inbox; it does not schedule or\n place workers. A Task is work. A Dispatch is one authoritative Task attempt.\n- Lifecycle authority comes from the active Dispatch, not a terminal title,\n copied ID, old database row, provider transcript, or visible pane.\n- Workers use the exact executable, handle, Task ID, and Dispatch ID in the live\n preamble, plus any other flag it carries. Never reconstruct, translate, or\n broaden those arguments.\n- After remote start, address the worker by Dispatch ID. The execution host owns\n process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts\n `live` / `unverifiable` / `exited`; contact loss is not process death.\n- Liveness is layered: `worker-list`'s `projection.liveness` is the fleet verdict\n for the agent; `worker-show`'s `observation.status` is PTY liveness only. A live\n terminal can still hold a dead or stuck agent.\n- Folder workspaces are valid; never require Git or assume a worktree.\n- Clients and remote servers update independently. Treat unknown optional fields\n as absent. A new stream operation requires advertised capability because old\n decoders may silently drop unknown opcodes. Never fall back to local execution\n when remote authority or capability is unproven.\n- Use the executable you used to run `skills get` for the entire run. In the\n examples below, replace `ORCA` with it; do not create a shell variable or run\n `ORCA` literally. If it fails, report that exact error instead of switching.\n- `ORCA status --json` shows your Orca session ID as `caller.orcaSessionId` when you have one.\n- A successful `orchestration send` proves durable enqueue; its wake or nudge is\n best-effort attention only and does not prove the recipient read or accepted it.\n\n## Worker obligations\n\nThe injected preamble is authoritative. A dispatched worker must:\n\n1. Do only the current Task and use the preamble's `ask` command for a blocking\n coordinator question. Never open a local question TUI the coordinator cannot\n answer. Resume the same message ID after an ask timeout.\n2. Send heartbeats only at the cadence in the preamble. A heartbeat proves\n liveness, not completion.\n3. Read coordinator follow-ups at each natural checkpoint — before starting a\n new file, after a test run — and once more immediately before `worker_done`:\n `ORCA orchestration check --terminal --json`.\n4. Send `worker_done` exactly once, from the dispatched terminal, with a\n three-sentence executive summary, both lifecycle IDs, and explicit\n `--outcome succeeded` or `--outcome failed`. Never encode failure only in prose.\n5. Append `--files-modified` and `--report-path` only with real values when\n applicable. After `worker_done`, end the dispatched turn and idle; do not poll\n or start new work.\n\nA direct user instruction after completion starts new user-owned work and takes\nprecedence over the idle rule. Do not reuse the settled lifecycle IDs.\n\n## Canonical supervised loop\n\nConfirm the runtime, bind one Run, and start the full independent wave before\nwaiting. `worker-start --spec` creates the Task and its attempt in one call:\n\n```text\nORCA status --json\nORCA orchestration run-create --objective \"\" --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent codex --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent claude --json\nORCA orchestration check --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nIf `worker-start` exits non-zero, do not relaunch. Read the receipt's\n`failedStage` and `residualResources`, then load\n`references/recovery-and-cleanup.md`.\n\nUse `task-create` plus `worker-start --task ` for planned fan-out with\ndependencies or a retry of a known Task. Use dependencies only for real ordering\nand prefer parallel waves over chains deeper than three or four steps; nested\nworkers obey the depth limit, and a new Run does not reset the caller's depth.\n\nA consuming `check` names its caller with `--terminal `, never `--from`;\nomit it inside the coordinator's own Orca terminal. It returns the bound Run's\noldest FIFO Delivery and replays that batch until acknowledged. Process every\nmessage: reply to questions, validate each `worker_done` against the expected\nactive Dispatch, and decide each settled terminal's next owner before the ack:\n\n```text\nORCA orchestration reply --id --body \"\" --json\nORCA orchestration worker-release --dispatch --json\nORCA orchestration check --ack --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nKeep waiting until every expected Dispatch settles. A timeout or empty result is\na checkpoint, not a failure. Do not stop, retry, release, or launch a duplicate\neditor without the positive proof `## Outcome` requires.\n\nAfter three consecutive empty waits, stop waiting blindly and enumerate with\n`ORCA orchestration worker-list --include-remote --json` (defaults to the bound\nRun; `--run ` overrides; the receipt's `scope` names which), acting on\neach row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.\nRows come newest first and page at 100: while `page.hasMore`, follow `page.nextCursor` with `--cursor `.\nA `none` `nextAction` has no argv to run: read `liveness.reason` and keep waiting\nwith `check --wait`. Absence never earns an argv; settlement and pending work still do.\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose\n`worker-stop` or `worker-abandon` explicitly. `unverifiable` is absence,\nincluding when `worker-show` reports `agentWait` null. Absence never authorizes\nstop, abandon, retry, or release; keep waiting or inspect.\n\n`worker-start` is the normal path, composing placement, terminal readiness,\nprompt injection, and supervised resource ownership. `dispatch --inject` leaves\nan operator-created process unsupervised and is only for an expressiveness gap.\n\n## Task-spec contract\n\nEvery Task spec must be self-contained and name:\n\n- **Target:** the files, component, or environment in scope.\n- **Change:** the concrete result to produce.\n- **Constraints:** invariants, compatibility rules, and do-not-touch boundaries.\n- **Ownership:** what this worker may edit and any coordination boundary.\n- **Observable acceptance:** the test, output, or evidence that proves completion.\n\n## Completion accounting\n\nAfter an accepted success or failure report, immediately do exactly one:\n\n1. Reuse the same proven agent terminal for an immediate follow-up Dispatch.\n2. Record user-requested retention with `worker-retain`.\n3. Run `worker-release`.\n\nRelease is post-settlement cleanup, not cancellation. Only an accepted\nsettlement authorizes it; no other observation does. If release is uncertain,\nfollow its exact recovery receipt and never substitute `terminal close`.\n\nA valid `worker_done` settles the Task and Dispatch automatically; do not follow\nit with `task-update --status completed`. Enumerate the terminals still owing a\ndecision with `worker-list --run --terminal-state reclaimable --json`,\nand do not end the coordinator turn until it returns none.\n\n## Conditional references\n\nThis compact guide is sufficient for the normal local loop. At an action gate\nbelow, run `ORCA skills get orchestration --reference references/.md` and\nread only that document; `--references` lists the names. If the CLI rejects\n`--reference`, run `ORCA skills get orchestration --full` once instead: it\nreturns this exact kernel and every reference, so read only the named one. If an\nolder CLI rejects `--full`, keep this kernel's safety floor, use that command's\n`--help`, and never guess newer flags.\n\n| Action gate | Bundled reference |\n| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |\n| Expanded DAG waves, launch model/effort, same-terminal reuse, or review ownership | `references/coordinator-loop.md` |\n| You are a dispatched worker and the live preamble does not answer your question, or `check` returned an error | `references/worker-contract.md` |\n| New worktree, exact workspace, SSH, WSL, or connected-server placement | `references/placement-and-remote.md` |\n| Inbox replay, follow-up messages, group addresses, or decision gates | `references/messaging-and-gates.md` |\n| Failed/stopped/unknown attempts, retry, stop, abandon, retain, or uncertain release | `references/recovery-and-cleanup.md` |\n| Custom argv or terminal topology that `worker-start` cannot express | `references/low-level-topology.md` |\n| Any legacy label, adopted Run, compatibility receipt, or takeover | `references/legacy-contract-migration.md` |\n\nRetired scheduler commands are not aliases for Run creation. Recovery commands\nmust provide their exact next action; follow it with the same selected executable.\n" // oxfmt-ignore -const ORCHESTRATION_FULL_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Coordinate supervised Orca workers: threaded messages, blocking ask/reply,\n task dispatch, worker_done/escalation waits, task DAGs, decision gates,\n coordinator loops, and decomposing work across agents. Use `orca-cli` for full\n ownership handoffs — \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", \"another worktree\" — unless asked to supervise, monitor, or coordinate\n a DAG, and for terminal control, lightweight terminal prompts, shell commands,\n Orca worktree management, and reading or waiting on terminals.\n---\n\n# Orca orchestration\n\nOrchestration is Orca's structured coordination layer. It records who owns work,\nwhich attempt is authoritative, and when supervised work has settled.\n\n## Outcome\n\n**Result:** every in-scope Task has one explicit outcome and every settled worker\nterminal has a next owner or cleanup decision. **Next consumer:** the user who\nrequested supervision. **Done:** all expected Dispatches have settled, every\ndelivered message was processed before acknowledgment, each settled worker was\nreused, explicitly retained, or released, and the turn ends only when the report\nto that user names, per Task, its outcome, the evidence behind it, and any\nunresolved blocker.\n\n**Safe failure:** preserve work and authority and report the state as unknown or\n`unverifiable`. Only positive proof of exit authorizes stop, abandon, or retry,\nand only an accepted settlement authorizes release. Every other observation,\nabsence included, is a checkpoint.\n\n## Classify the role\n\n| Current context | Role | Route |\n| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ |\n| The user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use a decision gate, or manage ask/reply | Coordinator | Use the supervised loop below |\n| The current prompt contains a live injected preamble with Task and Dispatch IDs | Dispatched worker | Follow the preamble and the worker obligations below |\n| The user asks to hand off ownership or start another agent/worktree without supervision | Handoff owner | Use `orca-cli`; create no Run, Task, or Dispatch and do not monitor completion |\n| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |\n| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |\n\nModel or effort selection does not make a handoff supervised. Never substitute a\nnon-Orca subagent tool when Orca orchestration provenance was requested.\n\n## Authority and safety floor\n\n- A Run is a durable namespace and coordinator inbox; it does not schedule or\n place workers. A Task is work. A Dispatch is one authoritative Task attempt.\n- Lifecycle authority comes from the active Dispatch, not a terminal title,\n copied ID, old database row, provider transcript, or visible pane.\n- Workers use the exact executable, handle, Task ID, and Dispatch ID in the live\n preamble, plus any other flag it carries. Never reconstruct, translate, or\n broaden those arguments.\n- After remote start, address the worker by Dispatch ID. The execution host owns\n process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts\n `live` / `unverifiable` / `exited`; contact loss is not process death.\n- Liveness is layered: `worker-list`'s `projection.liveness` is the fleet verdict\n for the agent; `worker-show`'s `observation.status` is PTY liveness only. A live\n terminal can still hold a dead or stuck agent.\n- Folder workspaces are valid; never require Git or assume a worktree.\n- Clients and remote servers update independently. Treat unknown optional fields\n as absent. A new stream operation requires advertised capability because old\n decoders may silently drop unknown opcodes. Never fall back to local execution\n when remote authority or capability is unproven.\n- Use the executable you used to run `skills get` for the entire run. In the\n examples below, replace `ORCA` with it; do not create a shell variable or run\n `ORCA` literally. If it fails, report that exact error instead of switching.\n- `ORCA status --json` shows your Orca session ID as `caller.orcaSessionId` when you have one.\n- A successful `orchestration send` proves durable enqueue; its wake or nudge is\n best-effort attention only and does not prove the recipient read or accepted it.\n\n## Worker obligations\n\nThe injected preamble is authoritative. A dispatched worker must:\n\n1. Do only the current Task and use the preamble's `ask` command for a blocking\n coordinator question. Never open a local question TUI the coordinator cannot\n answer. Resume the same message ID after an ask timeout.\n2. Send heartbeats only at the cadence in the preamble. A heartbeat proves\n liveness, not completion.\n3. Read coordinator follow-ups at each natural checkpoint — before starting a\n new file, after a test run — and once more immediately before `worker_done`:\n `ORCA orchestration check --terminal --json`.\n4. Send `worker_done` exactly once, from the dispatched terminal, with a\n three-sentence executive summary, both lifecycle IDs, and explicit\n `--outcome succeeded` or `--outcome failed`. Never encode failure only in prose.\n5. Append `--files-modified` and `--report-path` only with real values when\n applicable. After `worker_done`, end the dispatched turn and idle; do not poll\n or start new work.\n\nA direct user instruction after completion starts new user-owned work and takes\nprecedence over the idle rule. Do not reuse the settled lifecycle IDs.\n\n## Canonical supervised loop\n\nConfirm the runtime, bind one Run, and start the full independent wave before\nwaiting. `worker-start --spec` creates the Task and its attempt in one call:\n\n```text\nORCA status --json\nORCA orchestration run-create --objective \"\" --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent codex --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent claude --json\nORCA orchestration check --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nIf `worker-start` exits non-zero, do not relaunch. Read the receipt's\n`failedStage` and `residualResources`, then load\n`references/recovery-and-cleanup.md`.\n\nUse `task-create` plus `worker-start --task ` for planned fan-out with\ndependencies or a retry of a known Task. Use dependencies only for real ordering\nand prefer parallel waves over chains deeper than three or four steps; nested\nworkers obey the depth limit, and a new Run does not reset the caller's depth.\n\nA consuming `check` names its caller with `--terminal `, never `--from`;\nomit it inside the coordinator's own Orca terminal. It returns the bound Run's\noldest FIFO Delivery and replays that batch until acknowledged. Process every\nmessage: reply to questions, validate each `worker_done` against the expected\nactive Dispatch, and decide each settled terminal's next owner before the ack:\n\n```text\nORCA orchestration reply --id --body \"\" --json\nORCA orchestration worker-release --dispatch --json\nORCA orchestration check --ack --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nKeep waiting until every expected Dispatch settles. A timeout or empty result is\na checkpoint, not a failure. Do not stop, retry, release, or launch a duplicate\neditor without the positive proof `## Outcome` requires.\n\nAfter three consecutive empty waits, stop waiting blindly and enumerate with\n`ORCA orchestration worker-list --include-remote --json` (defaults to the bound\nRun; `--run ` overrides; the receipt's `scope` names which), acting on\neach row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.\nRows come newest first and page at 100: while `page.hasMore`, follow `page.nextCursor` with `--cursor `.\nA `none` `nextAction` has no argv to run: read `liveness.reason` and keep waiting\nwith `check --wait`. Absence never earns an argv; settlement and pending work still do.\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose\n`worker-stop` or `worker-abandon` explicitly. `unverifiable` is absence,\nincluding when `worker-show` reports `agentWait` null. Absence never authorizes\nstop, abandon, retry, or release; keep waiting or inspect.\n\n`worker-start` is the normal path, composing placement, terminal readiness,\nprompt injection, and supervised resource ownership. `dispatch --inject` leaves\nan operator-created process unsupervised and is only for an expressiveness gap.\n\n## Task-spec contract\n\nEvery Task spec must be self-contained and name:\n\n- **Target:** the files, component, or environment in scope.\n- **Change:** the concrete result to produce.\n- **Constraints:** invariants, compatibility rules, and do-not-touch boundaries.\n- **Ownership:** what this worker may edit and any coordination boundary.\n- **Observable acceptance:** the test, output, or evidence that proves completion.\n\n## Completion accounting\n\nAfter an accepted success or failure report, immediately do exactly one:\n\n1. Reuse the same proven agent terminal for an immediate follow-up Dispatch.\n2. Record user-requested retention with `worker-retain`.\n3. Run `worker-release`.\n\nRelease is post-settlement cleanup, not cancellation. Only an accepted\nsettlement authorizes it; no other observation does. If release is uncertain,\nfollow its exact recovery receipt and never substitute `terminal close`.\n\nA valid `worker_done` settles the Task and Dispatch automatically; do not follow\nit with `task-update --status completed`. Enumerate the terminals still owing a\ndecision with `worker-list --run --terminal-state reclaimable --json`,\nand do not end the coordinator turn until it returns none.\n\n## Conditional references\n\nThis compact guide is sufficient for the normal local loop. At an action gate\nbelow, run `ORCA skills get orchestration --reference references/.md` and\nread only that document; `--references` lists the names. If the CLI rejects\n`--reference`, run `ORCA skills get orchestration --full` once instead: it\nreturns this exact kernel and every reference, so read only the named one. If an\nolder CLI rejects `--full`, keep this kernel's safety floor, use that command's\n`--help`, and never guess newer flags.\n\n| Action gate | Bundled reference |\n| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |\n| Expanded DAG waves, launch model/effort, same-terminal reuse, or review ownership | `references/coordinator-loop.md` |\n| You are a dispatched worker and the live preamble does not answer your question, or `check` returned an error | `references/worker-contract.md` |\n| New worktree, exact workspace, SSH, WSL, or connected-server placement | `references/placement-and-remote.md` |\n| Inbox replay, follow-up messages, group addresses, or decision gates | `references/messaging-and-gates.md` |\n| Failed/stopped/unknown attempts, retry, stop, abandon, retain, or uncertain release | `references/recovery-and-cleanup.md` |\n| Custom argv or terminal topology that `worker-start` cannot express | `references/low-level-topology.md` |\n| Any legacy label, adopted Run, compatibility receipt, or takeover | `references/legacy-contract-migration.md` |\n\nRetired scheduler commands are not aliases for Run creation. Recovery commands\nmust provide their exact next action; follow it with the same selected executable.\n\n---\n\n# Bundled references\n\nThese references belong to the version-matched guide above. Read only the documents named by its action gates.\n\n\n\n# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, Cursor, Antigravity, or Muse terminal, `--model`\naccepts an opaque provider model ID. Pass it only when the user named a model;\notherwise omit it so the worker inherits the user's configured agent default.\nAdd `--effort` only when that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\nORCA orchestration worker-start --task --worktree current --agent muse --model muse-spark-1.3 --json\n```\n\nOther agents, including `opencode`, reject `--model`; they run the model set in\ntheir own config, so a coordinator wanting a same-model opencode worker relies\non that config.\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n\n\n\n# Legacy contract migration\n\nLoad this reference only for an authority label, adopted Run, compatibility or\nrecovery receipt, or explicit legacy takeover. A newly created attempt always\nuses the current grammar.\n\n## Authority labels\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported\n command printed with the message, using the same selected executable and\n arguments supplied by the original prompt.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded,\n at-least-once cutover replay. Process it idempotently and acknowledge only\n through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or\n lifecycle mutation.\n- An unlabeled current message uses the current guide and grammar.\n\nAn explicitly selected current Run, attested current binding, current Dispatch,\nor federated attachment takes precedence over legacy fallback. A retained\nadoption record alone does not grant mutation authority. If liveness, principal\nownership, capability, or the exact legacy contract is unproven, degrade to\nread-only inspection and never fall back to local execution.\n\nAdoption preserves the live agent process, PTY/session, terminal handle,\ntab/pane, worktree or folder workspace, Task, and Dispatch. It never restarts or\nreplaces the worker and never revives the retired scheduler. Loss of lifecycle\nauthority does not invalidate the existing process, assignment, or filesystem\nwork. Exact recovery may restore the same PTY once in its original inactive\nbackground tab; it must not spawn, write, signal, stop, switch, focus, split, or\ninject a terminal.\n\n## Compatibility recovery\n\nWhen a compatibility response returns structured next-step arguments, execute\nthose exact arguments with the same selected CLI executable. Do not translate\nfrom memory, broaden the recipient, or retry as a current mutation unless the\nreceipt explicitly authorizes it.\n\nA pending ask, reply, final Dispatch settlement, and consuming check have\ndurable recovery identities. Heartbeat and escalation remain at-least-once\nacross a manual contract-boundary retry. If an ask may already have been\nanswered, run the exact non-consuming recovery check printed by Orca before\ncreating any new question. Never guess among identical question threads.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The\ninitial command commits the question, prints its exact\n`ask --resume ` command, and exits with launcher status `75`. Run\nthat exact resume after the launcher or update boundary. For an attested WSL\nlaunch, preserve the printed `orca-ide` executable and distro route. Older WSL\nworkers without launch proof remain lifecycle read-only even while their\nterminal and filesystem work continue.\n\n## Read-only inspection and takeover\n\nRead-only inspection does not consume mail:\n\n```text\nORCA orchestration run-list --json\nORCA orchestration run-show --id run_legacy_local --json\nORCA orchestration run-show --id --json\nORCA orchestration task-list --run --json\nORCA orchestration inbox --full --json\nORCA orchestration check --terminal --peek --format --json\nORCA terminal read --terminal --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\n`run_legacy_local` is an empty audit tombstone after adoption. Find the ordinary\nRun whose objective is `Recovered orchestration work from a contract update`.\n\nOnly when the original coordinator is unavailable or cannot prove retained\nauthority may a new live coordinator take over from its own terminal:\n\n```text\nORCA orchestration run-use --id --takeover-legacy --json\nORCA orchestration check --run --json\n```\n\nTakeover binds the authenticated invoking terminal; `--from` cannot nominate\nanother coordinator. It fences only the old coordinator and moves pending mail\ninto current Run delivery. It preserves live workers, Tasks, Dispatches, processes, and files.\nNever take over while the original coordinator is actively coordinating.\n\nDo not launch a replacement editor merely because Orca updated or authority is\nunclear. Keep the original worker as the only editor until a stable handoff\npoint, then use a fresh current Dispatch in a conflict-free placement.\n\n\n\n# Low-level topology\n\nLoad this reference only when `worker-start` cannot express required custom argv\nor terminal topology. It is not the normal supervised loop and is never a full\nhandoff recipe.\n\n```text\nORCA terminal create --worktree active --title --command \"\" --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\nORCA orchestration dispatch --task --to --inject --json\n```\n\nWait for readiness only when startup could lose injected input. Prefer\nagent-first `worker-start` whenever its argv and topology are sufficient.\n\n`dispatch --inject` creates authoritative Task/Dispatch context but deliberately\nkeeps an operator-created process unsupervised: it creates no supervised worker\nresource row. `worker-show`, `worker-read`, and `worker-list` report the lane as\n`unsupervised`; `worker-stop` and `worker-abandon` do not close that process, and\nsettled retain/release take no process action.\n\nUse `worker-start --terminal ` when lifecycle ownership of an existing\nagent terminal is required. Never imply that low-level dispatch retroactively\nowns a process, never use it to route around the nested-depth limit, and never\nuse it for an ownership handoff.\n\n\n\n# Messaging and gates\n\nLoad this reference for inbox replay, attempt-specific guidance, group\naddresses, blocking questions, or coordinator-managed DAG decisions.\n\nA successful `send` proves durable enqueue. Wake and nudge are best-effort\nattention only: neither proves the recipient read the message, began a turn, or\naccepted steering.\n\n## Coordinator delivery loop\n\n`check` names its caller with `--terminal ` and is the only verb that\nrejects `--from`. Omit `--terminal` inside an Orca terminal, where Orca resolves\nthe caller; pass it explicitly from anywhere else, including a dispatched\nworker reading coordinator follow-ups.\n\nA consuming coordinator `check` returns the bound Run's oldest FIFO Delivery,\nup to 50 messages, and replays that exact batch until acknowledged. Process\nevery row and required terminal ownership decision before `--ack`. Type filters\ndecide when a waiter wakes; they do not authorize skipping older actionable\nmail. A Delivery therefore always carries the whole FIFO batch whatever its\ntypes, and a `check` without `--wait` hands that batch over unfiltered.\n`--peek` and `--all` are read-only inspection, not progress through the\ncoordinator inbox.\n\nAn empty wait or timeout is a checkpoint. Continue rolling waits until every\nexpected Dispatch settles. Heartbeat or visible activity means alive, not done.\n\n## Addresses\n\nUse a stable Dispatch address for attempt-specific coordinator guidance:\n\n```text\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nDo not substitute a remote terminal handle. Omit `--from` for ordinary\ncoordinator calls; a dispatched worker instead copies the exact `--from` and\nother arguments in its preamble. `check` is the exception: it identifies\nits caller with `--terminal`, never `--from`.\n\nGroup addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,\n`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. Every group but\n`@worktree:` means the live Dispatches of the sender's own Run. Mail goes\nto each `dispatch:` mailbox, except a worker coordinating a child Run\nreceives it in that `run:` mailbox. A sender bound to no Run is refused;\n`--run` must match the group audience and never grants membership.\nA Run group excludes its owning coordinator; a worker raising a blocker sends\nto `run:`. A worker that created its own Run addresses that Run's workers,\nnot its siblings. `@worktree:` reaches matching workspace terminals,\nincluding coordinators. Use groups only for intentional fan-out status or\nquestions. `worker_done`, heartbeat, and other\nDispatch lifecycle messages never target groups.\n\n## Questions and gates\n\nA worker uses `ask`; its timeout leaves one durable question pending, which the\nworker resumes by message ID. The coordinator answers that message with `reply`.\n\nUse a gate only for a coordinator-owned Task-DAG decision:\n\n```text\nORCA orchestration gate-create --task --question \"\" --options --json\nORCA orchestration gate-resolve --id --resolution \"\" --json\nORCA orchestration gate-list --task --json\n```\n\nPass `json_array` using the quoting rules of the active shell; do not copy POSIX\nsingle-quote syntax into PowerShell or `cmd.exe`.\n\nDo not create a gate merely to answer a worker's `ask`.\n\n\n\n# Placement and remote execution\n\nLoad this reference before creating a new worktree or placing work through SSH,\nWSL, or another connected Orca server.\n\n## Placement choices\n\nA fresh worker means a fresh agent terminal, not a new Git worktree. Use the\ncurrent or an exact existing workspace by default. Create a worktree only when\nthe user requested one or a concrete checkout or filesystem conflict makes\nsharing unsafe.\n\n```text\n# Current workspace; setup is not rerun.\nORCA orchestration worker-start --task --worktree current --agent codex --json\n\n# Stacked child worktree.\nORCA orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n\n# Independent top-level worktree.\nORCA orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nCurrent and exact existing workspaces create a fresh terminal unless\n`--terminal` is explicit. Folder workspaces are first-class; do not invoke Git\nor require worktree lineage when the selected workspace is a folder.\n\nRegister a folder workspace through project setup. `repo add --path `\nrequires a valid Git repository and rejects a plain directory:\n\n```text\nORCA project setup-existing-folder --project --host --path --kind folder --json\n```\n\nThen place work on the returned workspace with an exact selector. A worktree\nselector needs the full `::` value Orca returned, passed as\n`id:`; a bare repo id is not a worktree id. `new-child` and\n`new-top-level` are worktree creation and do not apply to a folder.\n\nNew worktrees use agent-first creation and run setup by default. Preserve the\nrepository's startup policy: `start-immediately` can report setup as `running`,\nwhile `wait-for-setup` gates prompt delivery on success. Orca lineage, Git base,\nfilesystem isolation, coordination parentage, UI grouping, and execution host\nare separate decisions.\n\n## Connected servers\n\nThe Run and Tasks remain authoritative on the current server. `--on` selects\nonly the worker's execution server and appears only on `worker-start`:\n\n```text\nORCA orchestration worker-start --task --on --worktree new-top-level --repo --name --agent codex --setup run --json\n```\n\nRemote `current` and `new-child` are invalid because they are ambiguous across\nservers. Use an exact discovered remote workspace, or `new-top-level` with an\nexact remote repository selector. After start, route every follow-up, read,\nstop, and cleanup by Dispatch ID; never repeat `--on` or substitute a remote\nterminal handle.\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\nORCA orchestration worker-list --run --include-remote --json\n```\n\n`worker-list` reads local fleet state only; enumerate remote workers with\n`--include-remote` or every one of them reads `unverifiable`. Scope every list\nwith `--run `: unscoped, it reports every Dispatch this runtime has\nrecorded, and the workers you are waiting on are lost in that history.\n\n## Execution-host and mixed-version floor\n\nThe execution host owns process, filesystem, transcript, stop, and cleanup\nfacts. Render only `live`, `unverifiable`, or `exited`. Connection loss, relay\nabsence, missing client inventory, or timeout yields `unverifiable`, never\nsynthetic exit and never a client-local substitute action.\n\nClients and servers update independently. Optional response fields may be\nabsent. Forward model/effort, transcript reads, cleanup, or another new remote\noperation only when the peer advertises the relevant capability; unknown stream\nopcodes can be silently dropped. A narrow unsupported response may degrade to a\ndocumented older path, but must not broaden the target or cross the execution\nboundary. Changing host-published content reaches old clients even without a\nwire-shape change, so preserve established semantics or negotiate the behavior.\n\nFor WSL, use the exact executable and arguments returned by Orca so the distro\nand packaged launcher remain bound. Do not translate a printed `orca-ide`\nrecovery command into a PATH-resolved local command.\n\n\n\n# Recovery and cleanup\n\nLoad this reference only after a failed/stopped/unknown attempt, explicit retry\ndecision, stop/abandon request, retention request, or uncertain release.\n\n| Proven state | Safe action |\n| ----------------------- | ------------------------------------------------------------------ |\n| `ready` or active | Keep waiting; optionally read bounded output |\n| `failed` or `stopped` | Start a replacement with `--retry-of`; repeat placement explicitly |\n| `outcome_unknown` | Inspect, then choose `worker-stop` or explicit `worker-abandon` |\n| Accepted `worker_done` | Reuse, retain, or release |\n| Remote contact lost | Preserve `unverifiable`; do not stop or retry from absence alone |\n| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |\n| Proven `exited` agent | Enumerate with `worker-list`; follow its `nextAction` |\n\n## Inspect before acting\n\n```text\nORCA orchestration worker-list --run --json\nORCA orchestration worker-list --run --include-remote --json\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\n```\n\n`worker-list` is the enumerating command and the authority on agent liveness:\neach row carries `projection.liveness`, `projection.attention.categories`,\n`projection.attention.requiresAction`, and a literal `projection.nextAction`\nargv to run. Always scope it with `--run `; an unscoped list reports\nevery Dispatch this runtime has ever recorded and buries the live ones.\n`worker-show`'s `observation.status` is PTY liveness only, so a `live` terminal\nwhose agent died at a trust prompt still reads `live` there.\n\nWhen the two disagree, the fleet verdict decides — unless the fleet row is\n`unverifiable` for a reason that names a gap on this client rather than a fact\nabout the worker. `missing_status`, `host_unavailable`, and\n`capability_unsupported` are such gaps: the first means this runtime holds no\nstatus row, the second that it could not ask the execution host at all, and the\nthird that a stale peer answered but lacks the fleet-snapshot capability.\nAgainst any of them, a `worker-show` verdict sourced from the execution host is\nthe better evidence and outranks the row. Only `host_unavailable` is contact\nloss; the other two mean the host was never asked or answered without the\ncapability.\n\nThis never promotes absence. `unverifiable` from either command still authorizes\nnothing — only a positive `live` or `exited` verdict does.\n\nA worker started with `--on ` reads `unverifiable` until you\nenumerate with `--include-remote`, which asks its execution host for the\nverdict. Rows come newest first and past 100 the response pages, so follow\n`page.nextCursor` with `--cursor ` until `page.hasMore` is false.\n\n## Stall needs positive evidence\n\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Only then choose `worker-stop` or `worker-abandon`.\n\n`unverifiable` is always absence — `missing_status`, `stale_status`,\n`restored_unconfirmed`, or a remote worker with no connection — and a null\n`agentWait` or an unchanged `worker-read` tail is that same absence seen again.\nAbsence never authorizes stop, abandon, retry, or release: keep waiting, or\ninspect until you hold one of the positive signals above. A `nextAction` that\nnames an inspecting command is asking for evidence, not for cleanup.\n\n`worker-read --source auto` uses a proven provider transcript when available and\notherwise returns bounded terminal output with a typed `fallbackReason`.\nContinue with its top-level cursor, which is pinned to that source. If Orca\nreports `source_changed`, restart without the old cursor. A bounded initial\ntranscript tail can return an EOF cursor that follows only newly appended records;\nread `contentComplete`, `clipping`, and `warnings` before assuming omitted older\nrecords are pageable. Never guess a provider session ID, transcript path, or\nremote terminal handle.\n\n## Was the mutation applied?\n\nWhen a mutation's response was lost and named no Dispatch, do not replay blind.\nEvery orchestration mutation accepts `--retry-request `, which reuses one\noperation identity so Orca can replay, join, or recover it instead of starting a\nduplicate. Ask what happened first:\n\n```text\nORCA orchestration request-show --request --json\n```\n\n`completed` means the mutation already took effect; read its recorded receipt\ninstead of rerunning. `pending` means the original mutation is still running or\nOrca restarted before recording its outcome; replay the original command with\n`--retry-request `. `absent` means this runtime holds no receipt\nunder your caller identity — that is not proof nothing happened, so inspect the\naffected Task, Dispatch, and terminal before deciding whether to retry.\n\nWhen a worker's terminal accepted input but the submit is unconfirmed, use\n`terminal send --wait-submit `: it observes the accepted prompt for that\nlong and, on timeout, returns the input-accepted receipt without resending.\n\n## Refused starts\n\n`dispatch` and `worker-start` refuse the following preflight cases with a stable\n`error.code`; read it before choosing a recovery, and treat `error.data.nextSteps`\nas the exact recovery text. Older hosts may omit `data`, so treat every field as\noptional.\n\n| Code | Meaning | Recovery |\n| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |\n| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |\n| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |\n| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |\n\n## Retry, stop, and abandon\n\nRetry only a positively proven failed or stopped attempt. Name the failed Task\nwith `--task`, since `--spec` creates a new one. Placement is never silently\ninherited:\n\n```text\nORCA orchestration worker-start --task --retry-of --worktree --agent --json\n```\n\nAfter three consecutive failures for one Task, its dispatch context\ncircuit-breaks and the Task is failed. Do not route around that boundary with a\nnew Run or an unrelated Dispatch.\n\nFor `outcome_unknown`, inspect first, then make an explicit choice:\n\n```text\nORCA orchestration worker-stop --dispatch --json\nORCA orchestration worker-abandon --dispatch --json\n```\n\n`worker-stop` closes only the exact proven supervised agent terminal. It never\ndeletes the worktree, setup terminal, configured tabs, or unrelated processes.\n`worker-abandon` fences orchestration while accepting that resources may remain\nlive; it performs no remote, process, or filesystem action. When it settles a\nworker, it retains an owned terminal by the same rule as `worker-retain`, so Orca\nstops owing its release; a release already committed (`releasing`,\n`release_unknown`) is left as it is. An already-settled worker is left untouched.\n\nTo cancel a Task, settle its worker with `worker-stop` or `worker-abandon`, then\nrecord the reason. A cancelled Task is `failed`, so its dependents stay blocked\nuntil a `--retry-of` replacement completes it:\n\n```text\nORCA orchestration task-update --id --status failed --result cancelled --json\n```\n\n## Retain and release\n\n```text\nORCA orchestration worker-retain --dispatch --json\nORCA orchestration worker-release --dispatch --json\n```\n\nRetain only when the user explicitly wants the settled terminal kept live.\nRelease works after succeeded and failed reports, archives readable output, and\ncloses only the exact terminal owned by that settled Dispatch. Replays may call\nrelease again safely. Reused, pre-existing, setup, coordinator, active,\nuser-taken-over, and unproven terminals are retained.\n\nA `worker-start` that failed before its agent was ready still owns the terminal\nit created. Its receipt names `worker-release`, and `worker-list` reports that\nrow as `reclaimable`; release it there rather than closing the terminal by hand.\n\nNever release because of timeout, TUI idle, heartbeat, status, question,\nescalation, or stale/rejected completion. If the receipt says `release_pending`\nor `release_unknown`, follow its exact recovery action. Never substitute\n`terminal close`.\n\n`orchestration reset` is destructive recovery. Do not run it during active\ncoordination unless the user explicitly abandons that state.\n\n\n\n# Worker contract\n\nThe injected preamble is authoritative. Copy its command rather than\nreconstructing flags. In particular, preserve the exact executable, worker\nhandle, Task ID, and Dispatch ID, and keep any other flag it carries (an older\nOrca host adds `--dispatch-capability`).\n\n## Heartbeat\n\nSend heartbeats only at the cadence required by the live preamble. Skip them\nwhile blocked inside `ask` or `check --wait`; those calls are liveness signals.\n\n```text\nORCA orchestration send --from --type heartbeat --subject \"alive\" --task-id --dispatch-id --phase \"\"\n```\n\nUse typed lifecycle flags, not a hand-written JSON payload. A heartbeat proves\nliveness, never completion.\n\n## Ask and resume\n\nUse Orca `ask` whenever the coordinator must answer. Never open a local question\nTUI the coordinator cannot answer.\n\n```text\nORCA orchestration ask --from --question \"\" --options \",\" --timeout-ms 600000\n\nORCA orchestration ask --from --resume --timeout-ms 600000\n```\n\nA timeout or disconnect leaves the original question pending. Resume its\nmessage ID; do not create a duplicate question.\n\n## Reading coordinator follow-ups\n\nThe coordinator steers a running worker with `send --to dispatch:`. That\nenqueue is durable but does not interrupt you, so nothing arrives unless you\nlook:\n\n```text\nORCA orchestration check --terminal --json\n```\n\nRun it at each natural checkpoint — before starting a new file, after a test\nrun — and once more immediately before `worker_done`, so a redirect or a\ncancellation lands before the Task settles. `check` names its caller with\n`--terminal`, never `--from`. Stop checking after `worker_done`.\n\nIf `check` returns `consumer_fenced`, this process no longer owns its Dispatch:\nthe Attempt was re-attached to another worker or settled without you. Stop, do\nnot send `worker_done`, and do not retry the check. An empty `check` never means\nyou were replaced; `consumer_fenced` is the only way you learn that.\nIf `send` or `ask` returns `consumer_fenced`, the command ran from another\nparty's terminal (a coordinator or another worker); run it from your own terminal.\n\n## Escalation\n\nEscalate only before completion and only when the coordinator must intervene:\n\n```text\nORCA orchestration send --from --type escalation --subject \"Blocked: \" --body \"
\" --task-id --dispatch-id \n```\n\n## Completion\n\nSend exactly one terminal report. `--body` is three sentences: what changed,\nwhat was found, and what remains. Use `--outcome failed` when the requested work\nis not complete; never hide failure in prose or silently exit.\n\nAppend `--files-modified` or `--report-path` only when applicable, using actual\npaths. Do not send documentation placeholders as metadata.\n\n```text\nORCA orchestration send --from --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded\n```\n\nAfter `worker_done`, end the dispatched turn and idle. Do not poll, close your\nown terminal, or begin unrelated work. A later direct user instruction is new\nuser-owned work and must not reuse settled lifecycle IDs; a supervised follow-up\narrives with a fresh preamble and Task block.\n" +const ORCHESTRATION_FULL_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Coordinate supervised Orca workers: threaded messages, blocking ask/reply,\n task dispatch, worker_done/escalation waits, task DAGs, decision gates,\n coordinator loops, and decomposing work across agents. Use `orca-cli` for full\n ownership handoffs — \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", \"another worktree\" — unless asked to supervise, monitor, or coordinate\n a DAG, and for terminal control, lightweight terminal prompts, shell commands,\n Orca worktree management, and reading or waiting on terminals.\n---\n\n# Orca orchestration\n\nOrchestration is Orca's structured coordination layer. It records who owns work,\nwhich attempt is authoritative, and when supervised work has settled.\n\n## Outcome\n\n**Result:** every in-scope Task has one explicit outcome and every settled worker\nterminal has a next owner or cleanup decision. **Next consumer:** the user who\nrequested supervision. **Done:** all expected Dispatches have settled, every\ndelivered message was processed before acknowledgment, each settled worker was\nreused, explicitly retained, or released, and the turn ends only when the report\nto that user names, per Task, its outcome, the evidence behind it, and any\nunresolved blocker.\n\n**Safe failure:** preserve work and authority and report the state as unknown or\n`unverifiable`. Only positive proof of exit authorizes stop, abandon, or retry,\nand only an accepted settlement authorizes release. Every other observation,\nabsence included, is a checkpoint.\n\n## Classify the role\n\n| Current context | Role | Route |\n| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ |\n| The user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use a decision gate, or manage ask/reply | Coordinator | Use the supervised loop below |\n| The current prompt contains a live injected preamble with Task and Dispatch IDs | Dispatched worker | Follow the preamble and the worker obligations below |\n| The user asks to hand off ownership or start another agent/worktree without supervision | Handoff owner | Use `orca-cli`; create no Run, Task, or Dispatch and do not monitor completion |\n| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |\n| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |\n\nModel or effort selection does not make a handoff supervised. Never substitute a\nnon-Orca subagent tool when Orca orchestration provenance was requested.\n\n## Authority and safety floor\n\n- A Run is a durable namespace and coordinator inbox; it does not schedule or\n place workers. A Task is work. A Dispatch is one authoritative Task attempt.\n- Lifecycle authority comes from the active Dispatch, not a terminal title,\n copied ID, old database row, provider transcript, or visible pane.\n- Workers use the exact executable, handle, Task ID, and Dispatch ID in the live\n preamble, plus any other flag it carries. Never reconstruct, translate, or\n broaden those arguments.\n- After remote start, address the worker by Dispatch ID. The execution host owns\n process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts\n `live` / `unverifiable` / `exited`; contact loss is not process death.\n- Liveness is layered: `worker-list`'s `projection.liveness` is the fleet verdict\n for the agent; `worker-show`'s `observation.status` is PTY liveness only. A live\n terminal can still hold a dead or stuck agent.\n- Folder workspaces are valid; never require Git or assume a worktree.\n- Clients and remote servers update independently. Treat unknown optional fields\n as absent. A new stream operation requires advertised capability because old\n decoders may silently drop unknown opcodes. Never fall back to local execution\n when remote authority or capability is unproven.\n- Use the executable you used to run `skills get` for the entire run. In the\n examples below, replace `ORCA` with it; do not create a shell variable or run\n `ORCA` literally. If it fails, report that exact error instead of switching.\n- `ORCA status --json` shows your Orca session ID as `caller.orcaSessionId` when you have one.\n- A successful `orchestration send` proves durable enqueue; its wake or nudge is\n best-effort attention only and does not prove the recipient read or accepted it.\n\n## Worker obligations\n\nThe injected preamble is authoritative. A dispatched worker must:\n\n1. Do only the current Task and use the preamble's `ask` command for a blocking\n coordinator question. Never open a local question TUI the coordinator cannot\n answer. Resume the same message ID after an ask timeout.\n2. Send heartbeats only at the cadence in the preamble. A heartbeat proves\n liveness, not completion.\n3. Read coordinator follow-ups at each natural checkpoint — before starting a\n new file, after a test run — and once more immediately before `worker_done`:\n `ORCA orchestration check --terminal --json`.\n4. Send `worker_done` exactly once, from the dispatched terminal, with a\n three-sentence executive summary, both lifecycle IDs, and explicit\n `--outcome succeeded` or `--outcome failed`. Never encode failure only in prose.\n5. Append `--files-modified` and `--report-path` only with real values when\n applicable. After `worker_done`, end the dispatched turn and idle; do not poll\n or start new work.\n\nA direct user instruction after completion starts new user-owned work and takes\nprecedence over the idle rule. Do not reuse the settled lifecycle IDs.\n\n## Canonical supervised loop\n\nConfirm the runtime, bind one Run, and start the full independent wave before\nwaiting. `worker-start --spec` creates the Task and its attempt in one call:\n\n```text\nORCA status --json\nORCA orchestration run-create --objective \"\" --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent codex --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent claude --json\nORCA orchestration check --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nIf `worker-start` exits non-zero, do not relaunch. Read the receipt's\n`failedStage` and `residualResources`, then load\n`references/recovery-and-cleanup.md`.\n\nUse `task-create` plus `worker-start --task ` for planned fan-out with\ndependencies or a retry of a known Task. Use dependencies only for real ordering\nand prefer parallel waves over chains deeper than three or four steps; nested\nworkers obey the depth limit, and a new Run does not reset the caller's depth.\n\nA consuming `check` names its caller with `--terminal `, never `--from`;\nomit it inside the coordinator's own Orca terminal. It returns the bound Run's\noldest FIFO Delivery and replays that batch until acknowledged. Process every\nmessage: reply to questions, validate each `worker_done` against the expected\nactive Dispatch, and decide each settled terminal's next owner before the ack:\n\n```text\nORCA orchestration reply --id --body \"\" --json\nORCA orchestration worker-release --dispatch --json\nORCA orchestration check --ack --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nKeep waiting until every expected Dispatch settles. A timeout or empty result is\na checkpoint, not a failure. Do not stop, retry, release, or launch a duplicate\neditor without the positive proof `## Outcome` requires.\n\nAfter three consecutive empty waits, stop waiting blindly and enumerate with\n`ORCA orchestration worker-list --include-remote --json` (defaults to the bound\nRun; `--run ` overrides; the receipt's `scope` names which), acting on\neach row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.\nRows come newest first and page at 100: while `page.hasMore`, follow `page.nextCursor` with `--cursor `.\nA `none` `nextAction` has no argv to run: read `liveness.reason` and keep waiting\nwith `check --wait`. Absence never earns an argv; settlement and pending work still do.\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose\n`worker-stop` or `worker-abandon` explicitly. `unverifiable` is absence,\nincluding when `worker-show` reports `agentWait` null. Absence never authorizes\nstop, abandon, retry, or release; keep waiting or inspect.\n\n`worker-start` is the normal path, composing placement, terminal readiness,\nprompt injection, and supervised resource ownership. `dispatch --inject` leaves\nan operator-created process unsupervised and is only for an expressiveness gap.\n\n## Task-spec contract\n\nEvery Task spec must be self-contained and name:\n\n- **Target:** the files, component, or environment in scope.\n- **Change:** the concrete result to produce.\n- **Constraints:** invariants, compatibility rules, and do-not-touch boundaries.\n- **Ownership:** what this worker may edit and any coordination boundary.\n- **Observable acceptance:** the test, output, or evidence that proves completion.\n\n## Completion accounting\n\nAfter an accepted success or failure report, immediately do exactly one:\n\n1. Reuse the same proven agent terminal for an immediate follow-up Dispatch.\n2. Record user-requested retention with `worker-retain`.\n3. Run `worker-release`.\n\nRelease is post-settlement cleanup, not cancellation. Only an accepted\nsettlement authorizes it; no other observation does. If release is uncertain,\nfollow its exact recovery receipt and never substitute `terminal close`.\n\nA valid `worker_done` settles the Task and Dispatch automatically; do not follow\nit with `task-update --status completed`. Enumerate the terminals still owing a\ndecision with `worker-list --run --terminal-state reclaimable --json`,\nand do not end the coordinator turn until it returns none.\n\n## Conditional references\n\nThis compact guide is sufficient for the normal local loop. At an action gate\nbelow, run `ORCA skills get orchestration --reference references/.md` and\nread only that document; `--references` lists the names. If the CLI rejects\n`--reference`, run `ORCA skills get orchestration --full` once instead: it\nreturns this exact kernel and every reference, so read only the named one. If an\nolder CLI rejects `--full`, keep this kernel's safety floor, use that command's\n`--help`, and never guess newer flags.\n\n| Action gate | Bundled reference |\n| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |\n| Expanded DAG waves, launch model/effort, same-terminal reuse, or review ownership | `references/coordinator-loop.md` |\n| You are a dispatched worker and the live preamble does not answer your question, or `check` returned an error | `references/worker-contract.md` |\n| New worktree, exact workspace, SSH, WSL, or connected-server placement | `references/placement-and-remote.md` |\n| Inbox replay, follow-up messages, group addresses, or decision gates | `references/messaging-and-gates.md` |\n| Failed/stopped/unknown attempts, retry, stop, abandon, retain, or uncertain release | `references/recovery-and-cleanup.md` |\n| Custom argv or terminal topology that `worker-start` cannot express | `references/low-level-topology.md` |\n| Any legacy label, adopted Run, compatibility receipt, or takeover | `references/legacy-contract-migration.md` |\n\nRetired scheduler commands are not aliases for Run creation. Recovery commands\nmust provide their exact next action; follow it with the same selected executable.\n\n---\n\n# Bundled references\n\nThese references belong to the version-matched guide above. Read only the documents named by its action gates.\n\n\n\n# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, Cursor, Antigravity, or Muse terminal, `--model`\naccepts an opaque provider model ID. Pass it only when the user named a model;\notherwise omit it so the worker inherits the user's configured agent default.\nAdd `--effort` only when that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\nORCA orchestration worker-start --task --worktree current --agent muse --model muse-spark-1.3 --json\n```\n\nOpenCode also accepts `--model` in an existing worktree when the execution host\nverifies its CLI version and model availability. Creating a new worktree with an\nOpenCode model override is unsupported; use `--worktree current` or an existing\nworktree selector, or omit `--model`. OpenCode effort is unsupported. Omit the\nmodel to inherit its configuration; unsupported or unknown hosts refuse the\noverride explicitly.\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n\n\n\n# Legacy contract migration\n\nLoad this reference only for an authority label, adopted Run, compatibility or\nrecovery receipt, or explicit legacy takeover. A newly created attempt always\nuses the current grammar.\n\n## Authority labels\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported\n command printed with the message, using the same selected executable and\n arguments supplied by the original prompt.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded,\n at-least-once cutover replay. Process it idempotently and acknowledge only\n through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or\n lifecycle mutation.\n- An unlabeled current message uses the current guide and grammar.\n\nAn explicitly selected current Run, attested current binding, current Dispatch,\nor federated attachment takes precedence over legacy fallback. A retained\nadoption record alone does not grant mutation authority. If liveness, principal\nownership, capability, or the exact legacy contract is unproven, degrade to\nread-only inspection and never fall back to local execution.\n\nAdoption preserves the live agent process, PTY/session, terminal handle,\ntab/pane, worktree or folder workspace, Task, and Dispatch. It never restarts or\nreplaces the worker and never revives the retired scheduler. Loss of lifecycle\nauthority does not invalidate the existing process, assignment, or filesystem\nwork. Exact recovery may restore the same PTY once in its original inactive\nbackground tab; it must not spawn, write, signal, stop, switch, focus, split, or\ninject a terminal.\n\n## Compatibility recovery\n\nWhen a compatibility response returns structured next-step arguments, execute\nthose exact arguments with the same selected CLI executable. Do not translate\nfrom memory, broaden the recipient, or retry as a current mutation unless the\nreceipt explicitly authorizes it.\n\nA pending ask, reply, final Dispatch settlement, and consuming check have\ndurable recovery identities. Heartbeat and escalation remain at-least-once\nacross a manual contract-boundary retry. If an ask may already have been\nanswered, run the exact non-consuming recovery check printed by Orca before\ncreating any new question. Never guess among identical question threads.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The\ninitial command commits the question, prints its exact\n`ask --resume ` command, and exits with launcher status `75`. Run\nthat exact resume after the launcher or update boundary. For an attested WSL\nlaunch, preserve the printed `orca-ide` executable and distro route. Older WSL\nworkers without launch proof remain lifecycle read-only even while their\nterminal and filesystem work continue.\n\n## Read-only inspection and takeover\n\nRead-only inspection does not consume mail:\n\n```text\nORCA orchestration run-list --json\nORCA orchestration run-show --id run_legacy_local --json\nORCA orchestration run-show --id --json\nORCA orchestration task-list --run --json\nORCA orchestration inbox --full --json\nORCA orchestration check --terminal --peek --format --json\nORCA terminal read --terminal --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\n`run_legacy_local` is an empty audit tombstone after adoption. Find the ordinary\nRun whose objective is `Recovered orchestration work from a contract update`.\n\nOnly when the original coordinator is unavailable or cannot prove retained\nauthority may a new live coordinator take over from its own terminal:\n\n```text\nORCA orchestration run-use --id --takeover-legacy --json\nORCA orchestration check --run --json\n```\n\nTakeover binds the authenticated invoking terminal; `--from` cannot nominate\nanother coordinator. It fences only the old coordinator and moves pending mail\ninto current Run delivery. It preserves live workers, Tasks, Dispatches, processes, and files.\nNever take over while the original coordinator is actively coordinating.\n\nDo not launch a replacement editor merely because Orca updated or authority is\nunclear. Keep the original worker as the only editor until a stable handoff\npoint, then use a fresh current Dispatch in a conflict-free placement.\n\n\n\n# Low-level topology\n\nLoad this reference only when `worker-start` cannot express required custom argv\nor terminal topology. It is not the normal supervised loop and is never a full\nhandoff recipe.\n\n```text\nORCA terminal create --worktree active --title --command \"\" --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\nORCA orchestration dispatch --task --to --inject --json\n```\n\nWait for readiness only when startup could lose injected input. Prefer\nagent-first `worker-start` whenever its argv and topology are sufficient.\n\n`dispatch --inject` creates authoritative Task/Dispatch context but deliberately\nkeeps an operator-created process unsupervised: it creates no supervised worker\nresource row. `worker-show`, `worker-read`, and `worker-list` report the lane as\n`unsupervised`; `worker-stop` and `worker-abandon` do not close that process, and\nsettled retain/release take no process action.\n\nUse `worker-start --terminal ` when lifecycle ownership of an existing\nagent terminal is required. Never imply that low-level dispatch retroactively\nowns a process, never use it to route around the nested-depth limit, and never\nuse it for an ownership handoff.\n\n\n\n# Messaging and gates\n\nLoad this reference for inbox replay, attempt-specific guidance, group\naddresses, blocking questions, or coordinator-managed DAG decisions.\n\nA successful `send` proves durable enqueue. Wake and nudge are best-effort\nattention only: neither proves the recipient read the message, began a turn, or\naccepted steering.\n\n## Coordinator delivery loop\n\n`check` names its caller with `--terminal ` and is the only verb that\nrejects `--from`. Omit `--terminal` inside an Orca terminal, where Orca resolves\nthe caller; pass it explicitly from anywhere else, including a dispatched\nworker reading coordinator follow-ups.\n\nA consuming coordinator `check` returns the bound Run's oldest FIFO Delivery,\nup to 50 messages, and replays that exact batch until acknowledged. Process\nevery row and required terminal ownership decision before `--ack`. Type filters\ndecide when a waiter wakes; they do not authorize skipping older actionable\nmail. A Delivery therefore always carries the whole FIFO batch whatever its\ntypes, and a `check` without `--wait` hands that batch over unfiltered.\n`--peek` and `--all` are read-only inspection, not progress through the\ncoordinator inbox.\n\nAn empty wait or timeout is a checkpoint. Continue rolling waits until every\nexpected Dispatch settles. Heartbeat or visible activity means alive, not done.\n\n## Addresses\n\nUse a stable Dispatch address for attempt-specific coordinator guidance:\n\n```text\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nDo not substitute a remote terminal handle. Omit `--from` for ordinary\ncoordinator calls; a dispatched worker instead copies the exact `--from` and\nother arguments in its preamble. `check` is the exception: it identifies\nits caller with `--terminal`, never `--from`.\n\nGroup addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,\n`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. Every group but\n`@worktree:` means the live Dispatches of the sender's own Run. Mail goes\nto each `dispatch:` mailbox, except a worker coordinating a child Run\nreceives it in that `run:` mailbox. A sender bound to no Run is refused;\n`--run` must match the group audience and never grants membership.\nA Run group excludes its owning coordinator; a worker raising a blocker sends\nto `run:`. A worker that created its own Run addresses that Run's workers,\nnot its siblings. `@worktree:` reaches matching workspace terminals,\nincluding coordinators. Use groups only for intentional fan-out status or\nquestions. `worker_done`, heartbeat, and other\nDispatch lifecycle messages never target groups.\n\n## Questions and gates\n\nA worker uses `ask`; its timeout leaves one durable question pending, which the\nworker resumes by message ID. The coordinator answers that message with `reply`.\n\nUse a gate only for a coordinator-owned Task-DAG decision:\n\n```text\nORCA orchestration gate-create --task --question \"\" --options --json\nORCA orchestration gate-resolve --id --resolution \"\" --json\nORCA orchestration gate-list --task --json\n```\n\nPass `json_array` using the quoting rules of the active shell; do not copy POSIX\nsingle-quote syntax into PowerShell or `cmd.exe`.\n\nDo not create a gate merely to answer a worker's `ask`.\n\n\n\n# Placement and remote execution\n\nLoad this reference before creating a new worktree or placing work through SSH,\nWSL, or another connected Orca server.\n\n## Placement choices\n\nA fresh worker means a fresh agent terminal, not a new Git worktree. Use the\ncurrent or an exact existing workspace by default. Create a worktree only when\nthe user requested one or a concrete checkout or filesystem conflict makes\nsharing unsafe.\n\n```text\n# Current workspace; setup is not rerun.\nORCA orchestration worker-start --task --worktree current --agent codex --json\n\n# Stacked child worktree.\nORCA orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n\n# Independent top-level worktree.\nORCA orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nCurrent and exact existing workspaces create a fresh terminal unless\n`--terminal` is explicit. Folder workspaces are first-class; do not invoke Git\nor require worktree lineage when the selected workspace is a folder.\n\nRegister a folder workspace through project setup. `repo add --path `\nrequires a valid Git repository and rejects a plain directory:\n\n```text\nORCA project setup-existing-folder --project --host --path --kind folder --json\n```\n\nThen place work on the returned workspace with an exact selector. A worktree\nselector needs the full `::` value Orca returned, passed as\n`id:`; a bare repo id is not a worktree id. `new-child` and\n`new-top-level` are worktree creation and do not apply to a folder.\n\nNew worktrees use agent-first creation and run setup by default. Preserve the\nrepository's startup policy: `start-immediately` can report setup as `running`,\nwhile `wait-for-setup` gates prompt delivery on success. Orca lineage, Git base,\nfilesystem isolation, coordination parentage, UI grouping, and execution host\nare separate decisions.\n\n## Connected servers\n\nThe Run and Tasks remain authoritative on the current server. `--on` selects\nonly the worker's execution server and appears only on `worker-start`:\n\n```text\nORCA orchestration worker-start --task --on --worktree new-top-level --repo --name --agent codex --setup run --json\n```\n\nRemote `current` and `new-child` are invalid because they are ambiguous across\nservers. Use an exact discovered remote workspace, or `new-top-level` with an\nexact remote repository selector. After start, route every follow-up, read,\nstop, and cleanup by Dispatch ID; never repeat `--on` or substitute a remote\nterminal handle.\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\nORCA orchestration worker-list --run --include-remote --json\n```\n\n`worker-list` reads local fleet state only; enumerate remote workers with\n`--include-remote` or every one of them reads `unverifiable`. Scope every list\nwith `--run `: unscoped, it reports every Dispatch this runtime has\nrecorded, and the workers you are waiting on are lost in that history.\n\n## Execution-host and mixed-version floor\n\nThe execution host owns process, filesystem, transcript, stop, and cleanup\nfacts. Render only `live`, `unverifiable`, or `exited`. Connection loss, relay\nabsence, missing client inventory, or timeout yields `unverifiable`, never\nsynthetic exit and never a client-local substitute action.\n\nClients and servers update independently. Optional response fields may be\nabsent. Forward model/effort, transcript reads, cleanup, or another new remote\noperation only when the peer advertises the relevant capability; unknown stream\nopcodes can be silently dropped. A narrow unsupported response may degrade to a\ndocumented older path, but must not broaden the target or cross the execution\nboundary. Changing host-published content reaches old clients even without a\nwire-shape change, so preserve established semantics or negotiate the behavior.\n\nFor WSL, use the exact executable and arguments returned by Orca so the distro\nand packaged launcher remain bound. Do not translate a printed `orca-ide`\nrecovery command into a PATH-resolved local command.\n\n\n\n# Recovery and cleanup\n\nLoad this reference only after a failed/stopped/unknown attempt, explicit retry\ndecision, stop/abandon request, retention request, or uncertain release.\n\n| Proven state | Safe action |\n| ----------------------- | ------------------------------------------------------------------ |\n| `ready` or active | Keep waiting; optionally read bounded output |\n| `failed` or `stopped` | Start a replacement with `--retry-of`; repeat placement explicitly |\n| `outcome_unknown` | Inspect, then choose `worker-stop` or explicit `worker-abandon` |\n| Accepted `worker_done` | Reuse, retain, or release |\n| Remote contact lost | Preserve `unverifiable`; do not stop or retry from absence alone |\n| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |\n| Proven `exited` agent | Enumerate with `worker-list`; follow its `nextAction` |\n\n## Inspect before acting\n\n```text\nORCA orchestration worker-list --run --json\nORCA orchestration worker-list --run --include-remote --json\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\n```\n\n`worker-list` is the enumerating command and the authority on agent liveness:\neach row carries `projection.liveness`, `projection.attention.categories`,\n`projection.attention.requiresAction`, and a literal `projection.nextAction`\nargv to run. Always scope it with `--run `; an unscoped list reports\nevery Dispatch this runtime has ever recorded and buries the live ones.\n`worker-show`'s `observation.status` is PTY liveness only, so a `live` terminal\nwhose agent died at a trust prompt still reads `live` there.\n\nWhen the two disagree, the fleet verdict decides — unless the fleet row is\n`unverifiable` for a reason that names a gap on this client rather than a fact\nabout the worker. `missing_status`, `host_unavailable`, and\n`capability_unsupported` are such gaps: the first means this runtime holds no\nstatus row, the second that it could not ask the execution host at all, and the\nthird that a stale peer answered but lacks the fleet-snapshot capability.\nAgainst any of them, a `worker-show` verdict sourced from the execution host is\nthe better evidence and outranks the row. Only `host_unavailable` is contact\nloss; the other two mean the host was never asked or answered without the\ncapability.\n\nThis never promotes absence. `unverifiable` from either command still authorizes\nnothing — only a positive `live` or `exited` verdict does.\n\nA worker started with `--on ` reads `unverifiable` until you\nenumerate with `--include-remote`, which asks its execution host for the\nverdict. Rows come newest first and past 100 the response pages, so follow\n`page.nextCursor` with `--cursor ` until `page.hasMore` is false.\n\n## Stall needs positive evidence\n\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Only then choose `worker-stop` or `worker-abandon`.\n\n`unverifiable` is always absence — `missing_status`, `stale_status`,\n`restored_unconfirmed`, or a remote worker with no connection — and a null\n`agentWait` or an unchanged `worker-read` tail is that same absence seen again.\nAbsence never authorizes stop, abandon, retry, or release: keep waiting, or\ninspect until you hold one of the positive signals above. A `nextAction` that\nnames an inspecting command is asking for evidence, not for cleanup.\n\n`worker-read --source auto` uses a proven provider transcript when available and\notherwise returns bounded terminal output with a typed `fallbackReason`.\nContinue with its top-level cursor, which is pinned to that source. If Orca\nreports `source_changed`, restart without the old cursor. A bounded initial\ntranscript tail can return an EOF cursor that follows only newly appended records;\nread `contentComplete`, `clipping`, and `warnings` before assuming omitted older\nrecords are pageable. Never guess a provider session ID, transcript path, or\nremote terminal handle.\n\n## Was the mutation applied?\n\nWhen a mutation's response was lost and named no Dispatch, do not replay blind.\nEvery orchestration mutation accepts `--retry-request `, which reuses one\noperation identity so Orca can replay, join, or recover it instead of starting a\nduplicate. Ask what happened first:\n\n```text\nORCA orchestration request-show --request --json\n```\n\n`completed` means the mutation already took effect; read its recorded receipt\ninstead of rerunning. `pending` means the original mutation is still running or\nOrca restarted before recording its outcome; replay the original command with\n`--retry-request `. `absent` means this runtime holds no receipt\nunder your caller identity — that is not proof nothing happened, so inspect the\naffected Task, Dispatch, and terminal before deciding whether to retry.\n\nWhen a worker's terminal accepted input but the submit is unconfirmed, use\n`terminal send --wait-submit `: it observes the accepted prompt for that\nlong and, on timeout, returns the input-accepted receipt without resending.\n\n## Refused starts\n\n`dispatch` and `worker-start` refuse the following preflight cases with a stable\n`error.code`; read it before choosing a recovery, and treat `error.data.nextSteps`\nas the exact recovery text. Older hosts may omit `data`, so treat every field as\noptional.\n\n| Code | Meaning | Recovery |\n| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |\n| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |\n| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |\n| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |\n\n## Retry, stop, and abandon\n\nRetry only a positively proven failed or stopped attempt. Name the failed Task\nwith `--task`, since `--spec` creates a new one. Placement is never silently\ninherited:\n\n```text\nORCA orchestration worker-start --task --retry-of --worktree --agent --json\n```\n\nAfter three consecutive failures for one Task, its dispatch context\ncircuit-breaks and the Task is failed. Do not route around that boundary with a\nnew Run or an unrelated Dispatch.\n\nFor `outcome_unknown`, inspect first, then make an explicit choice:\n\n```text\nORCA orchestration worker-stop --dispatch --json\nORCA orchestration worker-abandon --dispatch --json\n```\n\n`worker-stop` closes only the exact proven supervised agent terminal. It never\ndeletes the worktree, setup terminal, configured tabs, or unrelated processes.\n`worker-abandon` fences orchestration while accepting that resources may remain\nlive; it performs no remote, process, or filesystem action. When it settles a\nworker, it retains an owned terminal by the same rule as `worker-retain`, so Orca\nstops owing its release; a release already committed (`releasing`,\n`release_unknown`) is left as it is. An already-settled worker is left untouched.\n\nTo cancel a Task, settle its worker with `worker-stop` or `worker-abandon`, then\nrecord the reason. A cancelled Task is `failed`, so its dependents stay blocked\nuntil a `--retry-of` replacement completes it:\n\n```text\nORCA orchestration task-update --id --status failed --result cancelled --json\n```\n\n## Retain and release\n\n```text\nORCA orchestration worker-retain --dispatch --json\nORCA orchestration worker-release --dispatch --json\n```\n\nRetain only when the user explicitly wants the settled terminal kept live.\nRelease works after succeeded and failed reports, archives readable output, and\ncloses only the exact terminal owned by that settled Dispatch. Replays may call\nrelease again safely. Reused, pre-existing, setup, coordinator, active,\nuser-taken-over, and unproven terminals are retained.\n\nA `worker-start` that failed before its agent was ready still owns the terminal\nit created. Its receipt names `worker-release`, and `worker-list` reports that\nrow as `reclaimable`; release it there rather than closing the terminal by hand.\n\nNever release because of timeout, TUI idle, heartbeat, status, question,\nescalation, or stale/rejected completion. If the receipt says `release_pending`\nor `release_unknown`, follow its exact recovery action. Never substitute\n`terminal close`.\n\n`orchestration reset` is destructive recovery. Do not run it during active\ncoordination unless the user explicitly abandons that state.\n\n\n\n# Worker contract\n\nThe injected preamble is authoritative. Copy its command rather than\nreconstructing flags. In particular, preserve the exact executable, worker\nhandle, Task ID, and Dispatch ID, and keep any other flag it carries (an older\nOrca host adds `--dispatch-capability`).\n\n## Heartbeat\n\nSend heartbeats only at the cadence required by the live preamble. Skip them\nwhile blocked inside `ask` or `check --wait`; those calls are liveness signals.\n\n```text\nORCA orchestration send --from --type heartbeat --subject \"alive\" --task-id --dispatch-id --phase \"\"\n```\n\nUse typed lifecycle flags, not a hand-written JSON payload. A heartbeat proves\nliveness, never completion.\n\n## Ask and resume\n\nUse Orca `ask` whenever the coordinator must answer. Never open a local question\nTUI the coordinator cannot answer.\n\n```text\nORCA orchestration ask --from --question \"\" --options \",\" --timeout-ms 600000\n\nORCA orchestration ask --from --resume --timeout-ms 600000\n```\n\nA timeout or disconnect leaves the original question pending. Resume its\nmessage ID; do not create a duplicate question.\n\n## Reading coordinator follow-ups\n\nThe coordinator steers a running worker with `send --to dispatch:`. That\nenqueue is durable but does not interrupt you, so nothing arrives unless you\nlook:\n\n```text\nORCA orchestration check --terminal --json\n```\n\nRun it at each natural checkpoint — before starting a new file, after a test\nrun — and once more immediately before `worker_done`, so a redirect or a\ncancellation lands before the Task settles. `check` names its caller with\n`--terminal`, never `--from`. Stop checking after `worker_done`.\n\nIf `check` returns `consumer_fenced`, this process no longer owns its Dispatch:\nthe Attempt was re-attached to another worker or settled without you. Stop, do\nnot send `worker_done`, and do not retry the check. An empty `check` never means\nyou were replaced; `consumer_fenced` is the only way you learn that.\nIf `send` or `ask` returns `consumer_fenced`, the command ran from another\nparty's terminal (a coordinator or another worker); run it from your own terminal.\n\n## Escalation\n\nEscalate only before completion and only when the coordinator must intervene:\n\n```text\nORCA orchestration send --from --type escalation --subject \"Blocked: \" --body \"
\" --task-id --dispatch-id \n```\n\n## Completion\n\nSend exactly one terminal report. `--body` is three sentences: what changed,\nwhat was found, and what remains. Use `--outcome failed` when the requested work\nis not complete; never hide failure in prose or silently exit.\n\nAppend `--files-modified` or `--report-path` only when applicable, using actual\npaths. Do not send documentation placeholders as metadata.\n\n```text\nORCA orchestration send --from --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded\n```\n\nAfter `worker_done`, end the dispatched turn and idle. Do not poll, close your\nown terminal, or begin unrelated work. A later direct user instruction is new\nuser-owned work and must not reuse settled lifecycle IDs; a supervised follow-up\narrives with a fresh preamble and Task block.\n" // oxfmt-ignore -const ORCHESTRATION_COORDINATOR_LOOP_REFERENCE_MARKDOWN = "# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, Cursor, Antigravity, or Muse terminal, `--model`\naccepts an opaque provider model ID. Pass it only when the user named a model;\notherwise omit it so the worker inherits the user's configured agent default.\nAdd `--effort` only when that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\nORCA orchestration worker-start --task --worktree current --agent muse --model muse-spark-1.3 --json\n```\n\nOther agents, including `opencode`, reject `--model`; they run the model set in\ntheir own config, so a coordinator wanting a same-model opencode worker relies\non that config.\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n" +const ORCHESTRATION_COORDINATOR_LOOP_REFERENCE_MARKDOWN = "# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, Cursor, Antigravity, or Muse terminal, `--model`\naccepts an opaque provider model ID. Pass it only when the user named a model;\notherwise omit it so the worker inherits the user's configured agent default.\nAdd `--effort` only when that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\nORCA orchestration worker-start --task --worktree current --agent muse --model muse-spark-1.3 --json\n```\n\nOpenCode also accepts `--model` in an existing worktree when the execution host\nverifies its CLI version and model availability. Creating a new worktree with an\nOpenCode model override is unsupported; use `--worktree current` or an existing\nworktree selector, or omit `--model`. OpenCode effort is unsupported. Omit the\nmodel to inherit its configuration; unsupported or unknown hosts refuse the\noverride explicitly.\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n" // oxfmt-ignore const ORCHESTRATION_LEGACY_CONTRACT_MIGRATION_REFERENCE_MARKDOWN = "# Legacy contract migration\n\nLoad this reference only for an authority label, adopted Run, compatibility or\nrecovery receipt, or explicit legacy takeover. A newly created attempt always\nuses the current grammar.\n\n## Authority labels\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported\n command printed with the message, using the same selected executable and\n arguments supplied by the original prompt.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded,\n at-least-once cutover replay. Process it idempotently and acknowledge only\n through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or\n lifecycle mutation.\n- An unlabeled current message uses the current guide and grammar.\n\nAn explicitly selected current Run, attested current binding, current Dispatch,\nor federated attachment takes precedence over legacy fallback. A retained\nadoption record alone does not grant mutation authority. If liveness, principal\nownership, capability, or the exact legacy contract is unproven, degrade to\nread-only inspection and never fall back to local execution.\n\nAdoption preserves the live agent process, PTY/session, terminal handle,\ntab/pane, worktree or folder workspace, Task, and Dispatch. It never restarts or\nreplaces the worker and never revives the retired scheduler. Loss of lifecycle\nauthority does not invalidate the existing process, assignment, or filesystem\nwork. Exact recovery may restore the same PTY once in its original inactive\nbackground tab; it must not spawn, write, signal, stop, switch, focus, split, or\ninject a terminal.\n\n## Compatibility recovery\n\nWhen a compatibility response returns structured next-step arguments, execute\nthose exact arguments with the same selected CLI executable. Do not translate\nfrom memory, broaden the recipient, or retry as a current mutation unless the\nreceipt explicitly authorizes it.\n\nA pending ask, reply, final Dispatch settlement, and consuming check have\ndurable recovery identities. Heartbeat and escalation remain at-least-once\nacross a manual contract-boundary retry. If an ask may already have been\nanswered, run the exact non-consuming recovery check printed by Orca before\ncreating any new question. Never guess among identical question threads.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The\ninitial command commits the question, prints its exact\n`ask --resume ` command, and exits with launcher status `75`. Run\nthat exact resume after the launcher or update boundary. For an attested WSL\nlaunch, preserve the printed `orca-ide` executable and distro route. Older WSL\nworkers without launch proof remain lifecycle read-only even while their\nterminal and filesystem work continue.\n\n## Read-only inspection and takeover\n\nRead-only inspection does not consume mail:\n\n```text\nORCA orchestration run-list --json\nORCA orchestration run-show --id run_legacy_local --json\nORCA orchestration run-show --id --json\nORCA orchestration task-list --run --json\nORCA orchestration inbox --full --json\nORCA orchestration check --terminal --peek --format --json\nORCA terminal read --terminal --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\n`run_legacy_local` is an empty audit tombstone after adoption. Find the ordinary\nRun whose objective is `Recovered orchestration work from a contract update`.\n\nOnly when the original coordinator is unavailable or cannot prove retained\nauthority may a new live coordinator take over from its own terminal:\n\n```text\nORCA orchestration run-use --id --takeover-legacy --json\nORCA orchestration check --run --json\n```\n\nTakeover binds the authenticated invoking terminal; `--from` cannot nominate\nanother coordinator. It fences only the old coordinator and moves pending mail\ninto current Run delivery. It preserves live workers, Tasks, Dispatches, processes, and files.\nNever take over while the original coordinator is actively coordinating.\n\nDo not launch a replacement editor merely because Orca updated or authority is\nunclear. Keep the original worker as the only editor until a stable handoff\npoint, then use a fresh current Dispatch in a conflict-free placement.\n" diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index a03bd6c8023..43674b9d757 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -36,7 +36,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.', 'When reusing --terminal, pass --worktree for that terminal; current means the coordinator worktree.', '--agent takes an Orca agent id enabled on the worker server, such as claude, codex, cursor, antigravity, muse, zcode, opencode, or opencode2.', - '--model supports Claude, Codex, Cursor, Antigravity, and Muse opaque provider model ids; --effort requires --model. OMP accepts --model only; --effort is unsupported. Neither can combine with --terminal. Other agents, including opencode and zcode, launch with the model from their own config.', + '--model supports Claude, Codex, Cursor, Antigravity, and Muse opaque provider model ids; --effort requires --model. OMP accepts --model only; --effort is unsupported. Neither can combine with --terminal. OpenCode model selection requires an existing worktree, a verified execution-host CLI, and an available model; effort is unsupported. Other agents, including zcode, launch with the model from their own config.', 'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.', 'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.', "How the worker runs follows the user's own setting for new agent tabs; there is no flag for it and no caller needs to ask. A dispatch the setting cannot apply to still starts, so the placement, agent, and launch options passed here are always the ones honoured.", diff --git a/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt b/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt index de078fd731e..193c69c2765 100644 --- a/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt +++ b/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt @@ -2,9 +2,8 @@ # attributes a fresh agent the host builds; carries agentStartedTelemetry # resume continues an existing agent session; not a new start # mobile-followup the phone's session-tab launch; attribution is a separate follow-up -# The resolve-worktree-removal-target call also serves a bare typed command, which stays unattributed. -src/main/runtime/orca-runtime-create-agent-session.ts 2 attributes -src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts 1 attributes +# The shared execution-host builder also serves bare commands; only fresh-agent callers supply attribution. +src/main/opencode/opencode-model-startup-plan.ts 2 attributes src/main/runtime/runtime-worktree-agent-startup.ts 3 attributes src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts 1 resume src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts 1 mobile-followup diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 1d9bae4f45d..ac1511d1258 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -25,6 +25,7 @@ * is injected as a factory instead of branched on here. */ +import { assertOpenCodeModelLaunchPreferencesAbsent } from '../opencode/opencode-model-startup-plan' import { parsePaneKey } from '../../shared/stable-pane-id' import type { AgentLaunchIntent, @@ -76,6 +77,9 @@ export async function executeAgentLaunch( execution: AgentLaunchExecution ): Promise { const { intent, runtime } = execution + if (intent.reuseTerminal || intent.target.kind === 'create-worktree') { + assertOpenCodeModelLaunchPreferencesAbsent(intent.agent, intent.sessionOptions) + } const vocabulary = execution.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY const settings = readAgentLaunchModeSettings(runtime) const preflight = decideAgentLaunchMode({ diff --git a/src/main/agent-launch/agent-launch-opencode-model.test.ts b/src/main/agent-launch/agent-launch-opencode-model.test.ts new file mode 100644 index 00000000000..39906f8c779 --- /dev/null +++ b/src/main/agent-launch/agent-launch-opencode-model.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentLaunchIntent } from '../../shared/agent-launch-intent' +import { executeAgentLaunch } from './agent-launch-executor' + +function harness() { + const readSettings = vi.fn(() => { + throw new Error('settings_not_needed_for_refusal') + }) + const createSupport = vi.fn(async () => { + throw new Error('support_not_needed_for_refusal') + }) + const createTerminal = vi.fn(async () => ({ handle: 'term_new' })) + const deliverPrompt = vi.fn(async () => true) + const createWorktree = vi.fn(async () => ({ + worktreeId: 'wt_new', + startupTerminalHandle: 'term_new' + })) + return { + readSettings, + createTerminal, + deliverPrompt, + createWorktree, + run: (intent: AgentLaunchIntent) => + executeAgentLaunch({ + intent, + runtime: { + getClientSettings: readSettings, + getStructuredAgentSessionCreateSupport: createSupport + }, + surfaces: { + createTerminalAgent: createTerminal, + createStructuredSession: async () => { + throw new Error('structured_not_expected') + }, + deliverTerminalPrompt: deliverPrompt + }, + workspaces: { createWorktree } + }) + } +} + +const reused: AgentLaunchIntent = { + agent: 'opencode', + target: { kind: 'existing', worktree: 'folder:private' }, + reuseTerminal: { handle: 'term_existing' }, + prompt: { text: 'Read only', delivery: 'submit' }, + sessionOptions: { model: 'private-proof/model-b' } +} +const creating: AgentLaunchIntent = { + ...reused, + reuseTerminal: undefined, + target: { kind: 'create-worktree', create: { repo: 'id:private', name: 'task' } } +} + +describe('OpenCode model preferences on unsupported launch placements', () => { + it.each([reused, creating])( + 'refuses before reading settings or creating or delivering', + async (intent) => { + const h = harness() + await expect(h.run(intent)).rejects.toMatchObject({ code: 'capability_unsupported' }) + expect(h.readSettings).not.toHaveBeenCalled() + expect(h.createTerminal).not.toHaveBeenCalled() + expect(h.createWorktree).not.toHaveBeenCalled() + expect(h.deliverPrompt).not.toHaveBeenCalled() + } + ) + + it('preserves prompt delivery to a reused terminal without model preferences', async () => { + const h = harness() + expect((await h.run({ ...reused, sessionOptions: undefined })).outcome).toEqual({ + kind: 'terminal', + handle: 'term_existing' + }) + expect(h.deliverPrompt).toHaveBeenCalledOnce() + expect(h.createWorktree).not.toHaveBeenCalled() + }) + + it('preserves ordinary worktree creation without model preferences', async () => { + const h = harness() + expect((await h.run({ ...creating, sessionOptions: undefined })).worktreeId).toBe('wt_new') + expect(h.createWorktree).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts index 0eefb2fd498..3b470e75138 100644 --- a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts +++ b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts @@ -1,3 +1,4 @@ +import '../runtime/rpc/unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it } from 'vitest' import { AiVaultHandler } from '../../relay/ai-vault-handler' import type { RelayDispatcher } from '../../relay/dispatcher' diff --git a/src/main/ai-vault/local-log-tail-reader.ts b/src/main/ai-vault/local-log-tail-reader.ts index 3045981e5c5..9044a64e833 100644 --- a/src/main/ai-vault/local-log-tail-reader.ts +++ b/src/main/ai-vault/local-log-tail-reader.ts @@ -1,4 +1,5 @@ import { open } from 'node:fs/promises' +import { LOCAL_READ_OPEN_FLAGS } from '../ipc/filesystem/local-regular-file-read' import { LOCAL_LOG_TAIL_CHUNK_BYTES, type LocalLogTailReadResult @@ -23,7 +24,7 @@ export async function readLocalLogTailRange( throw new Error('Invalid local log tail byte offset') } - const handle = await open(filePath, 'r') + const handle = await open(filePath, LOCAL_READ_OPEN_FLAGS) try { const initialStats = await handle.stat() if (!initialStats.isFile()) { diff --git a/src/main/ai-vault/session-document-stream-contract.test.ts b/src/main/ai-vault/session-document-stream-contract.test.ts index 3ab4053b303..d0aee7cf305 100644 --- a/src/main/ai-vault/session-document-stream-contract.test.ts +++ b/src/main/ai-vault/session-document-stream-contract.test.ts @@ -110,13 +110,29 @@ describe('streamed session document contracts', () => { } ) - it.each(['null', '[]', '123', '"text"'])( + it.each(['null', '[]', '[{}]', '123', '"text"', '"{}"'])( 'does not publish a nonobject document %s', async (content) => { expect(await read(content)).toBeNull() } ) + it('recognizes an object root after whitespace and empty byte chunks', async () => { + async function* padded() { + yield Buffer.alloc(0) + yield Buffer.from(' \t') + yield Buffer.from('\r\n') + yield Buffer.alloc(0) + yield* bytes('{"id":"日本語😀","messages":[1]}', 1) + yield Buffer.alloc(0) + yield Buffer.from(' \n') + } + expect(await read('', { bytes: padded() })).toEqual({ + record: { id: '日本語😀' }, + state: [1] + }) + }) + it('validates discarded subtrees and closes their source on malformed input', async () => { let closed = false async function* malformed() { diff --git a/src/main/ai-vault/session-document-stream.ts b/src/main/ai-vault/session-document-stream.ts index 4fcf24f7a55..b8af9738617 100644 --- a/src/main/ai-vault/session-document-stream.ts +++ b/src/main/ai-vault/session-document-stream.ts @@ -115,15 +115,8 @@ export async function readStreamedSessionDocument(args: { assembler.consume(token) }) const decoder = new StringDecoder('utf8') - let objectRoot: boolean | undefined for await (const chunk of args.bytes) { throwIfAiVaultScanCancelled(args.signal) - if (objectRoot === undefined) { - const first = chunk.find((byte) => byte !== 32 && byte !== 9 && byte !== 10 && byte !== 13) - if (first !== undefined) { - objectRoot = first === 123 - } - } parser.write(decoder.write(chunk)) await yieldToEventLoop() } @@ -136,5 +129,5 @@ export async function readStreamedSessionDocument(args: { if (consumeFailure) { throw consumeFailure.error } - return objectRoot ? { record, state } : null + return assembler.current === record ? { record, state } : null } 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/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-legacy-session-resume.ts b/src/main/codex/codex-legacy-session-resume.ts index 29112c38d03..a072383a660 100644 --- a/src/main/codex/codex-legacy-session-resume.ts +++ b/src/main/codex/codex-legacy-session-resume.ts @@ -86,6 +86,32 @@ export async function prepareLegacySharedCodexSessionResume( return { useRealCodexHome: true } } +/** Explicit account restarts must move the verified rollout before changing credentials. */ +export async function prepareCodexAccountRestartResume(args: { + sourceHome: string + transcriptPath: string + targetHome: string + systemCodexHomePath: string +}): Promise { + if (sameRuntimePath(args.sourceHome, args.targetHome)) { + return args.targetHome + } + const relativePath = relative( + resolve(join(args.sourceHome, 'sessions')), + resolve(args.transcriptPath) + ) + if (!isDatedRolloutRelativePath(relativePath)) { + throw new Error(RETRYABLE_RESUME_ERROR) + } + const paths = resolveCodexSessionBackfillPaths(args.systemCodexHomePath) + await materializeLegacyRollout( + args.transcriptPath, + join(args.targetHome, 'sessions', relativePath), + paths.auditLogPath + ) + return args.targetHome +} + /** * Repins a per-account resume to the selected account's home, or null to keep * the session's own home. 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/daemon/terminal-cursor-line-context.test.ts b/src/main/daemon/terminal-cursor-line-context.test.ts index 29afe00618b..3184d53ca17 100644 --- a/src/main/daemon/terminal-cursor-line-context.test.ts +++ b/src/main/daemon/terminal-cursor-line-context.test.ts @@ -157,4 +157,79 @@ describe('readTerminalCursorLineContext', () => { expect(detectTerminalComposerDraft(context)?.text).toBe(draft) emulator.dispose() }) + + it.each([true, false])('bounds blank-row cell reads with reusable cells %s', (reusable) => { + const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true }) + try { + const rig = observeCellReads(terminal) + const active = rig.source.buffer.active + const source = { + ...rig.source, + buffer: { active: { ...active, getNullCell: reusable ? active.getNullCell : undefined } } + } + const context = readTerminalCursorLineContext(source, terminal.rows) + expect(context?.typedRows).toEqual(['']) + expect(context?.typedRowsBelow).toEqual(['', '', '', '']) + expect(context?.promptGlyphBoldRows).toEqual([false]) + expect(context?.rowsBelowCustomForeground).toEqual([false, false, false, false]) + // A blank glyph search must share the full row scan; the cursor suffix is separate. + expect(rig.targets.length).toBeLessThanOrEqual(terminal.cols * (terminal.rows + 1)) + expect(rig.allocated).toHaveLength(reusable ? 1 : 0) + expect(rig.targets.every((cell) => cell === rig.allocated[0])).toBe(true) + } finally { + terminal.dispose() + } + }) + + it('keeps dimmed first-glyph styles while excluding their text', () => { + const terminal = new Terminal({ cols: 32, rows: 5, allowProposedApi: true }) + try { + writeSync(terminal, '\x1b[2;1m›\x1b[0m typed\x1b7\r\n\x1b[2;31mfooter\x1b[0m\x1b8') + const context = readTerminalCursorLineContext(terminal, terminal.rows) + expect(context?.typedRows).toEqual([' typed']) + expect(context?.promptGlyphBoldRows).toEqual([true]) + expect(context?.typedRowsBelow[0]).toBe('') + expect(context?.rowsBelowCustomForeground?.[0]).toBe(true) + } finally { + terminal.dispose() + } + }) + + it('uses the first visible glyph even when later glyphs have styles', () => { + const terminal = new Terminal({ cols: 32, rows: 5, allowProposedApi: true }) + try { + writeSync( + terminal, + '\x1b[1m \u00a0\x1b[22m›\x1b[1mX\x1b[0m\x1b7\r\n' + + '\x1b[31m \x1b[39mplain \x1b[32mlater\x1b[0m\x1b8' + ) + const context = readTerminalCursorLineContext(terminal, terminal.rows) + expect(context?.promptGlyphBoldRows).toEqual([false]) + expect(context?.rowsBelowCustomForeground?.[0]).toBe(false) + } finally { + terminal.dispose() + } + }) + + it('finds styled first glyphs after unstyled blank cells', () => { + const terminal = new Terminal({ cols: 32, rows: 5, allowProposedApi: true }) + try { + writeSync(terminal, ' \x1b[1m›\x1b[0m typed\x1b7\r\n \x1b[31mfooter\x1b[0m\x1b8') + const context = readTerminalCursorLineContext(terminal, terminal.rows) + expect(context?.promptGlyphBoldRows).toEqual([true]) + expect(context?.rowsBelowCustomForeground?.[0]).toBe(true) + } finally { + terminal.dispose() + } + }) + + it('keeps an unwritten cell between typed glyphs as a space', () => { + const terminal = new Terminal({ cols: 12, rows: 5, allowProposedApi: true }) + try { + writeSync(terminal, 'a\x1b[3Gb') + expect(readTerminalCursorLineContext(terminal, terminal.rows)?.typedRows).toEqual(['a b']) + } finally { + terminal.dispose() + } + }) }) diff --git a/src/main/daemon/terminal-snapshot-ansi-buffers.ts b/src/main/daemon/terminal-snapshot-ansi-buffers.ts index e0b16d56d8f..65936f3c82b 100644 --- a/src/main/daemon/terminal-snapshot-ansi-buffers.ts +++ b/src/main/daemon/terminal-snapshot-ansi-buffers.ts @@ -1,21 +1,15 @@ import type { TerminalModes } from './types' +import { splitAtAlternateScreenEntry } from '../../shared/terminal-alternate-screen-split' export function splitTerminalSnapshotAnsi( snapshotAnsi: string, modes: TerminalModes ): { snapshotAnsi: string; scrollbackAnsi: string } { - if (!modes.alternateScreen) { - return { snapshotAnsi, scrollbackAnsi: '' } - } - const alternateScreenMarker = '\x1b[?1049h' - const start = snapshotAnsi.lastIndexOf(alternateScreenMarker) - if (start === -1) { + const split = modes.alternateScreen ? splitAtAlternateScreenEntry(snapshotAnsi) : null + if (!split) { return { snapshotAnsi, scrollbackAnsi: '' } } // Why: rehydrateSequences owns the alt-screen transition. Keeping the // normal buffer separate lets an already-alt renderer rebuild it safely. - return { - scrollbackAnsi: snapshotAnsi.slice(0, start), - snapshotAnsi: snapshotAnsi.slice(start + alternateScreenMarker.length) - } + return { scrollbackAnsi: split.normalAnsi, snapshotAnsi: split.alternateAnsi } } diff --git a/src/main/destination-serialized-local-rename.test.ts b/src/main/destination-serialized-local-rename.test.ts index 7bdab6a0914..6a969a6ee40 100644 --- a/src/main/destination-serialized-local-rename.test.ts +++ b/src/main/destination-serialized-local-rename.test.ts @@ -12,6 +12,7 @@ const { copyFileMock, handleMock, lstatMock, realpathMock, renameMock } = vi.hoi const handlers = new Map Promise>() vi.mock('electron', () => ({ + app: { getPath: () => '/orca-test-user-data' }, ipcMain: { handle: handleMock } })) diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 664bbaf1aa3..2be65fd1c73 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -22,6 +22,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/gitea/client.ts', 1], // Generated OpenCode claim source consumes JSON or cancels its body in finally. ['main/opencode/opencode-startup-prompt-source.ts', 1], + // Authenticated loopback preflight consumes bounded JSON and cancels every body in finally. + ['main/opencode/opencode-launch-model-context.ts', 1], ['main/orca-profiles/profile-cloud-client.ts', 1], ['main/orca-profiles/profile-cloud-org-members-client.ts', 1], ['main/rate-limits/codex-fetcher.ts', 3], diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index aba55bee6e5..07b8b82f9a7 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -10,7 +10,7 @@ const { destroySystemTrayMock, relaunchAppMock, showOpenDialogMock, - grantFloatingWorkspaceDirectoryMock, + trustFloatingWorkspaceDirectoryMock, registerRendererShutdownCheckpointHandlerMock, registerMacKeyboardLayoutChangeNotificationsMock } = vi.hoisted(() => ({ @@ -22,7 +22,7 @@ const { destroySystemTrayMock: vi.fn(), relaunchAppMock: vi.fn(), showOpenDialogMock: vi.fn(), - grantFloatingWorkspaceDirectoryMock: vi.fn(), + trustFloatingWorkspaceDirectoryMock: vi.fn(), registerRendererShutdownCheckpointHandlerMock: vi.fn(), registerMacKeyboardLayoutChangeNotificationsMock: vi.fn() })) @@ -103,7 +103,7 @@ vi.mock('../app-relaunch', () => ({ vi.mock('./floating-workspace-directory', () => ({ ensureDefaultFloatingWorkspacePath: vi.fn(), - grantFloatingWorkspaceDirectory: grantFloatingWorkspaceDirectoryMock, + trustFloatingWorkspaceDirectory: trustFloatingWorkspaceDirectoryMock, resolveFloatingTerminalCwd: vi.fn() })) @@ -155,7 +155,7 @@ describe('registerAppHandlers', () => { relaunchAppMock.mockReset() relaunchAppMock.mockImplementation(() => appRelaunchMock()) showOpenDialogMock.mockReset() - grantFloatingWorkspaceDirectoryMock.mockReset() + trustFloatingWorkspaceDirectoryMock.mockReset() registerRendererShutdownCheckpointHandlerMock.mockReset() registerMacKeyboardLayoutChangeNotificationsMock.mockReset() for (const probe of Object.values(windowsProbes)) { @@ -416,7 +416,7 @@ describe('registerAppHandlers', () => { expect(showOpenDialogMock).toHaveBeenCalledWith({ properties: ['openDirectory'] }) - expect(grantFloatingWorkspaceDirectoryMock).toHaveBeenCalledWith(store, '/Users/kaylee/notes') + expect(trustFloatingWorkspaceDirectoryMock).toHaveBeenCalledWith(store, '/Users/kaylee/notes') }) // Why: the renderer reads these on every Windows capability refresh; the sync probes diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index ea74a240c38..d056727fdb7 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -15,10 +15,9 @@ import { isWslAvailableAsync, listWslDistrosAsync } from '../wsl' import { isGitBashAvailable } from '../git-bash' import { setUnreadDockBadgeCount } from '../dock/unread-badge' import { destroySystemTray } from '../tray/system-tray' -import { authorizeExternalPath } from './filesystem-auth' import { ensureDefaultFloatingWorkspacePath, - grantFloatingWorkspaceDirectory, + trustFloatingWorkspaceDirectory, resolveFloatingTerminalCwd } from './floating-workspace-directory' import { isMarkdownDocumentName, markdownDocumentFromFilePath } from './markdown-documents' @@ -60,7 +59,6 @@ async function pickFloatingMarkdownDocument( if (!isMarkdownDocumentName(filePath)) { throw new Error('Selected file is not a markdown document.') } - authorizeExternalPath(filePath) return markdownDocumentFromFilePath(cwd, filePath, { outsideRootRelativePath: 'basename' }) } @@ -70,7 +68,7 @@ async function pickFloatingWorkspaceDirectory( ): Promise { const parentWindow = BrowserWindow.fromWebContents(event.sender) const options = { - // Why: this picker only grants access to an existing directory; creation belongs to explicit file actions. + // Why: this picker only chooses an existing directory; creation belongs to explicit file actions. properties: ['openDirectory'] } satisfies Electron.OpenDialogOptions const result = parentWindow @@ -80,8 +78,8 @@ async function pickFloatingWorkspaceDirectory( return null } const selectedDir = result.filePaths[0] - // Why: a user-approved picker selection is a trust grant for later markdown creation, unlike typed settings text. - await grantFloatingWorkspaceDirectory(store, selectedDir) + // Why: only a user-approved picker selection may become the floating terminal's cwd, unlike typed settings text. + await trustFloatingWorkspaceDirectory(store, selectedDir) return selectedDir } diff --git a/src/main/ipc/automatic-load-path-text.ts b/src/main/ipc/automatic-load-path-text.ts new file mode 100644 index 00000000000..2bc92ef3444 --- /dev/null +++ b/src/main/ipc/automatic-load-path-text.ts @@ -0,0 +1,47 @@ +import { basename, sep } from 'node:path' +import { parseWslUncPath } from '../../shared/wsl-paths' + +// Why the path flavour, not the OS: these are Windows path rules, and tests exercise them with +// path.win32 on any host. +function usesWindowsPaths(): boolean { + return sep === '\\' +} + +const WINDOWS_RESERVED_DEVICE_STEM = + /^(?:con|prn|aux|nul|conin\$|conout\$|clock\$|com[0-9¹²³]|lpt[0-9¹²³])$/i + +/** + * `NUL.png`, `com1 .jpg`, `Aux.`, `NUL:stream.png` name a Windows device, not a file, whatever the + * extension or alternate data stream (`:`). + */ +export function isWindowsReservedDeviceName(filePath: string): boolean { + if (!usesWindowsPaths()) { + return false + } + const stem = + basename(filePath) + .replace(/[. ]+$/, '') + .split(/[.:]/)[0] ?? '' + return WINDOWS_RESERVED_DEVICE_STEM.test(stem.replace(/ +$/, '')) +} + +function toBackslashes(filePath: string): string { + return filePath.replace(/\//g, '\\') +} + +/** A Windows device-namespace path (`\\?\`, `\\.\`), which can name devices and shares alike. */ +export function isDeviceNamespacePath(filePath: string): boolean { + return usesWindowsPaths() && /^\\\\[?.]\\/.test(toBackslashes(filePath)) +} + +/** + * A network share (`\\host\share`). WSL paths are UNC in form but stay on this machine, so they + * are not network paths. + */ +export function isNetworkSharePath(filePath: string): boolean { + if (!usesWindowsPaths() || isDeviceNamespacePath(filePath)) { + return false + } + const normalized = toBackslashes(filePath) + return normalized.startsWith('\\\\') && parseWslUncPath(normalized) === null +} diff --git a/src/main/ipc/filesystem-allowed-roots.test.ts b/src/main/ipc/filesystem-allowed-roots.test.ts index ce4a30c62fa..7d965f218d7 100644 --- a/src/main/ipc/filesystem-allowed-roots.test.ts +++ b/src/main/ipc/filesystem-allowed-roots.test.ts @@ -15,7 +15,7 @@ import type { ProjectGroup } from '../../shared/project-group-types' import type { Project } from '../../shared/project-types' import type { Repo } from '../../shared/repo-types' import { getAllowedRoots } from './filesystem-allowed-roots' -import { authorizeExternalPath, resolveAuthorizedPath } from './filesystem-auth' +import { resolveAuthorizedPath } from './filesystem-auth' import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic' @@ -358,25 +358,6 @@ describe('resolveAuthorizedPath allowed-root reuse', () => { } ) - it('builds no allowed-root list at all for a granted external path', async () => { - const external = join(outsideRoot, 'external.md') - await writeFile(external, 'notes\n') - authorizeExternalPath(external) - counts.getRepos = 0 - counts.getProjects = 0 - counts.getFolderWorkspaces = 0 - - for (let index = 0; index < 5; index += 1) { - await expect(resolveAuthorizedPath(external, store)).resolves.toBe(external) - } - - // The grant answers on its own; hoisting the snapshot must not turn zero builds into one per read. - expect.soft(counts.getRepos).toBe(0) - expect.soft(counts.getProjects).toBe(0) - expect.soft(counts.getFolderWorkspaces).toBe(0) - expect.soft(vi.mocked(buildProjectGroupChildIndex)).not.toHaveBeenCalled() - }) - it.skipIf(process.platform === 'win32')( 'still refuses a directory symlink that escapes every allowed root', async () => { diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts index fa82b7a652c..12a9995eecf 100644 --- a/src/main/ipc/filesystem-auth.test.ts +++ b/src/main/ipc/filesystem-auth.test.ts @@ -10,12 +10,7 @@ import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' import type { GitWorktreeInfo } from '../../shared/worktree/types' -import { - AUTHORIZED_EXTERNAL_PATHS_MAX, - authorizeExternalPath, - isPathAllowed, - resolveAuthorizedPath -} from './filesystem-auth' +import { resolveAuthorizedPath } from './filesystem-auth' import { isDescendantOrEqual, validateGitRelativeFilePath } from './filesystem-path-containment' import { __resetCreatedWorktreeRootsForTests, @@ -419,42 +414,3 @@ describe('filesystem-auth path containment', () => { } }) }) - -describe('filesystem-auth authorized external path bound', () => { - // Empty allow-list store, so a path is allowed only if it (or an ancestor) is - // in the session-authorized external-path set. - const emptyStore = makeStore([]) - const flood = (n: number): string => - resolve(`/leak-audit-ext/flood-${String(n).padStart(6, '0')}`) - - it('bounds the authorized external path set with LRU eviction', () => { - const keep = resolve('/leak-audit-ext/keep') - authorizeExternalPath(keep) - - // Flood past the cap with distinct external paths, re-authorizing `keep` - // periodically so LRU keeps it hot. - const total = AUTHORIZED_EXTERNAL_PATHS_MAX + 200 - for (let i = 0; i < total; i += 1) { - authorizeExternalPath(flood(i)) - if (i % 250 === 0) { - authorizeExternalPath(keep) - } - } - - // The oldest never-re-touched entries fell out of the bounded set... - expect(isPathAllowed(flood(0), emptyStore)).toBe(false) - // ...while the periodically re-authorized path and the most recent survive. - expect(isPathAllowed(keep, emptyStore)).toBe(true) - expect(isPathAllowed(flood(total - 1), emptyStore)).toBe(true) - }) - - it('re-authorizes an evicted path on next use (self-healing)', () => { - const path = resolve('/leak-audit-ext/evicted-then-reused') - for (let i = 0; i < AUTHORIZED_EXTERNAL_PATHS_MAX + 50; i += 1) { - authorizeExternalPath(flood(100_000 + i)) - } - expect(isPathAllowed(path, emptyStore)).toBe(false) - authorizeExternalPath(path) - expect(isPathAllowed(path, emptyStore)).toBe(true) - }) -}) diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 620baa9b595..e031c6a91e1 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -1,7 +1,7 @@ import { resolve, dirname, basename } from 'node:path' -import { realpathSync } from 'node:fs' import { realpath } from 'node:fs/promises' import type { Store } from '../persistence' +import { PATH_OUTSIDE_ALLOWED_DIRECTORIES } from '../../shared/local-file-access' import { getAllowedRoots } from './filesystem-allowed-roots' import { isDescendantOrEqual, isENOENT, normalizeExistingPath } from './filesystem-path-containment' import { @@ -16,45 +16,16 @@ export { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cach export { invalidateAuthorizedRootsCacheForRepo } from './registered-worktree-roots-scoped-invalidation' export { isENOENT } from './filesystem-path-containment' -export const PATH_ACCESS_DENIED_MESSAGE = - 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.' -// Why: authorized external paths accumulate all session; LRU-bound the set. Safe to evict because every caller re-authorizes before operating. -export const AUTHORIZED_EXTERNAL_PATHS_MAX = 4096 -const authorizedExternalPaths = new Set() - -function rememberAuthorizedExternalPath(path: string): void { - // Delete-then-add makes re-authorized paths most-recent so LRU eviction sheds only the oldest untouched entries. - authorizedExternalPaths.delete(path) - authorizedExternalPaths.add(path) - while (authorizedExternalPaths.size > AUTHORIZED_EXTERNAL_PATHS_MAX) { - const oldest = authorizedExternalPaths.keys().next().value - if (oldest === undefined) { - break - } - authorizedExternalPaths.delete(oldest) - } -} - -export function authorizeExternalPath(targetPath: string): void { - const resolvedTarget = resolve(targetPath) - rememberAuthorizedExternalPath(resolvedTarget) - try { - // Why: macOS canonicalizes /tmp to /private/tmp during read authorization. - rememberAuthorizedExternalPath(realpathSync(resolvedTarget)) - } catch {} -} - -/** - * One allowed-root list shared by every check in a single authorization. - * - * Lazy so a path already covered by an external grant still builds nothing at all, the way it did - * before the list was hoisted out of the individual checks. - */ +export const PATH_ACCESS_DENIED_MESSAGE = `${PATH_OUTSIDE_ALLOWED_DIRECTORIES}. If this blocks a legitimate workflow, please file a GitHub issue.` +/** One allowed-root list shared by every check in a single authorization, built on first use. */ type AllowedRootsSnapshot = { get: () => readonly string[] } -function createAllowedRootsSnapshot(store: Store): AllowedRootsSnapshot { +function createAllowedRootsSnapshot( + store: Store, + extraRoots: readonly string[] = [] +): AllowedRootsSnapshot { let roots: readonly string[] | undefined - return { get: () => (roots ??= getAllowedRoots(store)) } + return { get: () => (roots ??= [...getAllowedRoots(store), ...extraRoots]) } } export function isPathAllowed( @@ -63,14 +34,6 @@ export function isPathAllowed( allowedRoots?: AllowedRootsSnapshot ): boolean { const resolvedTarget = resolve(targetPath) - if (authorizedExternalPaths.has(resolvedTarget)) { - return true - } - for (const authorizedPath of authorizedExternalPaths) { - if (isDescendantOrEqual(resolvedTarget, authorizedPath)) { - return true - } - } return (allowedRoots?.get() ?? getAllowedRoots(store)).some((root) => isDescendantOrEqual(resolvedTarget, root) ) @@ -81,6 +44,8 @@ export type ResolveAuthorizedPathOptions = { * Canonicalize the parent but preserve the leaf so delete/rename target the symlink itself, not its destination (which may live outside allowed roots). */ preserveSymlink?: boolean + /** Roots only the desktop window may use (never runtime RPC), checked like any other root. */ + extraRoots?: readonly string[] } export async function resolveAuthorizedPath( @@ -91,7 +56,7 @@ export async function resolveAuthorizedPath( const resolvedTarget = resolve(targetPath) // Why: the roots depend only on store state, not on the candidate path, so one snapshot serves // every authorization below; each candidate is still checked against it in full. - const allowedRoots = createAllowedRootsSnapshot(store) + const allowedRoots = createAllowedRootsSnapshot(store, options.extraRoots) if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store, { allowedRoots }))) { throw new Error(PATH_ACCESS_DENIED_MESSAGE) } diff --git a/src/main/ipc/filesystem-document-folder-writes.test.ts b/src/main/ipc/filesystem-document-folder-writes.test.ts new file mode 100644 index 00000000000..6e8e50ad205 --- /dev/null +++ b/src/main/ipc/filesystem-document-folder-writes.test.ts @@ -0,0 +1,324 @@ +import { mkdir, mkdtemp, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type * as RepoWorktrees from '../repo-worktrees' +import { + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' +import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' + +type Handler = (event: unknown, args: unknown) => Promise + +const { handlers, userData } = vi.hoisted(() => ({ + handlers: new Map(), + userData: { path: '' } +})) + +vi.mock('electron', () => ({ + app: { getPath: () => userData.path }, + ipcMain: { handle: (channel: string, handler: Handler) => handlers.set(channel, handler) } +})) +vi.mock('../repo-worktrees', async () => { + const actual = await vi.importActual('../repo-worktrees') + return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) } +}) + +import { registerFilesystemMutationHandlers } from './filesystem-mutations' + +let projectPaths: string[] = [] + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members. +const STORE = { + getRepos: () => + projectPaths.map((path, index) => ({ + id: `repo-${index}`, + path, + displayName: 'project', + badgeColor: '#000', + addedAt: 0 + })), + getProjects: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) +} as unknown as Store + +const documentFolder = (documentPath: string) => ({ kind: 'document-folder', documentPath }) + +async function settles(promise: Promise): Promise<'ok' | 'denied'> { + return promise.then( + () => 'ok', + () => 'denied' + ) +} + +function call(channel: string, args: unknown): Promise { + const handler = handlers.get(channel) + if (!handler) { + throw new Error(`no handler for ${channel}`) + } + return handler(null, args) +} + +let base: string +let docFolder: string +let note: string + +beforeEach(async () => { + invalidateAuthorizedRootsCache() + handlers.clear() + base = await mkdtemp(join(await realpath(tmpdir()), 'orca-document-folder-')) + userData.path = join(base, 'user-data') + docFolder = join(base, 'notes') + note = join(docFolder, 'note.md') + await mkdir(join(userData.path, 'floating-workspace'), { recursive: true }) + await mkdir(docFolder) + await writeFile(note, '# note\n') + await writeFile(join(base, 'shot.png'), 'png') + projectPaths = [] + registerFilesystemMutationHandlers(STORE) +}) + +afterEach(async () => { + await rm(base, { recursive: true, force: true }) +}) + +describe('renaming a document the user opened outside every project', () => { + it('renames it within its own folder', async () => { + const renamed = join(docFolder, 'renamed.md') + + await call('fs:rename', { oldPath: note, newPath: renamed, access: documentFolder(note) }) + + expect(await readdir(docFolder)).toEqual(['renamed.md']) + }) + + it('refuses another file, a relative new name, and a request with no access', async () => { + await writeFile(join(docFolder, 'other.md'), 'other') + + expect( + await settles( + call('fs:rename', { + oldPath: join(docFolder, 'other.md'), + newPath: join(docFolder, 'moved.md'), + access: documentFolder(note) + }) + ) + ).toBe('denied') + expect( + await settles( + call('fs:rename', { oldPath: note, newPath: 'moved.md', access: documentFolder(note) }) + ) + ).toBe('denied') + expect( + await settles(call('fs:rename', { oldPath: note, newPath: join(docFolder, 'plain.md') })) + ).toBe('denied') + expect((await readdir(docFolder)).sort()).toEqual(['note.md', 'other.md']) + }) + + it('undoes a rename with the renamed file declared as the document', async () => { + const renamed = join(docFolder, 'renamed.md') + await call('fs:rename', { oldPath: note, newPath: renamed, access: documentFolder(note) }) + + await call('fs:rename', { oldPath: renamed, newPath: note, access: documentFolder(renamed) }) + + expect(await readdir(docFolder)).toEqual(['note.md']) + }) + + // Why each destination is undone: the Undo declares the moved file, so it must come back from anywhere. + it('moves it into another outside folder, and Undo brings it back', async () => { + await mkdir(join(base, 'other')) + const moved = join(base, 'other', 'note.md') + + await call('fs:rename', { oldPath: note, newPath: moved, access: documentFolder(note) }) + expect(await readdir(join(base, 'other'))).toEqual(['note.md']) + + await call('fs:rename', { oldPath: moved, newPath: note, access: documentFolder(moved) }) + expect(await readdir(join(base, 'other'))).toEqual([]) + expect(await readdir(docFolder)).toEqual(['note.md']) + }) + + it('moves it into a project, and Undo brings it back out', async () => { + const project = join(base, 'proj') + await mkdir(project) + projectPaths = [project] + invalidateAuthorizedRootsCache() + const moved = join(project, 'note.md') + + await call('fs:rename', { oldPath: note, newPath: moved, access: documentFolder(note) }) + expect(await readdir(project)).toEqual(['note.md']) + + await call('fs:rename', { oldPath: moved, newPath: note, access: documentFolder(moved) }) + expect(await readdir(project)).toEqual([]) + expect(await readdir(docFolder)).toEqual(['note.md']) + }) + + it('moves it into a subfolder, and Undo brings it back', async () => { + await mkdir(join(docFolder, 'archive')) + const moved = join(docFolder, 'archive', 'note.md') + + await call('fs:rename', { oldPath: note, newPath: moved, access: documentFolder(note) }) + expect(await readdir(join(docFolder, 'archive'))).toEqual(['note.md']) + + await call('fs:rename', { oldPath: moved, newPath: note, access: documentFolder(moved) }) + expect(await readdir(join(docFolder, 'archive'))).toEqual([]) + expect((await readdir(docFolder)).sort()).toEqual(['archive', 'note.md']) + }) +}) + +describe('inserting an image into a document the user opened outside every project', () => { + it('copies the image into the document folder', async () => { + const outcome = await call('fs:importExternalPaths', { + sourcePaths: [join(base, 'shot.png')], + destDir: docFolder, + access: documentFolder(note) + }) + + expect(outcome).toMatchObject({ results: [{ status: 'imported' }] }) + expect((await readdir(docFolder)).sort()).toEqual(['note.md', 'shot.png']) + }) + + it('copies the image into a subfolder of the document folder', async () => { + await mkdir(join(docFolder, 'images')) + + await call('fs:importExternalPaths', { + sourcePaths: [join(base, 'shot.png')], + destDir: join(docFolder, 'images'), + access: documentFolder(note) + }) + + expect(await readdir(join(docFolder, 'images'))).toEqual(['shot.png']) + }) + + it.skipIf(process.platform === 'win32')( + 'refuses an import through a linked subfolder that leads out', + async () => { + await mkdir(join(base, 'elsewhere')) + await symlink(join(base, 'elsewhere'), join(docFolder, 'linked')) + + expect( + await settles( + call('fs:importExternalPaths', { + sourcePaths: [join(base, 'shot.png')], + destDir: join(docFolder, 'linked'), + access: documentFolder(note) + }) + ) + ).toBe('denied') + expect(await readdir(join(base, 'elsewhere'))).toEqual([]) + } + ) + + it('refuses the parent folder, and any outside folder without access', async () => { + const importInto = (destDir: string, access?: unknown) => + settles( + call('fs:importExternalPaths', { + sourcePaths: [join(base, 'shot.png')], + destDir, + access + }) + ) + + expect(await importInto(base, documentFolder(note))).toBe('denied') + expect(await importInto(docFolder)).toBe('denied') + expect(await readdir(docFolder)).toEqual(['note.md']) + }) +}) + +describe('a project file opened by its full path', () => { + it('renames into a subfolder of the project, which the project check allows', async () => { + const project = join(base, 'project') + await mkdir(join(project, 'docs', 'old'), { recursive: true }) + await writeFile(join(project, 'docs', 'plan.md'), '# plan\n') + projectPaths = [project] + invalidateAuthorizedRootsCache() + const plan = join(project, 'docs', 'plan.md') + + await call('fs:rename', { + oldPath: plan, + newPath: join(project, 'docs', 'old', 'plan.md'), + access: documentFolder(plan) + }) + + expect(await readdir(join(project, 'docs', 'old'))).toEqual(['plan.md']) + }) + + it('renames into another folder of the same project, as with no declared access', async () => { + const project = join(base, 'project') + await mkdir(join(project, 'docs'), { recursive: true }) + await mkdir(join(project, 'archive')) + await writeFile(join(project, 'docs', 'plan.md'), '# plan\n') + projectPaths = [project] + invalidateAuthorizedRootsCache() + const plan = join(project, 'docs', 'plan.md') + + await call('fs:rename', { + oldPath: plan, + newPath: join(project, 'archive', 'plan.md'), + access: documentFolder(plan) + }) + + expect(await readdir(join(project, 'archive'))).toEqual(['plan.md']) + expect(await readdir(join(project, 'docs'))).toEqual([]) + }) + + it('moves out of the project, and Undo brings it back in', async () => { + const project = join(base, 'project') + await mkdir(join(project, 'docs'), { recursive: true }) + await writeFile(join(project, 'docs', 'plan.md'), '# plan\n') + projectPaths = [project] + invalidateAuthorizedRootsCache() + const plan = join(project, 'docs', 'plan.md') + const moved = join(docFolder, 'plan.md') + + await call('fs:rename', { oldPath: plan, newPath: moved, access: documentFolder(plan) }) + expect((await readdir(docFolder)).sort()).toEqual(['note.md', 'plan.md']) + + await call('fs:rename', { oldPath: moved, newPath: plan, access: documentFolder(moved) }) + expect(await readdir(join(project, 'docs'))).toEqual(['plan.md']) + expect(await readdir(docFolder)).toEqual(['note.md']) + }) + + it('never moves another project file out by naming the opened document', async () => { + const project = join(base, 'project') + await mkdir(project) + await writeFile(join(project, 'secret.md'), 'secret') + projectPaths = [project] + invalidateAuthorizedRootsCache() + + expect( + await settles( + call('fs:rename', { + oldPath: join(project, 'secret.md'), + newPath: join(docFolder, 'secret.md'), + access: documentFolder(note) + }) + ) + ).toBe('denied') + expect(await readdir(project)).toEqual(['secret.md']) + }) +}) + +describe('an SSH rename', () => { + it('goes to the remote host as before, whatever access it declares', async () => { + const renameNoClobber = vi.fn().mockResolvedValue(undefined) + registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) + try { + await call('fs:rename', { + oldPath: note, + newPath: join(base, 'note.md'), + access: documentFolder(note), + connectionId: 'ssh-1', + expectedSshTargetId: 'ssh-1', + expectedSshConnectionGeneration: 0 + }) + } finally { + unregisterSshFilesystemProvider('ssh-1') + } + + expect(renameNoClobber).toHaveBeenCalledWith(note, join(base, 'note.md')) + expect(await readdir(docFolder)).toEqual(['note.md']) + }) +}) diff --git a/src/main/ipc/filesystem-import-local-rollback.test.ts b/src/main/ipc/filesystem-import-local-rollback.test.ts index 7e196d700dc..b76fe597b35 100644 --- a/src/main/ipc/filesystem-import-local-rollback.test.ts +++ b/src/main/ipc/filesystem-import-local-rollback.test.ts @@ -12,7 +12,6 @@ const { lstatMock, mkdirMock, openMock, readdirMock, rmMock, unlinkMock } = vi.h unlinkMock: vi.fn() })) -vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: vi.fn() })) vi.mock('node:fs/promises', () => ({ lstat: lstatMock, mkdir: mkdirMock, diff --git a/src/main/ipc/filesystem-import-local.ts b/src/main/ipc/filesystem-import-local.ts index c77d841a53d..d7960a2beeb 100644 --- a/src/main/ipc/filesystem-import-local.ts +++ b/src/main/ipc/filesystem-import-local.ts @@ -1,6 +1,5 @@ import { lstat } from 'node:fs/promises' import { basename, join, resolve } from 'node:path' -import { authorizeExternalPath } from './filesystem-auth' import { isENOENT } from './filesystem-path-containment' import type { ImportItemResult } from '../../shared/filesystem-import-result-types' import { @@ -10,8 +9,8 @@ import { } from './filesystem-import-local-tree-copy' /** - * Import a single top-level source into destDir, handling authorization, - * validation, pre-scan, deconfliction, and copy. + * Import a single top-level source into destDir, handling validation, pre-scan, + * deconfliction, and copy. */ export async function importOneSource( sourcePath: string, @@ -20,10 +19,6 @@ export async function importOneSource( ): Promise { const resolvedSource = resolve(sourcePath) - // Why: authorize the external source path so downstream filesystem - // operations (lstat, readdir, copyFile) are permitted by Electron. - authorizeExternalPath(resolvedSource) - // Why: validate source using lstat on the unresolved path *before* // canonicalization so top-level symlinks are rejected instead of being // silently dereferenced by realpath. diff --git a/src/main/ipc/filesystem-import-ssh-path-safety.test.ts b/src/main/ipc/filesystem-import-ssh-path-safety.test.ts index 297dcb22045..3a6b0f2f86c 100644 --- a/src/main/ipc/filesystem-import-ssh-path-safety.test.ts +++ b/src/main/ipc/filesystem-import-ssh-path-safety.test.ts @@ -15,9 +15,6 @@ vi.mock('node:fs/promises', () => ({ readdir: readdirMock, realpath: realpathMock })) -vi.mock('./filesystem-auth', () => ({ - authorizeExternalPath: vi.fn() -})) vi.mock('./filesystem-path-containment', () => ({ isENOENT: (error: NodeJS.ErrnoException) => error.code === 'ENOENT' })) diff --git a/src/main/ipc/filesystem-import-ssh.test.ts b/src/main/ipc/filesystem-import-ssh.test.ts index 6a5d502b14c..3c18eea6981 100644 --- a/src/main/ipc/filesystem-import-ssh.test.ts +++ b/src/main/ipc/filesystem-import-ssh.test.ts @@ -27,7 +27,10 @@ const { getConnMgrMock: vi.fn() })) -vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) +vi.mock('electron', () => ({ + app: { getPath: () => '/orca-test-user-data' }, + ipcMain: { handle: handleMock } +})) vi.mock('fs/promises', () => ({ lstat: lstatMock, mkdir: mkdirMock, diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts index 9268c6ece24..a0cb300ba21 100644 --- a/src/main/ipc/filesystem-import-ssh.ts +++ b/src/main/ipc/filesystem-import-ssh.ts @@ -1,6 +1,5 @@ import { lstat } from 'node:fs/promises' import { basename, posix, resolve } from 'node:path' -import { authorizeExternalPath } from './filesystem-auth' import { isENOENT } from './filesystem-path-containment' import { getSshConnectionManager } from './ssh' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' @@ -97,8 +96,6 @@ async function importOneSourceSsh( ): Promise { const resolvedSource = resolve(sourcePath) - authorizeExternalPath(resolvedSource) - const originalName = basename(resolvedSource) try { assertSafeRemotePathSegment(originalName, remotePathFlavor) diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index 8047c19f62d..c2df10fd70b 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -29,6 +29,7 @@ const { })) vi.mock('electron', () => ({ + app: { getPath: () => '/orca-test-user-data' }, ipcMain: { handle: handleMock } })) diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index 18d05ab5c96..36db8630dc5 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -14,6 +14,7 @@ const { handleMock, copyFileMock, lstatMock, mkdirMock, renameMock, writeFileMoc })) vi.mock('electron', () => ({ + app: { getPath: () => '/orca-test-user-data' }, ipcMain: { handle: handleMock } })) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index ad1308f0c30..e96650a7442 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -3,7 +3,12 @@ import { constants } from 'node:fs' import { copyFile, mkdir, writeFile } from 'node:fs/promises' import { basename, dirname } from 'node:path' import type { Store } from '../persistence' -import { resolveAuthorizedPath } from './filesystem-auth' +import { + resolveDesktopAuthorizedPath, + resolveLocalRenamePaths, + resolveLocalRequestPath +} from './local-file-access-resolution' +import type { LocalFileAccess } from '../../shared/local-file-access' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution' import { importExternalPathsSsh } from './filesystem-import-ssh' @@ -49,7 +54,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { const provider = requireSshFilesystemProvider(args.connectionId) return provider.createFile(args.filePath) } - const filePath = await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveDesktopAuthorizedPath(args.filePath, store) await mkdir(dirname(filePath), { recursive: true }) try { // Use the 'wx' flag for atomic create-if-not-exists, avoiding TOCTOU races @@ -76,7 +81,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { const provider = requireSshFilesystemProvider(args.connectionId) return provider.createDir(args.dirPath) } - const dirPath = await resolveAuthorizedPath(args.dirPath, store) + const dirPath = await resolveDesktopAuthorizedPath(args.dirPath, store) await assertNotExists(dirPath) await mkdir(dirPath, { recursive: true }) } @@ -89,7 +94,12 @@ export function registerFilesystemMutationHandlers(store: Store): void { 'fs:rename', async ( _event, - args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation + args: { + oldPath: string + newPath: string + connectionId?: string + access?: LocalFileAccess + } & SshMutationExpectation ): Promise => { assertSshMutationExpectation( args.connectionId, @@ -107,9 +117,14 @@ export function registerFilesystemMutationHandlers(store: Store): void { // target file (potentially elsewhere in the worktree) and leave the // symlink dangling. newPath must also preserve its leaf so we don't // accidentally write into a symlinked destination name. - const oldPath = await resolveAuthorizedPath(args.oldPath, store, { preserveSymlink: true }) - const newPath = await resolveAuthorizedPath(args.newPath, store, { preserveSymlink: true }) - await renameLocalPathSerializedByDestination(oldPath, newPath) + // Outside every project, a document the user opened may still be renamed, to any path. + const { from, to } = await resolveLocalRenamePaths( + args.oldPath, + args.newPath, + args.access, + store + ) + await renameLocalPathSerializedByDestination(from, to) } ) @@ -133,10 +148,10 @@ export function registerFilesystemMutationHandlers(store: Store): void { const provider = requireSshFilesystemProvider(args.connectionId) return provider.copy(args.sourcePath, args.destinationPath) } - const sourcePath = await resolveAuthorizedPath(args.sourcePath, store, { + const sourcePath = await resolveDesktopAuthorizedPath(args.sourcePath, store, { preserveSymlink: true }) - const destinationPath = await resolveAuthorizedPath(args.destinationPath, store, { + const destinationPath = await resolveDesktopAuthorizedPath(args.destinationPath, store, { preserveSymlink: true }) await mkdir(dirname(destinationPath), { recursive: true }) @@ -155,6 +170,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { destDir: string connectionId?: string ensureDir?: boolean + access?: LocalFileAccess } & SshMutationExpectation ): Promise<{ results: ImportItemResult[] }> => { assertSshMutationExpectation( @@ -180,7 +196,13 @@ export function registerFilesystemMutationHandlers(store: Store): void { // destination is outside allowed roots, the entire import fails. // This only applies to local imports — remote paths are authorized by // the SSH connection boundary (see importExternalPathsSsh). - const resolvedDest = await resolveAuthorizedPath(args.destDir, store) + // An image inserted into a document the user opened lands in that document's own folder. + const resolvedDest = await resolveLocalRequestPath( + args.destDir, + args.access, + store, + 'import-into' + ) const results: ImportItemResult[] = [] const reservedNames = new Set() diff --git a/src/main/ipc/filesystem-runtime-upload-staging.test.ts b/src/main/ipc/filesystem-runtime-upload-staging.test.ts index de32bd8ebb4..7f6b4d96b17 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.test.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.test.ts @@ -6,7 +6,6 @@ import type * as RuntimeImportLimits from './runtime-import-limits' type RuntimeImportLimitsModule = typeof RuntimeImportLimits -vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) // Why: real ceilings are gigabytes, and truncate() is not sparse on NTFS, so a // literal over-limit fixture would allocate that much on Windows CI. vi.mock('./runtime-import-limits', async (importOriginal) => ({ diff --git a/src/main/ipc/filesystem-runtime-upload-staging.ts b/src/main/ipc/filesystem-runtime-upload-staging.ts index 6531f8a499a..9d5d8467e3e 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.ts @@ -6,7 +6,6 @@ import { import { constants } from 'node:fs' import { lstat, open, readdir, realpath } from 'node:fs/promises' import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' -import { authorizeExternalPath } from './filesystem-auth' import { isENOENT } from './filesystem-path-containment' import type { StagedExternalImportEntry, @@ -36,10 +35,6 @@ export async function stageOneSourceForRuntimeUpload( ): Promise { const resolvedSource = resolve(sourcePath) - // Why: runtime uploads read client-local paths in the client main process; - // authorize before lstat just like local copy imports. - authorizeExternalPath(resolvedSource) - let sourceStat: Awaited> try { sourceStat = await lstat(resolvedSource) diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts index d4096500381..db6f3c9c57c 100644 --- a/src/main/ipc/filesystem-search-rg-timeout.test.ts +++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts @@ -24,6 +24,7 @@ const { const handlers = new Map unknown>() vi.mock('electron', () => ({ + app: { getPath: () => '/orca-test-user-data' }, ipcMain: { handle: handleMock }, @@ -43,7 +44,6 @@ vi.mock('../wsl', () => ({ })) vi.mock('./filesystem-auth', () => ({ - authorizeExternalPath: vi.fn(async (value: string) => value), resolveAuthorizedPath: resolveAuthorizedPathMock })) diff --git a/src/main/ipc/filesystem-test-harness.ts b/src/main/ipc/filesystem-test-harness.ts index a3a06b95e85..069f4c10d83 100644 --- a/src/main/ipc/filesystem-test-harness.ts +++ b/src/main/ipc/filesystem-test-harness.ts @@ -60,6 +60,7 @@ export const recordCrashBreadcrumbMock: IpcMock = vi.fn() export const promoteLocalDownloadedFolderMock: IpcMock = vi.fn() export const electronMock = { + app: { getPath: () => '/orca-test-user-data' }, BrowserWindow: { fromWebContents: fromWebContentsMock }, dialog: { showSaveDialog: showSaveDialogMock, showOpenDialog: showOpenDialogMock }, ipcMain: { handle: handleMock }, @@ -237,6 +238,36 @@ const ALL_MOCKS = [ pullRequestLinkedIssueMock ].flatMap(collectMocks) +/** A FileHandle double over `content`: positional reads copy from it, and stat reports its size. */ +export function localFileHandleMock( + content: Buffer, + { isFile = true, size = content.byteLength }: { isFile?: boolean; size?: number } = {} +): Record { + return { + stat: vi.fn(async () => ({ + size, + isFile: () => isFile, + isDirectory: () => false, + mtimeMs: 123, + dev: 1, + ino: 2, + birthtimeMs: 3 + })), + read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const bytesRead = content.copy( + buffer, + offset, + position, + Math.min(position + length, content.length) + ) + return { bytesRead, buffer } + }), + write: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + close: vi.fn() + } +} + /** Resets every filesystem IPC mock and reinstalls the defaults every suite starts from. */ export function resetFilesystemIpcMocks(): void { handlers.clear() @@ -272,14 +303,6 @@ export function resetFilesystemIpcMocks(): void { statMock.mockResolvedValue({ size: 10, isDirectory: () => false, mtimeMs: 123 }) renameMock.mockResolvedValue(undefined) rmMock.mockResolvedValue(undefined) - openMock.mockResolvedValue({ - read: vi.fn(async (buffer: Buffer) => { - buffer.fill(0x61) - return { bytesRead: buffer.length, buffer } - }), - write: vi.fn().mockResolvedValue(undefined), - writeFile: vi.fn().mockResolvedValue(undefined), - close: vi.fn() - }) + openMock.mockResolvedValue(localFileHandleMock(Buffer.from('a'.repeat(10)))) lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) } diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 68946d56cff..8582a3b00ac 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -18,7 +18,8 @@ import { getSshFilesystemProviderMock, tryDeleteWslUncPathMock, recordCrashBreadcrumbMock, - resetFilesystemIpcMocks + resetFilesystemIpcMocks, + localFileHandleMock } from './filesystem-test-harness' vi.mock('electron', async () => (await import('./filesystem-test-harness')).electronMock) @@ -83,6 +84,13 @@ describe('registerFilesystemHandlers', () => { invalidateAuthorizedRootsCache() }) + it('registers no channel that remembers a path grant', () => { + registerFilesystemHandlers(store as never) + + expect(handlers.has('fs:readFile')).toBe(true) + expect(handlers.has('fs:authorizeExternalPath')).toBe(false) + }) + it('re-sorts SSH provider listings directories-first in natural order', async () => { // Why: the remote relay may be an older build that still sorts lexicographically. getSshFilesystemProviderMock.mockReturnValueOnce({ @@ -361,8 +369,7 @@ describe('registerFilesystemHandlers', () => { } ])('returns base64 content for supported $ext binaries', async ({ ext, mime, data }) => { const buf = Buffer.from(data) - statMock.mockResolvedValue({ size: buf.length, isDirectory: () => false, mtimeMs: 123 }) - readFileMock.mockResolvedValue(buf) + openMock.mockResolvedValue(localFileHandleMock(buf)) registerFilesystemHandlers(store as never) await expect( handlers.get('fs:readFile')!(null, { filePath: path.resolve(`/workspace/repo/file.${ext}`) }) @@ -376,8 +383,7 @@ describe('registerFilesystemHandlers', () => { it('opens text files larger than the old 5MB guard', async () => { const content = 'a'.repeat(6 * 1024 * 1024) - statMock.mockResolvedValue({ size: content.length, isDirectory: () => false, mtimeMs: 123 }) - readFileMock.mockResolvedValue(Buffer.from(content)) + openMock.mockResolvedValue(localFileHandleMock(Buffer.from(content))) registerFilesystemHandlers(store as never) @@ -391,17 +397,8 @@ describe('registerFilesystemHandlers', () => { it('returns stable byte metadata only for opted-in local log snapshots', async () => { const content = Buffer.from('first\npartial') - const close = vi.fn() - openMock.mockResolvedValue({ - stat: vi.fn().mockResolvedValue({ - size: content.byteLength, - dev: 1, - ino: 2, - birthtimeMs: 3 - }), - readFile: vi.fn().mockResolvedValue(content), - close - }) + const handle = localFileHandleMock(content) + openMock.mockResolvedValue(handle) registerFilesystemHandlers(store as never) await expect( @@ -414,12 +411,13 @@ describe('registerFilesystemHandlers', () => { isBinary: false, fileIdentity: '1:2:3' }) - expect(close).toHaveBeenCalledTimes(1) + expect(handle.close).toHaveBeenCalledTimes(1) expect(readFileMock).not.toHaveBeenCalled() }) it('rejects text files beyond the editor read budget', async () => { - statMock.mockResolvedValue({ size: 51 * 1024 * 1024, isDirectory: () => false, mtimeMs: 123 }) + const handle = localFileHandleMock(Buffer.alloc(0), { size: 51 * 1024 * 1024 }) + openMock.mockResolvedValue(handle) registerFilesystemHandlers(store as never) @@ -427,18 +425,13 @@ describe('registerFilesystemHandlers', () => { handlers.get('fs:readFile')!(null, { filePath: path.resolve('/workspace/repo/huge.json') }) ).rejects.toThrow('exceeds 50MB limit') - expect(readFileMock).not.toHaveBeenCalled() + expect(handle.read).not.toHaveBeenCalled() + expect(handle.close).toHaveBeenCalled() }) it('probes large unknown binaries without reading the full file', async () => { - statMock.mockResolvedValue({ size: 6 * 1024 * 1024, isDirectory: () => false, mtimeMs: 123 }) - openMock.mockResolvedValue({ - read: vi.fn(async (buffer: Buffer) => { - buffer[0] = 0x00 - return { bytesRead: 1, buffer } - }), - close: vi.fn() - }) + const handle = localFileHandleMock(Buffer.alloc(6 * 1024 * 1024)) + openMock.mockResolvedValue(handle) registerFilesystemHandlers(store as never) @@ -449,7 +442,7 @@ describe('registerFilesystemHandlers', () => { isBinary: true }) - expect(readFileMock).not.toHaveBeenCalled() + expect(handle.read).toHaveBeenCalledTimes(1) }) it('moves files to trash', async () => { @@ -519,8 +512,7 @@ describe('registerFilesystemHandlers', () => { }) it('keeps non-image binaries hidden from the editor payload', async () => { - statMock.mockResolvedValue({ size: 4, isDirectory: () => false, mtimeMs: 123 }) - readFileMock.mockResolvedValue(Buffer.from([0x00, 0x01, 0x02])) + openMock.mockResolvedValue(localFileHandleMock(Buffer.from([0x00, 0x01, 0x02]))) registerFilesystemHandlers(store as never) diff --git a/src/main/ipc/filesystem/filesystem-chunk-read-handler.ts b/src/main/ipc/filesystem/filesystem-chunk-read-handler.ts new file mode 100644 index 00000000000..e04ccec5e78 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-chunk-read-handler.ts @@ -0,0 +1,42 @@ +import { ipcMain } from 'electron' +import { readLocalFileRange } from './local-file-range-read' +import { validateFileRangeRequest } from '../../../shared/file-range-read' +import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { readSshFileExplorerChunk } from '../../runtime/ssh-file-explorer-chunk-read' +import { resolveLocalFileRequestPath } from '../local-file-access-resolution' +import type { LocalFileAccess } from '../../../shared/local-file-access' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemChunkReadHandler({ store }: FilesystemHandlerContext): void { + ipcMain.handle( + 'fs:readFileChunk', + async ( + _event, + args: { + filePath: string + connectionId?: string + access?: LocalFileAccess + offset: number + length: number + } + ) => { + validateFileRangeRequest(args.offset, args.length) + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + const stats = await provider.stat(args.filePath) + if (stats.type === 'directory') { + throw new Error('Cannot read a directory') + } + return readSshFileExplorerChunk( + provider, + args.filePath, + stats.size, + args.offset, + args.length + ) + } + const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store) + return readLocalFileRange(filePath, args.offset, args.length) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts index b140532c372..49473eb3f3f 100644 --- a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts +++ b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts @@ -1,6 +1,11 @@ -import { open } from 'node:fs/promises' -import type { FileHandle } from 'node:fs/promises' +import { extname } from 'node:path' import { localLogFileIdentity } from '../../ai-vault/local-log-tail-reader' +import { + fileTooLargeError, + openLocalRegularFile, + readLocalFileBounded, + readLocalFilePrefix +} from './local-regular-file-read' // Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. export const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB @@ -9,6 +14,7 @@ export const BINARY_PROBE_BYTES = 8192 export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB export const PREVIEWABLE_BINARY_MIME_TYPES: Record = { '.png': 'image/png', + '.avif': 'image/avif', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', @@ -19,25 +25,57 @@ export const PREVIEWABLE_BINARY_MIME_TYPES: Record = { '.pdf': 'application/pdf' } -export async function readLocalLogSnapshot(filePath: string): Promise<{ +export type LocalFileContent = { content: string isBinary: boolean + isImage?: boolean + mimeType?: string fileIdentity?: string -}> { - const handle = await open(filePath, 'r') +} + +/** One open, one handle: the size check, binary probe and read all see the same regular file. */ +export async function readLocalFileContent(filePath: string): Promise { + const { handle, stats } = await openLocalRegularFile(filePath) + try { + const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] + const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE + if (stats.size > sizeLimit) { + throw fileTooLargeError(stats.size, sizeLimit) + } + if (mimeType) { + const buffer = await readLocalFileBounded(handle, sizeLimit, stats.size) + return { + content: buffer.toString('base64'), + isBinary: true, + // Why: the renderer keys previewable-binary rendering off `isImage`, so set it for PDFs too to stay compatible. + isImage: true, + mimeType + } + } + // Why: probe large unknown files first so archives aren't fully buffered only to discover they aren't editable text. + if ( + stats.size > BINARY_PROBE_BYTES && + isBinaryBuffer(await readLocalFilePrefix(handle, BINARY_PROBE_BYTES)) + ) { + return { content: '', isBinary: true } + } + const buffer = await readLocalFileBounded(handle, sizeLimit, stats.size) + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { content: buffer.toString('utf-8'), isBinary: false } + } finally { + await handle.close() + } +} + +export async function readLocalLogSnapshot(filePath: string): Promise { + const { handle, stats } = await openLocalRegularFile(filePath) try { - const stats = await handle.stat() if (stats.size > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) - } - const buffer = await handle.readFile() - if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) + throw fileTooLargeError(stats.size, MAX_TEXT_FILE_SIZE) } + const buffer = await readLocalFileBounded(handle, MAX_TEXT_FILE_SIZE, stats.size) if (isBinaryBuffer(buffer)) { return { content: '', isBinary: true } } @@ -62,17 +100,6 @@ export function isBinaryBuffer(buffer: Buffer): boolean { return false } -export async function isBinaryFilePrefix(filePath: string): Promise { - const handle: FileHandle = await open(filePath, 'r') - try { - const probe = Buffer.alloc(BINARY_PROBE_BYTES) - const { bytesRead } = await handle.read(probe, 0, probe.length, 0) - return isBinaryBuffer(probe.subarray(0, bytesRead)) - } finally { - await handle.close() - } -} - export function isDirectoryEntry(entry: { isDirectory(): boolean isSymbolicLink(): boolean diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index 1d9fe681fee..f414f1d0c16 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -4,31 +4,32 @@ import { type PathExistenceResult } from '../../../shared/path-existence-batch' import { ipcMain } from 'electron' -import { readdir, readFile, stat } from 'node:fs/promises' -import { extname } from 'node:path' +import { readdir, stat } from 'node:fs/promises' import type { DirEntry, MarkdownDocument } from '../../../shared/filesystem-entry-types' import { sortDirEntries } from '../../../shared/file-name-sort' import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' -import { resolveAuthorizedPath } from '../filesystem-auth' +import type { LocalFileAccess } from '../../../shared/local-file-access' +import { + resolveDesktopAuthorizedPath, + resolveLocalFileRequestPath +} from '../local-file-access-resolution' import { isENOENT } from '../filesystem-path-containment' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from '../markdown-documents' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' import { recordCrashBreadcrumb } from '../../crash-reporting/crash-breadcrumb-store' import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from '../readdir-error-diagnostics' import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { registerFilesystemChunkReadHandler } from './filesystem-chunk-read-handler' import { - BINARY_PROBE_BYTES, - isBinaryBuffer, - isBinaryFilePrefix, isDirectoryEntry, - MAX_PREVIEWABLE_BINARY_SIZE, - MAX_TEXT_FILE_SIZE, - PREVIEWABLE_BINARY_MIME_TYPES, - readLocalLogSnapshot + readLocalFileContent, + readLocalLogSnapshot, + type LocalFileContent } from './filesystem-file-content-inspection' export function registerFilesystemReadHandlers(context: FilesystemHandlerContext): void { + registerFilesystemChunkReadHandler(context) const { store } = context ipcMain.handle( @@ -43,7 +44,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext // Why: re-sort locally — the remote relay may be an older build with lexicographic ordering. return sortDirEntries(await provider.readDir(args.dirPath)) } - const dirPath = await resolveAuthorizedPath(args.dirPath, store) + const dirPath = await resolveDesktopAuthorizedPath(args.dirPath, store) throwSite = 'readdir' const entries = await readdir(dirPath, { withFileTypes: true }) const mapped = entries.map((entry) => ({ @@ -71,52 +72,21 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext 'fs:readFile', async ( _event, - args: { filePath: string; connectionId?: string; includeLocalLogMetadata?: boolean } - ): Promise<{ - content: string - isBinary: boolean - isImage?: boolean - mimeType?: string - fileIdentity?: string - }> => { + args: { + filePath: string + connectionId?: string + includeLocalLogMetadata?: boolean + access?: LocalFileAccess + } + ): Promise => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.readFile(args.filePath) } - const filePath = await resolveAuthorizedPath(args.filePath, store) - if (args.includeLocalLogMetadata === true) { - return readLocalLogSnapshot(filePath) - } - const stats = await stat(filePath) - const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] - const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - if (stats.size > sizeLimit) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` - ) - } - - if (mimeType) { - const buffer = await readFile(filePath) - return { - content: buffer.toString('base64'), - isBinary: true, - // Why: the renderer keys previewable-binary rendering off `isImage`, so set it for PDFs too to stay compatible. - isImage: true, - mimeType - } - } - - // Why: probe large unknown files first so archives aren't fully buffered only to discover they aren't editable text. - if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { - return { content: '', isBinary: true } - } - - const buffer = await readFile(filePath) - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - return { content: buffer.toString('utf-8'), isBinary: false } + const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store) + return args.includeLocalLogMetadata === true + ? readLocalLogSnapshot(filePath) + : readLocalFileContent(filePath) } ) @@ -143,14 +113,14 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext 'fs:stat', async ( _event, - args: { filePath: string; connectionId?: string } + args: { filePath: string; connectionId?: string; access?: LocalFileAccess } ): Promise<{ size: number; isDirectory: boolean; mtime: number }> => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) const result = await provider.stat(args.filePath) return { size: result.size, isDirectory: result.type === 'directory', mtime: result.mtime } } - const filePath = await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store) const stats = await stat(filePath) return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs } } @@ -173,7 +143,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext try { await (provider ? provider.stat(filePath) - : stat(await resolveAuthorizedPath(filePath, store))) + : stat(await resolveDesktopAuthorizedPath(filePath, store))) return true } catch (error) { if (isENOENT(error)) { @@ -189,14 +159,17 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext ipcMain.handle( 'fs:pathExists', - async (_event, args: { filePath: string; connectionId?: string }): Promise => { + async ( + _event, + args: { filePath: string; connectionId?: string; access?: LocalFileAccess } + ): Promise => { try { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) await provider.stat(args.filePath) return true } - const filePath = await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store) await stat(filePath) return true } catch (error) { diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts index 94cc07a2f1f..63ee18a1d4b 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -26,7 +26,7 @@ import { getSshFilesystemProvider, requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' -import { resolveAuthorizedPath } from '../filesystem-auth' +import { resolveDesktopAuthorizedPath } from '../local-file-access-resolution' import { listQuickOpenFiles } from '../filesystem-list-files' import { isFileNameFilterQueryTooLarge, @@ -52,7 +52,7 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte const provider = requireSshFilesystemProvider(args.connectionId) return provider.search(args) } - const rootPath = await resolveAuthorizedPath(args.rootPath, store) + const rootPath = await resolveDesktopAuthorizedPath(args.rootPath, store) const localGitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.rootPath, diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index 07d4de5b56a..ff614b8d35a 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -4,10 +4,15 @@ import type { SshMutationExpectation } from '../../../shared/ssh-types' import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generation' import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { tryDeleteWslUncPath } from '../../wsl-unc-delete' -import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth' +import type { LocalFileAccess } from '../../../shared/local-file-access' +import { + resolveDesktopAuthorizedPath, + resolveLocalWriteRequestPath +} from '../local-file-access-resolution' import { isENOENT } from '../filesystem-path-containment' import { registerFilesystemMutationHandlers } from '../filesystem-mutations' import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { assertLocalWriteTargetIsRegularFile } from './local-regular-file-read' export function registerFilesystemWriteHandlers(context: FilesystemHandlerContext): void { const { store } = context @@ -16,7 +21,12 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex 'fs:writeFile', async ( _event, - args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation + args: { + filePath: string + content: string + connectionId?: string + access?: LocalFileAccess + } & SshMutationExpectation ): Promise => { assertSshMutationExpectation( args.connectionId, @@ -28,7 +38,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex const provider = requireSshFilesystemProvider(args.connectionId) return provider.writeFile(args.filePath, args.content) } - const filePath = await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveLocalWriteRequestPath(args.filePath, args.access, store) try { const fileStats = await lstat(filePath) if (fileStats.isDirectory()) { @@ -39,6 +49,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex throw error } } + await assertLocalWriteTargetIsRegularFile(filePath) await writeFile(filePath, args.content, 'utf-8') } ) @@ -64,7 +75,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex return provider.deletePath(args.targetPath, args.recursive) } // Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots). - const targetPath = await resolveAuthorizedPath(args.targetPath, store, { + const targetPath = await resolveDesktopAuthorizedPath(args.targetPath, store, { preserveSymlink: true }) // Why: WSL UNC targets have no Recycle Bin (shell.trashItem throws), so hard-delete via `rm` inside the distro (issue #6415). @@ -84,8 +95,4 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex ) registerFilesystemMutationHandlers(store) - - ipcMain.handle('fs:authorizeExternalPath', (_event, args: { targetPath: string }): void => { - authorizeExternalPath(args.targetPath) - }) } diff --git a/src/main/ipc/filesystem/local-file-range-read.test.ts b/src/main/ipc/filesystem/local-file-range-read.test.ts new file mode 100644 index 00000000000..b73cd7788c2 --- /dev/null +++ b/src/main/ipc/filesystem/local-file-range-read.test.ts @@ -0,0 +1,50 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { readLocalFileRange } from './local-file-range-read' + +let folder: string +beforeEach(async () => { + folder = await mkdtemp(join(tmpdir(), 'orca-file-range-')) +}) +afterEach(async () => { + await rm(folder, { recursive: true, force: true }) +}) + +it('reads only the requested bytes and reports partial and past-EOF ranges', async () => { + const file = join(folder, 'records.csv') + await writeFile(file, '0123456789') + await expect(readLocalFileRange(file, 3, 4)).resolves.toEqual({ + contentBase64: Buffer.from('3456').toString('base64'), + bytesRead: 4, + eof: false + }) + await expect(readLocalFileRange(file, 8, 4)).resolves.toEqual({ + contentBase64: Buffer.from('89').toString('base64'), + bytesRead: 2, + eof: true + }) + await expect(readLocalFileRange(file, 12, 4)).resolves.toEqual({ + contentBase64: '', + bytesRead: 0, + eof: true + }) +}) + +it('refuses directories through the shared regular-file guard', async () => { + await expect(readLocalFileRange(folder, 0, 4)).rejects.toThrow() +}) + +it('rejects invalid or excessive ranges before opening the path', async () => { + for (const [offset, length] of [ + [-1, 1], + [0, 0], + [0, 512 * 1024 + 1], + [Number.MAX_SAFE_INTEGER, 2] + ]) { + await expect(readLocalFileRange(join(folder, 'missing.csv'), offset, length)).rejects.toThrow( + 'Invalid file read range' + ) + } +}) diff --git a/src/main/ipc/filesystem/local-file-range-read.ts b/src/main/ipc/filesystem/local-file-range-read.ts new file mode 100644 index 00000000000..d9baaf66ef3 --- /dev/null +++ b/src/main/ipc/filesystem/local-file-range-read.ts @@ -0,0 +1,31 @@ +import type { RuntimeFileReadChunkResult } from '../../../shared/runtime-types' +import { openLocalRegularFile } from './local-regular-file-read' + +export async function readLocalFileRange( + filePath: string, + offset: number, + length: number +): Promise { + if ( + !Number.isSafeInteger(offset) || + offset < 0 || + !Number.isSafeInteger(length) || + length < 1 || + length > 512 * 1024 || + offset > Number.MAX_SAFE_INTEGER - (length - 1) + ) { + throw new Error('Invalid file read range') + } + const { handle, stats } = await openLocalRegularFile(filePath) + try { + const buffer = Buffer.alloc(Math.min(length, Math.max(0, stats.size - offset))) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset) + return { + contentBase64: buffer.subarray(0, bytesRead).toString('base64'), + bytesRead, + eof: offset + bytesRead >= stats.size + } + } finally { + await handle.close() + } +} diff --git a/src/main/ipc/filesystem/local-regular-file-read.test.ts b/src/main/ipc/filesystem/local-regular-file-read.test.ts new file mode 100644 index 00000000000..995e40ed09e --- /dev/null +++ b/src/main/ipc/filesystem/local-regular-file-read.test.ts @@ -0,0 +1,106 @@ +import { mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readLocalFileContent } from './filesystem-file-content-inspection' +import { + assertLocalWriteTargetIsRegularFile, + NOT_A_REGULAR_FILE_MESSAGE, + openLocalRegularFile, + readLocalFileBounded +} from './local-regular-file-read' + +let base: string + +beforeEach(async () => { + base = await mkdtemp(join(await realpath(tmpdir()), 'orca-regular-file-read-')) +}) + +afterEach(async () => { + await rm(base, { recursive: true, force: true }) +}) + +describe('local regular-file reads', () => { + it('reads a regular file', async () => { + const filePath = join(base, 'notes.txt') + await writeFile(filePath, 'hello\n') + + await expect(readLocalFileContent(filePath)).resolves.toEqual({ + content: 'hello\n', + isBinary: false + }) + }) + + it('refuses a directory', async () => { + await expect(readLocalFileContent(base)).rejects.toThrow() + }) + + it('caps a read at the limit even when the file is bigger than its handle claimed', async () => { + const filePath = join(base, 'big.txt') + await writeFile(filePath, 'a'.repeat(2048)) + const { handle } = await openLocalRegularFile(filePath) + try { + await expect(readLocalFileBounded(handle, 1024)).rejects.toThrow('File too large') + } finally { + await handle.close() + } + }) + + it('sizes the read from fstat, so a small file costs a small buffer', async () => { + const filePath = join(base, 'small.txt') + await writeFile(filePath, 'hello') + const { handle, stats } = await openLocalRegularFile(filePath) + const read = vi.spyOn(handle, 'read') + try { + await expect(readLocalFileBounded(handle, 1024 * 1024, stats.size)).resolves.toEqual( + Buffer.from('hello') + ) + // One read sized past the reported length, then a 1-byte probe confirming EOF. + expect(read.mock.calls.map((call) => call.at(2))).toEqual([6, 1]) + } finally { + await handle.close() + } + }) + + it('keeps reading a file that grew past its reported size, up to the cap', async () => { + const filePath = join(base, 'grew.txt') + await writeFile(filePath, 'a'.repeat(5000)) + const { handle } = await openLocalRegularFile(filePath) + try { + await expect(readLocalFileBounded(handle, 1024 * 1024, 10)).resolves.toHaveLength(5000) + await expect(readLocalFileBounded(handle, 4096, 10)).rejects.toThrow('File too large') + } finally { + await handle.close() + } + }) + + // Why: device files are POSIX-only. + describe.skipIf(process.platform === 'win32')('non-regular files', () => { + it.each(['/dev/zero', '/dev/urandom'])( + 'refuses %s before reading any of it', + async (device) => { + await expect(readLocalFileContent(device)).rejects.toThrow(NOT_A_REGULAR_FILE_MESSAGE) + } + ) + + it('refuses a symlink to a device', async () => { + const link = join(base, 'zero.png') + await symlink('/dev/zero', link) + + await expect(readLocalFileContent(link)).rejects.toThrow(NOT_A_REGULAR_FILE_MESSAGE) + }) + + it('refuses writing over a device but allows a regular or missing file', async () => { + const filePath = join(base, 'notes.txt') + await writeFile(filePath, 'x') + + await expect(assertLocalWriteTargetIsRegularFile('/dev/null')).rejects.toThrow( + NOT_A_REGULAR_FILE_MESSAGE + ) + await expect(assertLocalWriteTargetIsRegularFile(filePath)).resolves.toBeUndefined() + await expect( + assertLocalWriteTargetIsRegularFile(join(base, 'missing.txt')) + ).resolves.toBeUndefined() + }) + }) +}) diff --git a/src/main/ipc/filesystem/local-regular-file-read.ts b/src/main/ipc/filesystem/local-regular-file-read.ts new file mode 100644 index 00000000000..acffa8cb109 --- /dev/null +++ b/src/main/ipc/filesystem/local-regular-file-read.ts @@ -0,0 +1,89 @@ +import { constants, type Stats } from 'node:fs' +import { open, type FileHandle } from 'node:fs/promises' +import { isENOENT } from '../filesystem-path-containment' + +export const NOT_A_REGULAR_FILE_MESSAGE = 'Not a regular file' + +// Why O_NONBLOCK: opening a FIFO would otherwise wait for a writer forever; regular files ignore +// the flag and Windows has none. +export const LOCAL_READ_OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) +const LOCAL_WRITE_PROBE_FLAGS = constants.O_WRONLY | (constants.O_NONBLOCK ?? 0) +const READ_CHUNK_BYTES = 1024 * 1024 + +export function fileTooLargeError(size: number, limit: number): Error { + return new Error( + `File too large: ${(size / 1024 / 1024).toFixed(1)}MB exceeds ${limit / 1024 / 1024}MB limit` + ) +} + +/** Opens a path for reading, refusing anything but a regular file (FIFO, device, socket, directory). */ +export async function openLocalRegularFile( + filePath: string +): Promise<{ handle: FileHandle; stats: Stats }> { + const handle = await open(filePath, LOCAL_READ_OPEN_FLAGS) + try { + const stats = await handle.stat() + if (!stats.isFile()) { + throw new Error(NOT_A_REGULAR_FILE_MESSAGE) + } + return { handle, stats } + } catch (error) { + await handle.close() + throw error + } +} + +/** + * Reads from the start of the handle, sized from its fstat so a small file costs a small buffer. + * The size is only a hint: a file that grows past it is read on in bounded chunks, and the cap holds + * even when a file reports a false size. + */ +export async function readLocalFileBounded( + handle: FileHandle, + limit: number, + expectedSize = 0 +): Promise { + const chunks: Buffer[] = [] + let total = 0 + let nextChunkBytes = Math.max(0, Math.min(expectedSize, limit)) + 1 + while (true) { + const chunk = Buffer.allocUnsafe(Math.min(nextChunkBytes, limit + 1 - total)) + const { bytesRead } = await handle.read(chunk, 0, chunk.length, total) + if (bytesRead === 0) { + return chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, total) + } + chunks.push(chunk.subarray(0, bytesRead)) + total += bytesRead + if (total > limit) { + throw fileTooLargeError(total, limit) + } + // Why a 1-byte probe after a short read: it confirms EOF without another full-size buffer. + nextChunkBytes = bytesRead < chunk.length ? 1 : READ_CHUNK_BYTES + } +} + +export async function readLocalFilePrefix(handle: FileHandle, bytes: number): Promise { + const probe = Buffer.alloc(bytes) + const { bytesRead } = await handle.read(probe, 0, probe.length, 0) + return probe.subarray(0, bytesRead) +} + +/** Refuses writing over an existing non-regular file, e.g. a device or FIFO. */ +export async function assertLocalWriteTargetIsRegularFile(filePath: string): Promise { + let handle: FileHandle + try { + handle = await open(filePath, LOCAL_WRITE_PROBE_FLAGS) + } catch (error) { + if (isENOENT(error)) { + return + } + throw error + } + try { + if (!(await handle.stat()).isFile()) { + throw new Error(NOT_A_REGULAR_FILE_MESSAGE) + } + } finally { + await handle.close() + } +} diff --git a/src/main/ipc/floating-workspace-directory.test.ts b/src/main/ipc/floating-workspace-directory.test.ts index 2b372586cf1..55438efe587 100644 --- a/src/main/ipc/floating-workspace-directory.test.ts +++ b/src/main/ipc/floating-workspace-directory.test.ts @@ -4,9 +4,8 @@ import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings } from '../../shared/global-settings-types' -const { appGetPathMock, authorizeExternalPathMock } = vi.hoisted(() => ({ - appGetPathMock: vi.fn(), - authorizeExternalPathMock: vi.fn() +const { appGetPathMock } = vi.hoisted(() => ({ + appGetPathMock: vi.fn() })) vi.mock('electron', () => ({ @@ -15,13 +14,9 @@ vi.mock('electron', () => ({ } })) -vi.mock('./filesystem-auth', () => ({ - authorizeExternalPath: authorizeExternalPathMock -})) - import { ensureDefaultFloatingWorkspacePath, - grantFloatingWorkspaceDirectory, + trustFloatingWorkspaceDirectory, resolveFloatingTerminalCwd, sanitizeFloatingWorkspaceDirectorySetting } from './floating-workspace-directory' @@ -48,7 +43,7 @@ function createStore(settings: Partial = {}): TestStore { return store } -describe('floating workspace directory authorization', () => { +describe('floating workspace directory', () => { let tempRoot: string let homeDir: string let userDataDir: string @@ -67,7 +62,6 @@ describe('floating workspace directory authorization', () => { } throw new Error(`unexpected app path: ${name}`) }) - authorizeExternalPathMock.mockClear() }) afterEach(async () => { @@ -78,46 +72,37 @@ describe('floating workspace directory authorization', () => { await symlink(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir') } - it('defaults terminal cwd to home without authorizing home for markdown writes', async () => { + it('defaults terminal cwd to home', async () => { const store = createStore() await expect(resolveFloatingTerminalCwd(store as never)).resolves.toBe(homeDir) - - expect(authorizeExternalPathMock).not.toHaveBeenCalledWith(homeDir) }) it('keeps the app-owned directory for floating markdown notes', async () => { await expect(ensureDefaultFloatingWorkspacePath()).resolves.toBe( path.join(userDataDir, 'floating-workspace') ) - - expect(authorizeExternalPathMock).toHaveBeenCalledWith( - path.join(userDataDir, 'floating-workspace') - ) }) - it('persists picker-approved directories and reauthorizes them on resolution', async () => { + it('persists picker-approved directories and resolves them as the cwd', async () => { const store = createStore() const selectedDir = path.join(tempRoot, 'notes') await mkdir(selectedDir) const canonicalSelectedDir = await realpath(selectedDir) - await grantFloatingWorkspaceDirectory(store as never, selectedDir) + await trustFloatingWorkspaceDirectory(store, selectedDir) expect(store.settings.floatingTerminalTrustedCwds).toEqual([canonicalSelectedDir]) - expect(authorizeExternalPathMock).toHaveBeenCalledWith(canonicalSelectedDir) - authorizeExternalPathMock.mockClear() await expect( resolveFloatingTerminalCwd(store as never, { path: selectedDir, requireTrusted: true }) ).resolves.toBe(canonicalSelectedDir) - expect(authorizeExternalPathMock).toHaveBeenCalledWith(canonicalSelectedDir) }) - it('stores symlink grants as canonical targets and rejects the link after retargeting', async () => { + it('stores a symlinked choice as its canonical target and rejects the link after retargeting', async () => { const store = createStore() const originalTarget = path.join(tempRoot, 'original-target') const retargetedTarget = path.join(tempRoot, 'retargeted-target') @@ -127,16 +112,12 @@ describe('floating workspace directory authorization', () => { await symlinkDirectory(originalTarget, selectedLink) const canonicalOriginalTarget = await realpath(originalTarget) - await grantFloatingWorkspaceDirectory(store as never, selectedLink) + await trustFloatingWorkspaceDirectory(store, selectedLink) expect(store.settings.floatingTerminalTrustedCwds).toEqual([canonicalOriginalTarget]) - expect(authorizeExternalPathMock).toHaveBeenCalledWith(canonicalOriginalTarget) await unlink(selectedLink) await symlinkDirectory(retargetedTarget, selectedLink) - const canonicalRetargetedTarget = await realpath(retargetedTarget) - - authorizeExternalPathMock.mockClear() await expect( resolveFloatingTerminalCwd(store as never, { path: selectedLink, @@ -146,10 +127,9 @@ describe('floating workspace directory authorization', () => { await expect( sanitizeFloatingWorkspaceDirectorySetting(store as never, selectedLink) ).resolves.toBe('') - expect(authorizeExternalPathMock).not.toHaveBeenCalledWith(canonicalRetargetedTarget) }) - it('keeps temporarily inaccessible trusted directories when adding a new grant', async () => { + it('keeps temporarily inaccessible trusted directories when adding a new one', async () => { const missingTrustedDir = path.join(tempRoot, 'offline-drive', 'notes') const selectedDir = path.join(tempRoot, 'new-notes') await mkdir(selectedDir) @@ -158,7 +138,7 @@ describe('floating workspace directory authorization', () => { floatingTerminalTrustedCwds: [missingTrustedDir] }) - await grantFloatingWorkspaceDirectory(store as never, selectedDir) + await trustFloatingWorkspaceDirectory(store, selectedDir) expect(store.settings.floatingTerminalTrustedCwds).toEqual([ missingTrustedDir, @@ -200,6 +180,5 @@ describe('floating workspace directory authorization', () => { await expect(resolveFloatingTerminalCwd(store as never, { path: arbitraryDir })).resolves.toBe( arbitraryDir ) - expect(authorizeExternalPathMock).not.toHaveBeenCalledWith(arbitraryDir) }) }) diff --git a/src/main/ipc/floating-workspace-directory.ts b/src/main/ipc/floating-workspace-directory.ts index 9e5f7d34cb7..7c7a70cdd34 100644 --- a/src/main/ipc/floating-workspace-directory.ts +++ b/src/main/ipc/floating-workspace-directory.ts @@ -5,7 +5,6 @@ import { app } from 'electron' import type { GlobalSettings } from '../../shared/global-settings-types' import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' import type { Store } from '../persistence' -import { authorizeExternalPath } from './filesystem-auth' const FLOATING_WORKSPACE_DIRNAME = 'floating-workspace' @@ -68,12 +67,14 @@ function isTrustedFloatingWorkspaceDirectory( return getTrustedFloatingWorkspaceDirectories(settings).has(path.resolve(canonicalDirPath)) } +/** The app-owned folder floating markdown documents are created in; a desktop-only root. */ +export function getDefaultFloatingWorkspacePath(): string { + return path.join(app.getPath('userData'), FLOATING_WORKSPACE_DIRNAME) +} + export async function ensureDefaultFloatingWorkspacePath(): Promise { - const cwd = path.join(app.getPath('userData'), FLOATING_WORKSPACE_DIRNAME) + const cwd = getDefaultFloatingWorkspacePath() await mkdir(cwd, { recursive: true }) - // Why: the default floating workspace lives outside repo roots by design; - // authorize only this app-owned directory instead of widening access to ~. - authorizeExternalPath(cwd) return cwd } @@ -94,18 +95,16 @@ export async function resolveFloatingTerminalCwd( return ensureDefaultFloatingWorkspacePath() } + // Why: only picker-approved directories may become the cwd, so arbitrary settings text can't. if (isTrustedFloatingWorkspaceDirectory(canonicalCwd, store.getSettings())) { - // Why: picker-approved directories are persisted as explicit grants, so a - // restart can restore file creation access without trusting arbitrary text. - authorizeExternalPath(canonicalCwd) return canonicalCwd } return args?.requireTrusted === true ? ensureDefaultFloatingWorkspacePath() : cwd } -export async function grantFloatingWorkspaceDirectory( - store: Store, +export async function trustFloatingWorkspaceDirectory( + store: Pick, dirPath: string ): Promise { const resolvedDir = resolveFloatingWorkspaceInput(dirPath) @@ -113,7 +112,6 @@ export async function grantFloatingWorkspaceDirectory( if (!canonicalDir) { return } - authorizeExternalPath(canonicalDir) const trustedDirectories = await getPreservedTrustedFloatingWorkspaceDirectories( store.getSettings() ) diff --git a/src/main/ipc/jira-user-search.ts b/src/main/ipc/jira-user-search.ts new file mode 100644 index 00000000000..2a5861aaff8 --- /dev/null +++ b/src/main/ipc/jira-user-search.ts @@ -0,0 +1,44 @@ +import { ipcMain } from 'electron' +import { listAssignableUsers, listAssignableUsersForProject, searchUsers } from '../jira/issues' + +function normalizeSiteId(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +/** Registers the `jira:*` user-search IPC handlers (assignable and site-wide). */ +export function registerJiraUserSearchHandlers(): void { + ipcMain.handle( + 'jira:listAssignableUsers', + async (_event, args: { key: string; query?: string; siteId?: string }) => { + if (typeof args?.key !== 'string' || !args.key.trim()) { + return [] + } + return listAssignableUsers( + args.key.trim(), + typeof args.query === 'string' ? args.query : undefined, + normalizeSiteId(args.siteId) + ) + } + ) + + ipcMain.handle( + 'jira:listAssignableUsersForProject', + async (_event, args: { projectIdOrKey: string; query?: string; siteId?: string }) => { + if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) { + return [] + } + return listAssignableUsersForProject( + args.projectIdOrKey.trim(), + typeof args.query === 'string' ? args.query : undefined, + normalizeSiteId(args.siteId) + ) + } + ) + + ipcMain.handle('jira:searchUsers', async (_event, args?: { query?: string; siteId?: string }) => { + return searchUsers( + typeof args?.query === 'string' ? args.query : undefined, + normalizeSiteId(args?.siteId) + ) + }) +} diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts index 0d78eec5f44..6d0db96c1a2 100644 --- a/src/main/ipc/jira.ts +++ b/src/main/ipc/jira.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron' import { connect, disconnect, getStatus, selectSite, testConnection } from '../jira/client' import { _resetPreflightCache } from './preflight' import { JiraCancellableRequests } from './jira-cancellable-requests' +import { registerJiraUserSearchHandlers } from './jira-user-search' import { addIssueComment, createIssue, @@ -9,7 +10,6 @@ import { getIssueSummary, getIssueComments, getProjectStatusOrder, - listAssignableUsers, listCreateFields, listIssueTypes, listIssues, @@ -17,7 +17,6 @@ import { listProjects, listTransitions, searchIssues, - searchUsers, updateIssue } from '../jira/issues' import type { @@ -285,26 +284,7 @@ export function registerJiraHandlers(): void { return listPriorities(normalizeSiteId(args?.siteId)) }) - ipcMain.handle( - 'jira:listAssignableUsers', - async (_event, args: { key: string; query?: string; siteId?: string }) => { - if (typeof args?.key !== 'string' || !args.key.trim()) { - return [] - } - return listAssignableUsers( - args.key.trim(), - typeof args.query === 'string' ? args.query : undefined, - normalizeSiteId(args.siteId) - ) - } - ) - - ipcMain.handle('jira:searchUsers', async (_event, args?: { query?: string; siteId?: string }) => { - return searchUsers( - typeof args?.query === 'string' ? args.query : undefined, - normalizeSiteId(args?.siteId) - ) - }) + registerJiraUserSearchHandlers() ipcMain.handle('jira:listTransitions', async (_event, args: { key: string; siteId?: string }) => { if (typeof args?.key !== 'string' || !args.key.trim()) { diff --git a/src/main/ipc/keybindings.test.ts b/src/main/ipc/keybindings.test.ts index ec8016287d5..7199d39a0a4 100644 --- a/src/main/ipc/keybindings.test.ts +++ b/src/main/ipc/keybindings.test.ts @@ -1,21 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { KeybindingFileSnapshot } from '../../shared/keybindings' -const { - authorizeExternalPathMock, - getAllWindowsMock, - handleMock, - openPathMock, - rebuildAppMenuMock, - showItemInFolderMock -} = vi.hoisted(() => ({ - authorizeExternalPathMock: vi.fn(), - getAllWindowsMock: vi.fn(() => []), - handleMock: vi.fn(), - openPathMock: vi.fn(), - rebuildAppMenuMock: vi.fn(), - showItemInFolderMock: vi.fn() -})) +const { getAllWindowsMock, handleMock, openPathMock, rebuildAppMenuMock, showItemInFolderMock } = + vi.hoisted(() => ({ + getAllWindowsMock: vi.fn(() => []), + handleMock: vi.fn(), + openPathMock: vi.fn(), + rebuildAppMenuMock: vi.fn(), + showItemInFolderMock: vi.fn() + })) vi.mock('electron', () => ({ BrowserWindow: { @@ -30,10 +23,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('./filesystem-auth', () => ({ - authorizeExternalPath: authorizeExternalPathMock -})) - vi.mock('../menu/register-app-menu', () => ({ rebuildAppMenu: rebuildAppMenuMock })) @@ -60,7 +49,6 @@ function getHandler(channel: string): (...args: unknown[]) => unknown { describe('registerKeybindingHandlers', () => { beforeEach(() => { - authorizeExternalPathMock.mockReset() getAllWindowsMock.mockReturnValue([]) handleMock.mockReset() openPathMock.mockReset() @@ -68,11 +56,10 @@ describe('registerKeybindingHandlers', () => { showItemInFolderMock.mockReset() }) - it('authorizes the keybindings file for in-app editing when ensuring it exists', () => { + it('returns the keybindings file when ensuring it exists', () => { registerKeybindingHandlers({ ensureFile: vi.fn(() => snapshot) } as never) expect(getHandler('keybindings:ensureFile')()).toBe(snapshot) - expect(authorizeExternalPathMock).toHaveBeenCalledWith(snapshot.path) }) it('reconciles plugin command conflicts after a shortcut edit', () => { @@ -92,12 +79,11 @@ describe('registerKeybindingHandlers', () => { expect(onChanged).toHaveBeenCalledOnce() }) - it('authorizes the keybindings file before opening it outside Orca', async () => { + it('opens the keybindings file outside Orca', async () => { openPathMock.mockResolvedValue('') registerKeybindingHandlers({ ensureFile: vi.fn(() => snapshot) } as never) await expect(getHandler('keybindings:openFile')()).resolves.toBe(snapshot) - expect(authorizeExternalPathMock).toHaveBeenCalledWith(snapshot.path) expect(openPathMock).toHaveBeenCalledWith(snapshot.path) }) }) diff --git a/src/main/ipc/keybindings.ts b/src/main/ipc/keybindings.ts index 7a71f3116c0..6e137bf6ca6 100644 --- a/src/main/ipc/keybindings.ts +++ b/src/main/ipc/keybindings.ts @@ -2,7 +2,6 @@ import { BrowserWindow, ipcMain, shell } from 'electron' import type { KeybindingActionId, KeybindingFileSnapshot } from '../../shared/keybindings' import type { KeybindingService } from '../keybindings/keybinding-service' import { rebuildAppMenu } from '../menu/register-app-menu' -import { authorizeExternalPath } from './filesystem-auth' function broadcastKeybindingsChanged(snapshot: KeybindingFileSnapshot): void { for (const window of BrowserWindow.getAllWindows()) { @@ -21,9 +20,6 @@ export function registerKeybindingHandlers( ipcMain.handle('keybindings:ensureFile', () => { const snapshot = service.ensureFile() - // Why: keybindings.json lives in Orca's app config directory, not inside a - // workspace. Opening it in the editor still needs normal fs IPC access. - authorizeExternalPath(snapshot.path) broadcastKeybindingsChanged(snapshot) onChanged?.() return snapshot @@ -48,7 +44,6 @@ export function registerKeybindingHandlers( ipcMain.handle('keybindings:openFile', async () => { const snapshot = service.ensureFile() - authorizeExternalPath(snapshot.path) const error = await shell.openPath(snapshot.path) if (error) { throw new Error(error) @@ -58,7 +53,6 @@ export function registerKeybindingHandlers( ipcMain.handle('keybindings:revealFile', () => { const snapshot = service.ensureFile() - authorizeExternalPath(snapshot.path) shell.showItemInFolder(snapshot.path) return snapshot }) diff --git a/src/main/ipc/local-file-access-resolution-network-path.test.ts b/src/main/ipc/local-file-access-resolution-network-path.test.ts new file mode 100644 index 00000000000..b2340146526 --- /dev/null +++ b/src/main/ipc/local-file-access-resolution-network-path.test.ts @@ -0,0 +1,382 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' +import type * as NodePath from 'node:path' +import type { Store } from '../persistence' + +// Why win32 paths on every host: a UNC image in a document is a Windows credential leak, and the +// guarantee is that its path text is refused before any filesystem call can reach the network. +vi.mock('node:path', async () => { + const actual = await vi.importActual('node:path') + return { ...actual.win32, default: actual.win32 } +}) + +const { fsCalls } = vi.hoisted(() => { + const calls: string[] = [] + return { fsCalls: calls } +}) + +vi.mock('node:fs/promises', () => { + const record = (name: string) => + vi.fn(async (target: unknown) => { + fsCalls.push(`${name} ${String(target)}`) + if (name !== 'realpath') { + return { isFile: () => true, isDirectory: () => false } + } + // A project image that is really a link to a device name. + if (String(target).endsWith('share-link.png')) { + // A local image that is really a link onto a network share. + return '\\\\nas\\pics\\shot.png' + } + return String(target).endsWith('dev-link.png') ? 'C:\\repo\\CON.png' : target + }) + return { realpath: record('realpath'), stat: record('stat'), open: record('open') } +}) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + statSync: vi.fn((target: unknown) => { + fsCalls.push(`statSync ${String(target)}`) + throw new Error('statSync is not expected') + }) + } +}) +vi.mock('electron', () => ({ app: { getPath: () => 'C:\\Users\\me\\AppData\\Roaming\\Orca' } })) +vi.mock('../repo-worktrees', () => ({ listRepoWorktreeGraph: vi.fn(async () => []) })) + +import { + resolveLocalFileRequestPath, + resolveLocalRenamePaths, + resolveLocalRequestPath +} from './local-file-access-resolution' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members. +const store = { + getRepos: () => [ + { id: 'repo', path: 'C:\\repo', displayName: 'repo', badgeColor: '#000', addedAt: 0 } + ], + getProjects: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) +} as unknown as Store + +const besideTodo = { kind: 'document-folder', documentPath: 'C:\\Users\\me\\notes\\todo.md' } + +const networkTargets = [ + '\\\\attacker.example\\share\\x.png', + '//attacker.example/share/x.png', + '\\\\?\\UNC\\attacker.example\\share\\x.png' +] + +describe('document images on a network share', () => { + beforeEach(() => { + fsCalls.length = 0 + }) + + it.each(networkTargets)( + 'refuses %s from a project document without touching it', + async (target) => { + await expect( + resolveLocalFileRequestPath( + target, + { kind: 'document-resource', documentPath: 'C:\\repo\\README.md' }, + store + ) + ).rejects.toThrow('Access denied') + expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([]) + } + ) + + it.each(networkTargets)( + 'refuses %s from a document outside every project without touching it', + async (target) => { + await expect( + resolveLocalFileRequestPath( + target, + { kind: 'document-resource', documentPath: 'C:\\Users\\me\\notes\\todo.md' }, + store + ) + ).rejects.toThrow('Access denied') + expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([]) + } + ) + + it('refuses it with no declared access too', async () => { + await expect(resolveLocalFileRequestPath(networkTargets[0], undefined, store)).rejects.toThrow( + 'Access denied' + ) + expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([]) + }) +}) + +const CHAT_IMAGE = { kind: 'chat-image' } as const + +describe('chat transcript images on Windows', () => { + beforeEach(() => { + fsCalls.length = 0 + }) + + it.each([...networkTargets, '\\\\.\\C:\\x.png', '//?/C:/x.png'])( + 'refuses %s without touching it', + async (target) => { + await expect(resolveLocalFileRequestPath(target, CHAT_IMAGE, store)).rejects.toThrow( + 'Access denied' + ) + expect(fsCalls).toEqual([]) + } + ) + + it.each([ + 'C:\\Users\\me\\Pictures\\shot.png', + '\\\\wsl.localhost\\Ubuntu\\home\\me\\shot.png', + '\\\\wsl$\\Ubuntu\\tmp\\agent.webp' + ])('reads the local image %s in place', async (target) => { + await expect(resolveLocalFileRequestPath(target, CHAT_IMAGE, store)).resolves.toBe(target) + }) +}) + +describe('Windows reserved device names in automatic image loads', () => { + beforeEach(() => { + fsCalls.length = 0 + }) + + const deviceTargets = [ + 'C:\\Users\\me\\notes\\NUL.png', + 'C:\\Users\\me\\notes\\com1.jpg', + 'C:\\Users\\me\\notes\\Lpt9 .gif', + 'C:\\Users\\me\\notes\\aux..png', + 'C:\\Users\\me\\notes\\CON.tar.png', + 'C:\\Users\\me\\notes\\NUL:.png', + 'C:\\Users\\me\\notes\\COM1:.png', + 'C:\\Users\\me\\notes\\NUL:stream.png', + 'C:\\Users\\me\\notes\\CONIN$.png', + 'C:\\Users\\me\\notes\\CONOUT$', + 'C:\\Users\\me\\notes\\clock$.jpg', + 'C:\\Users\\me\\notes\\COM0.png', + 'C:\\Users\\me\\notes\\LPT0.png', + 'C:\\Users\\me\\notes\\com¹.png' + ] + + it.each(deviceTargets)('refuses %s as a chat image without touching it', async (target) => { + await expect(resolveLocalFileRequestPath(target, CHAT_IMAGE, store)).rejects.toThrow( + 'Access denied' + ) + expect(fsCalls).toEqual([]) + }) + + it.each(deviceTargets)( + 'refuses %s from a document beside it without touching it', + async (target) => { + await expect( + resolveLocalFileRequestPath( + target, + { kind: 'document-resource', documentPath: 'C:\\Users\\me\\notes\\todo.md' }, + store + ) + ).rejects.toThrow('Access denied') + expect(fsCalls).toEqual([]) + } + ) + + it.each(deviceTargets)( + 'refuses %s as a new file beside an opened document without touching it', + async (target) => { + await expect( + resolveLocalRequestPath(target, besideTodo, store, 'import-into') + ).rejects.toThrow('Access denied') + expect(fsCalls).toEqual([]) + } + ) + + it.each([...networkTargets, 'C:\\Users\\me\\other\\shot.png'])( + 'refuses %s outside an opened document folder without touching it', + async (target) => { + await expect( + resolveLocalRequestPath(target, besideTodo, store, 'import-into') + ).rejects.toThrow('Access denied') + expect(fsCalls).toEqual([]) + } + ) + + it('still reads an ordinary image whose name only starts like a device', async () => { + await expect( + resolveLocalFileRequestPath('C:\\Users\\me\\notes\\console.png', CHAT_IMAGE, store) + ).resolves.toBe('C:\\Users\\me\\notes\\console.png') + }) +}) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members. +const shareProjectStore = { + getRepos: () => [ + { + id: 'repo', + path: '\\\\server\\share\\repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + ], + getProjects: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) +} as unknown as Store + +describe('automatic image loads and dot segments that resolve to a device name', () => { + beforeEach(() => { + fsCalls.length = 0 + }) + + it.each(['C:\\d\\NUL.png\\.', 'C:\\d\\COM1.png\\x\\..', 'C:/d/COM1.jpg/.'])( + 'refuses %s for both access kinds without touching it', + async (target) => { + await expect(resolveLocalFileRequestPath(target, CHAT_IMAGE, store)).rejects.toThrow( + 'Access denied' + ) + await expect( + resolveLocalFileRequestPath( + target, + { kind: 'document-resource', documentPath: 'C:\\d\\README.md' }, + store + ) + ).rejects.toThrow('Access denied') + expect(fsCalls).toEqual([]) + } + ) + + it('refuses a device name inside a project for automatic loads without touching it', async () => { + await expect( + resolveLocalFileRequestPath('C:\\repo\\NUL.png', CHAT_IMAGE, store) + ).rejects.toThrow('Access denied') + expect(fsCalls).toEqual([]) + }) + + it('refuses a project image whose real target is a device name', async () => { + await expect( + resolveLocalFileRequestPath( + 'C:\\repo\\dev-link.png', + { kind: 'document-resource', documentPath: 'C:\\repo\\README.md' }, + store + ) + ).rejects.toThrow('Access denied') + }) +}) + +describe('the default check runs first for every declared kind', () => { + beforeEach(() => { + fsCalls.length = 0 + }) + + it.each([ + ['user-file', 'read', { kind: 'user-file' }], + [ + 'document-resource', + 'read', + { kind: 'document-resource', documentPath: 'C:\\repo\\README.md' } + ], + ['chat-image', 'read', CHAT_IMAGE], + ['document-folder', 'import-into', besideTodo] + ] as const)( + 'never touches a share outside every project while resolving %s %s', + async (_kind, operation, access) => { + for (const target of networkTargets) { + await resolveLocalRequestPath(target, access, store, operation).catch(() => undefined) + } + expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([]) + } + ) + + it('never touches a share while resolving a rename of an opened document', async () => { + for (const target of networkTargets) { + await resolveLocalRenamePaths(besideTodo.documentPath, target, besideTodo, store) + } + expect(fsCalls.filter((call) => call.includes('attacker'))).toEqual([]) + }) +}) + +const besideShareDocument = { + kind: 'document-resource', + documentPath: '\\\\nas\\notes\\todo.md' +} + +const besideAccentedShareDocument = { + kind: 'document-resource', + documentPath: '\\\\n\u00e1s\\notes\\todo.md' +} + +describe('automatic image loads on a network share', () => { + beforeEach(() => { + fsCalls.length = 0 + }) + + it('reads a chat or document image inside a project the user added from the share', async () => { + const image = '\\\\server\\share\\repo\\out.png' + + await expect(resolveLocalFileRequestPath(image, CHAT_IMAGE, shareProjectStore)).resolves.toBe( + image + ) + await expect( + resolveLocalFileRequestPath( + image, + { kind: 'document-resource', documentPath: '\\\\server\\share\\repo\\README.md' }, + shareProjectStore + ) + ).resolves.toBe(image) + }) + + it('refuses a local link that leads onto a share outside every project', async () => { + await expect( + resolveLocalFileRequestPath('C:\\Users\\me\\share-link.png', CHAT_IMAGE, store) + ).rejects.toThrow('Access denied') + }) + + it('refuses a chat share image outside every project without touching the share', async () => { + await expect( + resolveLocalFileRequestPath('\\\\server\\share\\other\\x.png', CHAT_IMAGE, shareProjectStore) + ).rejects.toThrow('Access denied') + expect(fsCalls.filter((call) => call.includes('other'))).toEqual([]) + }) + + // Why beside a share document too: a host spelled with a look-alike (U+212A KELVIN SIGN, an NFD + // accent) can pass a case-folded folder comparison, so no share is loaded outside a project. + it.each([ + '\\\\nas\\notes\\x.png', + '\\\\NAS\\Notes\\img\\y.png', + '//nas/notes/z.png', + '\\\\nas\\other\\x.png', + '\\\\evil\\notes\\x.png', + '\\\\nas\\notes-evil\\x.png', + '\\\\attacker.example\\share\\x.png' + ])('refuses %s beside a share document without touching any share', async (target) => { + await expect( + resolveLocalFileRequestPath(target, besideShareDocument, shareProjectStore) + ).rejects.toThrow('Access denied') + expect(fsCalls).toEqual([]) + }) + + it.each(['\\\\bac\u212Aup\\notes\\x.png', '//bac\u212Aup/notes/x.png'])( + 'refuses the KELVIN SIGN host spelling %s beside a document on \\\\backup without touching it', + async (target) => { + await expect( + resolveLocalFileRequestPath( + target, + { kind: 'document-resource', documentPath: '\\\\backup\\notes\\todo.md' }, + shareProjectStore + ) + ).rejects.toThrow('Access denied') + expect(fsCalls).toEqual([]) + } + ) + + it('refuses an NFD spelling of an accented share host beside a document on it, without touching it', async () => { + await expect( + resolveLocalFileRequestPath( + '\\\\na\u0301s\\notes\\x.png', + besideAccentedShareDocument, + shareProjectStore + ) + ).rejects.toThrow('Access denied') + expect(fsCalls).toEqual([]) + }) +}) diff --git a/src/main/ipc/local-file-access-resolution.test.ts b/src/main/ipc/local-file-access-resolution.test.ts new file mode 100644 index 00000000000..e659a516c96 --- /dev/null +++ b/src/main/ipc/local-file-access-resolution.test.ts @@ -0,0 +1,469 @@ +import { mkdir, mkdtemp, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type * as RepoWorktrees from '../repo-worktrees' +import { resolveAuthorizedPath } from './filesystem-auth' +import { + resolveDesktopAuthorizedPath, + resolveLocalFileRequestPath, + resolveLocalRenamePaths, + resolveLocalRequestPath, + resolveLocalWriteRequestPath, + type LocalRequestOperation +} from './local-file-access-resolution' +import { readLocalFileContent } from './filesystem/filesystem-file-content-inspection' +import { + assertLocalWriteTargetIsRegularFile, + NOT_A_REGULAR_FILE_MESSAGE +} from './filesystem/local-regular-file-read' +import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' + +const { userData } = vi.hoisted(() => ({ userData: { path: '' } })) + +vi.mock('electron', () => ({ app: { getPath: () => userData.path } })) +vi.mock('../repo-worktrees', async () => { + const actual = await vi.importActual('../repo-worktrees') + return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) } +}) + +type Registration = { repoPaths?: string[]; folderPath?: string } + +function makeStore({ repoPaths = [], folderPath }: Registration): Store { + const repos = repoPaths.map((path, index) => ({ + id: `repo-${index}`, + path, + displayName: 'project', + badgeColor: '#000', + addedAt: 0 + })) + const folders = folderPath ? [{ id: 'folder', folderPath, projectGroupId: 'none' }] : [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members. + return { + getRepos: () => repos.map((repo) => ({ ...repo })), + getProjects: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => folders.map((folder) => ({ ...folder })), + getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) + } as unknown as Store +} + +const USER_FILE = { kind: 'user-file' } as const +const documentResource = (documentPath: string) => + ({ kind: 'document-resource', documentPath }) as const +const documentFolder = (documentPath: string) => + ({ kind: 'document-folder', documentPath }) as const + +async function settles(promise: Promise): Promise<'ok' | 'denied'> { + return promise.then( + () => 'ok', + () => 'denied' + ) +} + +let base: string +let project: string +let outside: string + +beforeEach(async () => { + invalidateAuthorizedRootsCache() + base = await mkdtemp(join(await realpath(tmpdir()), 'orca-file-access-')) + project = join(base, 'project') + outside = join(base, 'outside') + userData.path = join(base, 'user-data') + await mkdir(project) + await mkdir(outside) + await mkdir(join(userData.path, 'floating-workspace'), { recursive: true }) + await writeFile(join(outside, 'notes.txt'), 'outside notes\n') +}) + +afterEach(async () => { + await rm(base, { recursive: true, force: true }) +}) + +describe('user-file requests', () => { + it('read a regular file outside every project in place', async () => { + const store = makeStore({}) + const filePath = await resolveLocalFileRequestPath(join(outside, 'notes.txt'), USER_FILE, store) + + await expect(readLocalFileContent(filePath)).resolves.toEqual({ + content: 'outside notes\n', + isBinary: false + }) + }) + + it.each(['notes.txt', 'C:notes.txt'])('refuse the non-absolute path %s', async (relative) => { + await expect(resolveLocalFileRequestPath(relative, USER_FILE, makeStore({}))).rejects.toThrow( + 'absolute path' + ) + }) + + it('stat a directory but never read one', async () => { + const dirPath = await resolveLocalFileRequestPath(outside, USER_FILE, makeStore({})) + + expect((await stat(dirPath)).isDirectory()).toBe(true) + await expect(readLocalFileContent(dirPath)).rejects.toThrow() + }) + + it('save an outside file, but never onto a device', async () => { + const store = makeStore({}) + const filePath = join(outside, 'notes.txt') + + await expect(resolveLocalWriteRequestPath(filePath, USER_FILE, store)).resolves.toBe(filePath) + await expect(resolveLocalWriteRequestPath(filePath, undefined, store)).rejects.toThrow( + 'Access denied' + ) + if (process.platform !== 'win32') { + await expect(assertLocalWriteTargetIsRegularFile('/dev/null')).rejects.toThrow( + NOT_A_REGULAR_FILE_MESSAGE + ) + } + }) + + it.skipIf(process.platform === 'win32')('refuse /dev/zero promptly', async () => { + const filePath = await resolveLocalFileRequestPath('/dev/zero', USER_FILE, makeStore({})) + + await expect(readLocalFileContent(filePath)).rejects.toThrow(NOT_A_REGULAR_FILE_MESSAGE) + }) +}) + +describe('a declared kind never refuses what the default project check allows', () => { + const outsideDocument = () => join(outside, 'doc-folder', 'note.md') + const declaredKinds = (): [string, unknown, LocalRequestOperation][] => [ + ['user-file read', USER_FILE, 'read'], + ['user-file write', USER_FILE, 'write'], + ['document-resource read', documentResource(outsideDocument()), 'read'], + ['chat-image read', { kind: 'chat-image' }, 'read'], + [ + 'document-folder import-into', + { kind: 'document-folder', documentPath: outsideDocument() }, + 'import-into' + ] + ] + + beforeEach(async () => { + await mkdir(join(project, 'src'), { recursive: true }) + await mkdir(join(outside, 'doc-folder'), { recursive: true }) + await writeFile(join(project, 'src', 'notes.txt'), 'text') + }) + + it('accepts every in-project path the default accepts, for every kind and operation', async () => { + const store = makeStore({ repoPaths: [project] }) + const targets = [ + join(project, 'src', 'notes.txt'), + join(project, 'src'), + join(project, 'src', 'new-name.txt'), + join(userData.path, 'floating-workspace', 'scratch.md') + ] + for (const [label, access, operation] of declaredKinds()) { + for (const target of targets) { + const byDefault = await resolveLocalRequestPath(target, undefined, store, operation) + await expect( + resolveLocalRequestPath(target, access, store, operation), + `${label} ${target}` + ).resolves.toBe(byDefault) + } + } + }) + + it.skipIf(process.platform === 'win32')( + 'accepts an in-project link for every kind exactly as the default does', + async () => { + await symlink(join(project, 'src', 'notes.txt'), join(project, 'notes-link')) + const store = makeStore({ repoPaths: [project] }) + for (const [label, access, operation] of declaredKinds()) { + const byDefault = await resolveLocalRequestPath( + join(project, 'notes-link'), + undefined, + store, + operation + ) + await expect( + resolveLocalRequestPath(join(project, 'notes-link'), access, store, operation), + label + ).resolves.toBe(byDefault) + } + } + ) + + it('renames an in-project document exactly as the default does', async () => { + const store = makeStore({ repoPaths: [project] }) + const source = join(project, 'src', 'notes.txt') + for (const target of [ + join(project, 'src', 'new-name.txt'), + join(project, 'new-name.txt'), + join(userData.path, 'floating-workspace', 'scratch.md') + ]) { + const byDefault = await resolveLocalRenamePaths(source, target, undefined, store) + await expect( + resolveLocalRenamePaths(source, target, documentFolder(source), store), + target + ).resolves.toEqual(byDefault) + } + }) +}) + +describe('requests with no declared access (roots only)', () => { + it('refuse a file outside every project, even one a user-file request may read', async () => { + const store = makeStore({ repoPaths: [project] }) + + await expect( + resolveLocalFileRequestPath(join(outside, 'notes.txt'), undefined, store) + ).rejects.toThrow('Access denied') + await expect( + resolveLocalFileRequestPath(join(outside, 'notes.txt'), { kind: 'grant-everything' }, store) + ).rejects.toThrow('Access denied') + }) + + it('allow the app-owned floating-workspace folder on the desktop only', async () => { + const store = makeStore({ repoPaths: [project] }) + const untitled = join(userData.path, 'floating-workspace', 'untitled.md') + + await expect(resolveDesktopAuthorizedPath(untitled, store)).resolves.toBe(untitled) + await expect(resolveAuthorizedPath(untitled, store)).rejects.toThrow('Access denied') + }) + + it('refuse the home folder, which the floating workspace starts in', async () => { + await expect( + resolveDesktopAuthorizedPath(homedir(), makeStore({ repoPaths: [project] })) + ).rejects.toThrow('Access denied') + }) +}) + +// Why: creating a symlink on Windows needs elevation or Developer Mode. +describe.skipIf(process.platform === 'win32')('project symlinks under every spelling', () => { + let real: string + let alias: string + let alias2: string + let privateBase: string + let varAlias: string + let secret: string + + beforeEach(async () => { + // Mirrors macOS /var -> /private/var: an alias of an ancestor above the project root. + privateBase = join(base, 'private') + varAlias = join(base, 'var') + real = join(privateBase, 'real', 'project') + alias = join(base, 'alias-project') + alias2 = join(base, 'alias2-project') + await mkdir(join(real, 'sub'), { recursive: true }) + await symlink(privateBase, varAlias) + await symlink(real, alias) + await symlink(real, alias2) + secret = join(outside, 'secret.txt') + await writeFile(secret, 'secret\n') + await writeFile(join(real, 'notes.md'), 'notes\n') + await symlink(secret, join(real, 'escape.txt')) + await symlink(outside, join(real, 'dir-link')) + await symlink(outside, join(real, 'sub', 'deep-link')) + }) + + const viaDirLink = (root: string): string => join(root, 'dir-link', 'secret.txt') + + it.each<[string, () => Registration, () => string]>([ + ['a leaf symlink', () => ({ repoPaths: [real] }), () => join(real, 'escape.txt')], + ['a directory symlink', () => ({ repoPaths: [real] }), () => viaDirLink(real)], + ['a directory symlink via an alias', () => ({ repoPaths: [real] }), () => viaDirLink(alias)], + [ + 'a directory symlink, registered and named via two aliases', + () => ({ repoPaths: [alias] }), + () => viaDirLink(alias2) + ], + [ + 'a directory symlink, registered via alias and named canonically', + () => ({ folderPath: alias }), + () => viaDirLink(real) + ], + [ + 'a deep directory symlink in a folder workspace', + () => ({ folderPath: real }), + () => join(alias, 'sub', 'deep-link', 'secret.txt') + ], + [ + 'a directory symlink named with `..`', + () => ({ repoPaths: [real] }), + () => join(alias, 'sub', '..', 'dir-link', 'secret.txt') + ], + [ + 'a directory symlink via an ancestor alias', + () => ({ repoPaths: [real] }), + () => viaDirLink(real.replace(privateBase, varAlias)) + ], + [ + 'a leaf symlink via an ancestor alias', + () => ({ repoPaths: [real] }), + () => join(real.replace(privateBase, varAlias), 'escape.txt') + ] + ])('never read or write through %s out of the project', async (_label, register, named) => { + const store = makeStore(register()) + + expect(await settles(resolveLocalFileRequestPath(named(), undefined, store))).toBe('denied') + expect(await settles(resolveLocalWriteRequestPath(named(), undefined, store))).toBe('denied') + expect(await settles(resolveLocalFileRequestPath(secret, undefined, store))).toBe('denied') + }) + + it('reads a project link the user opened by name in place, while project requests stay refused', async () => { + const store = makeStore({ repoPaths: [real] }) + const link = join(real, 'escape.txt') + + expect(await settles(resolveLocalFileRequestPath(link, undefined, store))).toBe('denied') + const named = await resolveLocalFileRequestPath(link, USER_FILE, store) + await expect(readLocalFileContent(named)).resolves.toEqual({ + content: 'secret\n', + isBinary: false + }) + }) +}) + +describe('document-resource requests', () => { + let otherProject: string + + beforeEach(async () => { + otherProject = join(base, 'other-project') + await mkdir(join(project, 'docs'), { recursive: true }) + await mkdir(otherProject) + await writeFile(join(project, 'docs', 'README.md'), '# doc\n') + await writeFile(join(project, 'logo.png'), 'png') + await writeFile(join(otherProject, 'shared.png'), 'png') + await writeFile(join(project, 'notes.txt'), 'text') + await writeFile(join(outside, 'outside.png'), 'png') + await mkdir(join(outside, 'doc-folder', 'img'), { recursive: true }) + await writeFile(join(outside, 'doc-folder', 'note.md'), '# note\n') + await writeFile(join(outside, 'doc-folder', 'img', 'nested.png'), 'png') + await writeFile(join(outside, 'doc-folder', 'sibling.png'), 'png') + }) + + it('limit a project document to every project root, whatever the file type', async () => { + const store = makeStore({ repoPaths: [project, otherProject] }) + const access = documentResource(join(project, 'docs', 'README.md')) + const outcome = (path: string) => settles(resolveLocalFileRequestPath(path, access, store)) + + expect(await outcome(join(project, 'logo.png'))).toBe('ok') + expect(await outcome(join(otherProject, 'shared.png'))).toBe('ok') + expect(await outcome(join(project, 'notes.txt'))).toBe('ok') + expect(await outcome(join(outside, 'outside.png'))).toBe('denied') + }) + + it('limit a document outside every project to its own folder', async () => { + const store = makeStore({ repoPaths: [project] }) + const access = documentResource(join(outside, 'doc-folder', 'note.md')) + const outcome = (path: string) => settles(resolveLocalFileRequestPath(path, access, store)) + + expect(await outcome(join(outside, 'doc-folder', 'sibling.png'))).toBe('ok') + expect(await outcome(join(outside, 'doc-folder', 'img', 'nested.png'))).toBe('ok') + expect(await outcome(join(outside, 'outside.png'))).toBe('denied') + expect(await outcome('/dev/zero')).toBe('denied') + }) + + it.skipIf(process.platform === 'win32')( + 'refuse a symlink in the document folder that leads out of it', + async () => { + const store = makeStore({}) + await symlink(join(outside, 'outside.png'), join(outside, 'doc-folder', 'escape.png')) + const access = documentResource(join(outside, 'doc-folder', 'note.md')) + + expect( + await settles( + resolveLocalFileRequestPath(join(outside, 'doc-folder', 'escape.png'), access, store) + ) + ).toBe('denied') + } + ) + + it.skipIf(process.platform === 'win32')( + 'read a link of any name in a project or beside the document when its target stays there', + async () => { + await writeFile(join(outside, 'doc-folder', 'diagram'), 'png') + await symlink(join(outside, 'doc-folder', 'diagram'), join(outside, 'doc-folder', 'a.png')) + await symlink(join(project, 'notes.txt'), join(project, 'notes-link')) + const store = makeStore({ repoPaths: [project] }) + + const inProject = documentResource(join(project, 'docs', 'README.md')) + const besideDoc = documentResource(join(outside, 'doc-folder', 'note.md')) + expect( + await settles(resolveLocalFileRequestPath(join(project, 'notes-link'), inProject, store)) + ).toBe('ok') + expect( + await settles( + resolveLocalFileRequestPath(join(outside, 'doc-folder', 'a.png'), besideDoc, store) + ) + ).toBe('ok') + } + ) +}) + +describe('chat-image requests', () => { + const CHAT_IMAGE = { kind: 'chat-image' } as const + + it('read any local image file in place, whatever turn named it', async () => { + const shot = join(outside, 'shot.png') + await writeFile(shot, 'png') + + const filePath = await resolveLocalFileRequestPath(shot, CHAT_IMAGE, makeStore({})) + + await expect(readLocalFileContent(filePath)).resolves.toMatchObject({ + isBinary: true, + mimeType: 'image/png' + }) + }) + + it.each(['shot.avif', 'shot.bmp', 'shot.ico', 'shot.svg', 'shot.webp'])( + 'read %s', + async (name) => { + await writeFile(join(outside, name), 'image') + expect( + await settles(resolveLocalFileRequestPath(join(outside, name), CHAT_IMAGE, makeStore({}))) + ).toBe('ok') + } + ) + + it.skipIf(process.platform === 'win32')( + 'judge a link by its target: an extensionless link to an image loads', + async () => { + await writeFile(join(outside, 'shot.png'), 'png') + await symlink(join(outside, 'shot.png'), join(outside, 'latest-screenshot')) + + expect( + await settles( + resolveLocalFileRequestPath(join(outside, 'latest-screenshot'), CHAT_IMAGE, makeStore({})) + ) + ).toBe('ok') + } + ) + + it.each(['notes.txt', 'missing.png'])('refuse %s', async (name) => { + expect( + await settles(resolveLocalFileRequestPath(join(outside, name), CHAT_IMAGE, makeStore({}))) + ).toBe('denied') + }) + + it('refuse a relative path, a device and a PDF', async () => { + const store = makeStore({}) + await writeFile(join(outside, 'doc.pdf'), '%PDF') + + expect(await settles(resolveLocalFileRequestPath('shot.png', CHAT_IMAGE, store))).toBe('denied') + expect(await settles(resolveLocalFileRequestPath('/dev/zero', CHAT_IMAGE, store))).toBe( + 'denied' + ) + expect( + await settles(resolveLocalFileRequestPath(join(outside, 'doc.pdf'), CHAT_IMAGE, store)) + ).toBe('denied') + }) + + it.skipIf(process.platform === 'win32')( + 'refuse an image-named link to a text file or a device', + async () => { + await symlink(join(outside, 'notes.txt'), join(outside, 'secret.png')) + await symlink('/dev/zero', join(outside, 'zero.png')) + const store = makeStore({}) + + expect( + await settles(resolveLocalFileRequestPath(join(outside, 'secret.png'), CHAT_IMAGE, store)) + ).toBe('denied') + expect( + await settles(resolveLocalFileRequestPath(join(outside, 'zero.png'), CHAT_IMAGE, store)) + ).toBe('denied') + } + ) +}) diff --git a/src/main/ipc/local-file-access-resolution.ts b/src/main/ipc/local-file-access-resolution.ts new file mode 100644 index 00000000000..94054a0ddf2 --- /dev/null +++ b/src/main/ipc/local-file-access-resolution.ts @@ -0,0 +1,317 @@ +import { dirname, extname, isAbsolute, resolve } from 'node:path' +import { realpath, stat } from 'node:fs/promises' +import type { Store } from '../persistence' +import type { LocalFileAccess } from '../../shared/local-file-access' +import { + PATH_ACCESS_DENIED_MESSAGE, + resolveAuthorizedPath, + type ResolveAuthorizedPathOptions +} from './filesystem-auth' +import { isDescendantOrEqual } from './filesystem-path-containment' +import { PREVIEWABLE_BINARY_MIME_TYPES } from './filesystem/filesystem-file-content-inspection' +import { getDefaultFloatingWorkspacePath } from './floating-workspace-directory' +import { + isDeviceNamespacePath, + isNetworkSharePath, + isWindowsReservedDeviceName +} from './automatic-load-path-text' +import { NOT_A_REGULAR_FILE_MESSAGE } from './filesystem/local-regular-file-read' + +const USER_FILE_NEEDS_ABSOLUTE_PATH_MESSAGE = + 'Access denied: a file opened by name needs an absolute path.' +const USER_FILE_ACCESS: LocalFileAccess = { kind: 'user-file' } + +const CHAT_IMAGE_TYPE_MESSAGE = 'Access denied: a chat can only show local image files.' + +/** Desktop IPC's root check: the project roots plus the app-owned floating-workspace folder. */ +export async function resolveDesktopAuthorizedPath( + targetPath: string, + store: Store, + options: ResolveAuthorizedPathOptions = {} +): Promise { + return resolveAuthorizedPath(targetPath, store, { + ...options, + extraRoots: [getDefaultFloatingWorkspacePath()] + }) +} + +/** A file the user named is used where it is; no root applies, and nothing is remembered. */ +function resolveUserNamedLocalPath(targetPath: string): string { + // Why isAbsolute on the raw input: resolve() would anchor `notes.txt` or `C:notes` to main's cwd. + if (typeof targetPath !== 'string' || !isAbsolute(targetPath)) { + throw new Error(USER_FILE_NEEDS_ABSOLUTE_PATH_MESSAGE) + } + return resolve(targetPath) +} + +/** + * A user-named path that must be an existing regular file, e.g. a notebook or a log being tailed. + * Inside a project it resolves as the default check does, to the real file. + */ +export async function resolveUserNamedRegularFile( + targetPath: string, + store: Store +): Promise { + const filePath = await resolveLocalRequestPath(targetPath, USER_FILE_ACCESS, store, 'read') + if (!(await stat(filePath)).isFile()) { + throw new Error(NOT_A_REGULAR_FILE_MESSAGE) + } + return filePath +} + +// Why every previewable type but PDF: chat shows these in an
, which renders no PDF. +function isChatImage(filePath: string): boolean { + const extension = extname(filePath).toLowerCase() + return Boolean(PREVIEWABLE_BINARY_MIME_TYPES[extension]) && extension !== '.pdf' +} + +// Why the resolved path: `NUL.png\.` and `COM1.png\x\..` resolve to a device name. +function isRefusedAutomaticLoadPath(filePath: string): boolean { + return isDeviceNamespacePath(filePath) || isWindowsReservedDeviceName(filePath) +} + +/** + * A file a document references (an image, typically) beyond what the default check allows: one in + * the document's own folder, like the common markdown-preview rule. A target outside the folder, + * or a network share, is refused by its path text before any filesystem call; a symlink inside the + * folder is still resolved by the folder check. + */ +async function resolveDocumentResourcePath( + targetPath: string, + documentPath: string +): Promise { + if ( + typeof targetPath !== 'string' || + !isAbsolute(targetPath) || + typeof documentPath !== 'string' || + !isAbsolute(documentPath) + ) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + const resolvedTarget = resolve(targetPath) + if (isRefusedAutomaticLoadPath(resolvedTarget)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + const documentFolder = dirname(resolve(documentPath)) + if (isNetworkSharePath(resolvedTarget) || !isDescendantOrEqual(resolvedTarget, documentFolder)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + const realTarget = resolve(await realpath(resolvedTarget)) + if (!isDescendantOrEqual(realTarget, resolve(await realpath(documentFolder)))) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + if (isRefusedAutomaticLoadPath(realTarget)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + return realTarget +} + +/** + * An image shown in a chat transcript, whoever's turn named it: any absolute local image file, + * typed by its real target. Transcripts load as they scroll into view, so a network share is read + * only inside a project the user added from it (the default check); anywhere else its path text, + * like a device path, is refused before any filesystem call. + */ +async function resolveChatImagePath(targetPath: string, store: Store): Promise { + if (typeof targetPath !== 'string' || !isAbsolute(targetPath)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + const resolvedTarget = resolve(targetPath) + if (isRefusedAutomaticLoadPath(resolvedTarget)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + if (isNetworkSharePath(resolvedTarget)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + const realTarget = resolve(await realpath(resolvedTarget)) + if (isRefusedAutomaticLoadPath(realTarget)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + if (isNetworkSharePath(realTarget) && !isNetworkSharePath(resolvedTarget)) { + // Why: a local link may still lead onto a share; that is readable only inside a project. + await resolveDesktopAuthorizedPath(realTarget, store) + } + // Why the real target's type: `shot.png -> ~/.ssh/id_rsa` must not be read as an image. + if (!isChatImage(realTarget)) { + throw new Error(CHAT_IMAGE_TYPE_MESSAGE) + } + return realTarget +} + +/** + * Adding a file beside a document the user opened: the target must stay inside the document's own + * folder, symlinks included. + */ +async function resolveDocumentFolderPath( + targetPath: string, + documentPath: string +): Promise { + if ( + typeof targetPath !== 'string' || + !isAbsolute(targetPath) || + typeof documentPath !== 'string' || + !isAbsolute(documentPath) + ) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + const resolvedTarget = resolve(targetPath) + const documentFolder = dirname(resolve(documentPath)) + if ( + isRefusedAutomaticLoadPath(resolvedTarget) || + !isDescendantOrEqual(resolvedTarget, documentFolder) + ) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + const realTarget = resolve(await realpath(resolvedTarget)) + const realFolder = resolve(await realpath(documentFolder)) + if (isRefusedAutomaticLoadPath(realTarget) || !isDescendantOrEqual(realTarget, realFolder)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + return realTarget +} + +function isOpenedDocument(targetPath: unknown, documentPath: string): boolean { + return ( + typeof targetPath === 'string' && + isAbsolute(targetPath) && + isAbsolute(documentPath) && + resolve(targetPath) === resolve(documentPath) + ) +} + +// Why parse: IPC input is untyped, and an unrecognised access kind must fall back to roots only. +function parseLocalFileAccess(access: unknown): LocalFileAccess | undefined { + if (typeof access !== 'object' || access === null || !('kind' in access)) { + return undefined + } + if (access.kind === 'user-file') { + return { kind: 'user-file' } + } + if (access.kind === 'chat-image') { + return { kind: 'chat-image' } + } + if ( + access.kind === 'document-folder' && + 'documentPath' in access && + typeof access.documentPath === 'string' + ) { + return { kind: 'document-folder', documentPath: access.documentPath } + } + if ( + access.kind === 'document-resource' && + 'documentPath' in access && + typeof access.documentPath === 'string' + ) { + return { kind: 'document-resource', documentPath: access.documentPath } + } + return undefined +} + +/** What a desktop request does with its path; each declared kind adds access to only some. */ +export type LocalRequestOperation = 'read' | 'write' | 'rename-from' | 'rename-to' | 'import-into' + +type KindRule = (targetPath: string) => Promise + +function declaredKindRule( + fileAccess: LocalFileAccess, + operation: LocalRequestOperation, + store: Store +): KindRule | undefined { + switch (fileAccess.kind) { + case 'user-file': + // Why renames: resolveLocalRenamePaths declares this only for the opened document itself. + return operation !== 'import-into' + ? async (targetPath) => resolveUserNamedLocalPath(targetPath) + : undefined + case 'document-resource': + return operation === 'read' + ? (targetPath) => resolveDocumentResourcePath(targetPath, fileAccess.documentPath) + : undefined + case 'chat-image': + return operation === 'read' + ? (targetPath) => resolveChatImagePath(targetPath, store) + : undefined + case 'document-folder': + return operation === 'import-into' + ? (targetPath) => resolveDocumentFolderPath(targetPath, fileAccess.documentPath) + : undefined + } +} + +/** + * The one resolver for desktop local file requests. A declared kind never refuses what the default + * project check allows; its own rule only adds paths outside every project. + */ +export async function resolveLocalRequestPath( + targetPath: string, + access: unknown, + store: Store, + operation: LocalRequestOperation +): Promise { + // Why the leaf is kept: a rename acts on a link itself, never on what it points to. + const options = { preserveSymlink: operation === 'rename-from' || operation === 'rename-to' } + const fileAccess = parseLocalFileAccess(access) + const kindRule = fileAccess && declaredKindRule(fileAccess, operation, store) + if (!fileAccess || !kindRule) { + return resolveDesktopAuthorizedPath(targetPath, store, options) + } + if (typeof targetPath !== 'string') { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + // Why device text first: a device is never a file to load, even inside a project. + const automaticLoad = fileAccess.kind === 'document-resource' || fileAccess.kind === 'chat-image' + if (automaticLoad && isRefusedAutomaticLoadPath(resolve(targetPath))) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + // Why the default check refuses an outside path by its text first: no share is contacted here. + const insideRoots = await resolveDesktopAuthorizedPath(targetPath, store, options).catch( + () => undefined + ) + if (insideRoots === undefined) { + return kindRule(targetPath) + } + if (automaticLoad && isRefusedAutomaticLoadPath(insideRoots)) { + throw new Error(PATH_ACCESS_DENIED_MESSAGE) + } + return insideRoots +} + +/** + * Both paths of a desktop rename. Renaming the opened document (its document-folder access) follows + * the user-file rule: the user typed the new path, so it may go anywhere, and its Undo, declared + * from the moved file, may come back from there. Any other rename gets the default check only. + */ +export async function resolveLocalRenamePaths( + oldPath: string, + newPath: string, + access: unknown, + store: Store +): Promise<{ from: string; to: string }> { + const fileAccess = parseLocalFileAccess(access) + const renameAccess = + fileAccess?.kind === 'document-folder' && isOpenedDocument(oldPath, fileAccess.documentPath) + ? USER_FILE_ACCESS + : undefined + return { + from: await resolveLocalRequestPath(oldPath, renameAccess, store, 'rename-from'), + to: await resolveLocalRequestPath(newPath, renameAccess, store, 'rename-to') + } +} + +/** A desktop read/stat request; no declared access means roots only. */ +export function resolveLocalFileRequestPath( + targetPath: string, + access: unknown, + store: Store +): Promise { + return resolveLocalRequestPath(targetPath, access, store, 'read') +} + +/** A desktop save; user-file access adds the open file the user named. */ +export function resolveLocalWriteRequestPath( + targetPath: string, + access: unknown, + store: Store +): Promise { + return resolveLocalRequestPath(targetPath, access, store, 'write') +} diff --git a/src/main/ipc/local-log-tail-lifetime.test.ts b/src/main/ipc/local-log-tail-lifetime.test.ts index 4abf283b9ec..95d320164a8 100644 --- a/src/main/ipc/local-log-tail-lifetime.test.ts +++ b/src/main/ipc/local-log-tail-lifetime.test.ts @@ -13,12 +13,16 @@ vi.mock('electron', () => ({ handle: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler) } })) -vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: authorize })) +vi.mock('./local-file-access-resolution', () => ({ resolveUserNamedRegularFile: authorize })) import { closeAllLocalLogTailWatchers, getActiveLocalLogTailWatcherCount, registerLocalLogTailHandlers } from './local-log-tail' +import type { Store } from '../persistence' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: path resolution is mocked, so the store is never read. +const NO_STORE = {} as Store class Sender extends EventEmitter { dead = false @@ -41,8 +45,7 @@ beforeEach(async () => { filePath = join(directory, 'fixture.log') await writeFile(filePath, 'test\n') authorize.mockReset().mockResolvedValue(filePath) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization is mocked; the handler never reads Store in this isolated fixture. - registerLocalLogTailHandlers({} as never) + registerLocalLogTailHandlers(NO_STORE) }) afterEach(async () => { closeAllLocalLogTailWatchers() diff --git a/src/main/ipc/local-log-tail.test.ts b/src/main/ipc/local-log-tail.test.ts index 5f7f46ab0f5..ce88830304e 100644 --- a/src/main/ipc/local-log-tail.test.ts +++ b/src/main/ipc/local-log-tail.test.ts @@ -18,7 +18,9 @@ vi.mock('electron', () => ({ vi.mock('node:fs', () => ({ watch: watchMock })) -vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: resolveAuthorizedPathMock })) +vi.mock('./local-file-access-resolution', () => ({ + resolveUserNamedRegularFile: resolveAuthorizedPathMock +})) vi.mock('../ai-vault/local-log-tail-reader', () => ({ readLocalLogTailRange: readRangeMock @@ -29,6 +31,10 @@ import { getActiveLocalLogTailWatcherCount, registerLocalLogTailHandlers } from './local-log-tail' +import type { Store } from '../persistence' + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: path resolution is mocked, so the store is never read. +const NO_STORE = {} as Store type FakeWatcher = { close: ReturnType @@ -64,7 +70,7 @@ beforeEach(() => { watchMock.mockReset() resolveAuthorizedPathMock.mockReset().mockImplementation(async (path: string) => path) readRangeMock.mockReset() - registerLocalLogTailHandlers({} as never) + registerLocalLogTailHandlers(NO_STORE) }) afterEach(() => { @@ -87,7 +93,7 @@ describe('local log tail IPC', () => { ) emitChange?.('change') - expect(resolveAuthorizedPathMock).toHaveBeenCalledWith('/logs/session.jsonl', expect.anything()) + expect(resolveAuthorizedPathMock).toHaveBeenCalledWith('/logs/session.jsonl', NO_STORE) expect(watchMock).toHaveBeenCalledWith('/logs/session.jsonl', expect.any(Function)) expect(sender.send).toHaveBeenCalledWith('fs:localLogTailChanged', { subscriptionId: 'tail-1', diff --git a/src/main/ipc/local-log-tail.ts b/src/main/ipc/local-log-tail.ts index 0892665ad50..85d402910de 100644 --- a/src/main/ipc/local-log-tail.ts +++ b/src/main/ipc/local-log-tail.ts @@ -1,6 +1,5 @@ import { ipcMain, type WebContents } from 'electron' import { watch, type FSWatcher } from 'node:fs' -import type { Store } from '../persistence' import type { LocalLogTailChangedPayload, LocalLogTailReadArgs, @@ -8,7 +7,8 @@ import type { LocalLogTailWatchArgs } from '../../shared/local-log-tail-types' import { readLocalLogTailRange } from '../ai-vault/local-log-tail-reader' -import { resolveAuthorizedPath } from './filesystem-auth' +import type { Store } from '../persistence' +import { resolveUserNamedRegularFile } from './local-file-access-resolution' import { abortWhenRendererGone } from './renderer-lifetime-abort' type TailSenderOwner = { @@ -109,7 +109,7 @@ async function startWatch( const pending = Symbol(subscriptionId) owner.pending.set(key, pending) try { - const filePath = await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveUserNamedRegularFile(args.filePath, store) if ( sender.isDestroyed() || owner.signal.aborted || @@ -147,7 +147,7 @@ export function registerLocalLogTailHandlers(store: Store): void { ipcMain.handle( 'fs:readLocalLogTail', async (_event, args: LocalLogTailReadArgs): Promise => { - const filePath = await resolveAuthorizedPath(args.filePath, store) + const filePath = await resolveUserNamedRegularFile(args.filePath, store) return readLocalLogTailRange(filePath, args.fromByteOffset, args.expectedIdentity) } ) diff --git a/src/main/ipc/notebook-project-link.test.ts b/src/main/ipc/notebook-project-link.test.ts new file mode 100644 index 00000000000..d0036d1d38f --- /dev/null +++ b/src/main/ipc/notebook-project-link.test.ts @@ -0,0 +1,102 @@ +import { EventEmitter } from 'node:events' +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type * as RepoWorktrees from '../repo-worktrees' +import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' + +type Handler = (event: unknown, args: unknown) => unknown + +const { handlers, startNotebookKernelMock, userData } = vi.hoisted(() => ({ + handlers: new Map(), + startNotebookKernelMock: vi.fn(), + userData: { path: '' } +})) + +vi.mock('electron', () => ({ + app: { getPath: () => userData.path }, + ipcMain: { handle: (channel: string, handler: Handler) => handlers.set(channel, handler) } +})) +vi.mock('../repo-worktrees', async () => { + const actual = await vi.importActual('../repo-worktrees') + return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) } +}) +vi.mock('../notebook/notebook-kernel', () => ({ startNotebookKernel: startNotebookKernelMock })) + +import { registerNotebookHandlers } from './notebook' + +let base: string +let project: string + +function storeWithProject(projectPath: string): Store { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these Store members. + return { + getRepos: () => [ + { id: 'repo', path: projectPath, displayName: 'project', badgeColor: '#000', addedAt: 0 } + ], + getProjects: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) + } as unknown as Store +} + +beforeEach(async () => { + invalidateAuthorizedRootsCache() + handlers.clear() + startNotebookKernelMock.mockReset().mockReturnValue({ + kernel: { execute: vi.fn(), interrupt: vi.fn(), shutdown: vi.fn() }, + ready: Promise.resolve({ status: 'ready' }), + exited: new Promise(() => {}) + }) + base = await mkdtemp(join(await realpath(tmpdir()), 'orca-notebook-link-')) + project = join(base, 'project') + userData.path = join(base, 'user-data') + await mkdir(join(project, 'analysis'), { recursive: true }) + await mkdir(join(userData.path, 'floating-workspace'), { recursive: true }) + await writeFile(join(project, 'analysis', 'real.ipynb'), '{}') + registerNotebookHandlers(storeWithProject(project)) +}) + +afterEach(async () => { + await rm(base, { recursive: true, force: true }) +}) + +it.skipIf(process.platform === 'win32')( + 'runs a notebook opened through a project link in its real folder, as the project check resolves it', + async () => { + await symlink(join(project, 'analysis', 'real.ipynb'), join(project, 'nb.ipynb')) + const owner = Object.assign(new EventEmitter(), { send: vi.fn(), isDestroyed: () => false }) + + await handlers.get('notebook:startKernel')!( + { sender: owner }, + { filePath: join(project, 'nb.ipynb'), python: '/py' } + ) + + expect(startNotebookKernelMock).toHaveBeenCalledWith( + expect.objectContaining({ cwd: join(project, 'analysis') }) + ) + } +) + +it.skipIf(process.platform === 'win32')( + 'runs a linked notebook outside every project in its real folder, as before', + async () => { + const notes = join(base, 'notes') + await mkdir(join(notes, 'analysis'), { recursive: true }) + await writeFile(join(notes, 'analysis', 'real.ipynb'), '{}') + await symlink(join(notes, 'analysis', 'real.ipynb'), join(notes, 'nb.ipynb')) + const owner = Object.assign(new EventEmitter(), { send: vi.fn(), isDestroyed: () => false }) + + await handlers.get('notebook:startKernel')!( + { sender: owner }, + { filePath: join(notes, 'nb.ipynb'), python: '/py' } + ) + + expect(startNotebookKernelMock).toHaveBeenCalledWith( + expect.objectContaining({ cwd: join(notes, 'analysis') }) + ) + } +) diff --git a/src/main/ipc/notebook.test.ts b/src/main/ipc/notebook.test.ts index 961c652f316..f8bd1ddc567 100644 --- a/src/main/ipc/notebook.test.ts +++ b/src/main/ipc/notebook.test.ts @@ -1,5 +1,9 @@ import { EventEmitter } from 'node:events' +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromises from 'node:fs/promises' import type { KernelFrame } from '../../shared/notebook-kernel-types' const handlers = new Map unknown>() @@ -14,11 +18,23 @@ vi.mock('electron', () => ({ handlers.set(channel, handler) } })) -vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: resolveAuthorizedPathMock })) +vi.mock('./local-file-access-resolution', () => ({ + resolveUserNamedRegularFile: resolveAuthorizedPathMock, + resolveDesktopAuthorizedPath: resolveAuthorizedPathMock +})) vi.mock('../notebook/notebook-kernel', () => ({ startNotebookKernel: startNotebookKernelMock })) +// Why: the mocked resolver returns made-up `/real/...` paths, which stand for real files already. +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + realpath: async (path: string) => (path.startsWith('/real/') ? path : actual.realpath(path)) + } +}) import { registerNotebookHandlers } from './notebook' import type { Store } from '../persistence' +import type * as FileAccessResolution from './local-file-access-resolution' function fakeKernel() { let onFrame: (frame: KernelFrame) => void = () => {} @@ -122,6 +138,30 @@ describe('notebook IPC', () => { expect(first.kernel.execute).not.toHaveBeenCalled() }) + it('starts a kernel for a notebook outside every project, and refuses a relative path', async () => { + const actual = await vi.importActual( + './local-file-access-resolution' + ) + resolveAuthorizedPathMock.mockImplementation(actual.resolveUserNamedRegularFile) + const folder = await mkdtemp(join(await realpath(tmpdir()), 'orca-notebook-')) + try { + const notebook = join(folder, 'analysis.ipynb') + await writeFile(notebook, '{}') + fakeKernel() + const start = handlers.get('notebook:startKernel')! + + await expect( + start({ sender: fakeOwner() }, { filePath: notebook, python: '/py' }) + ).resolves.toEqual({ status: 'ready' }) + expect(startNotebookKernelMock).toHaveBeenCalledWith(expect.objectContaining({ cwd: folder })) + await expect( + start({ sender: fakeOwner() }, { filePath: 'analysis.ipynb', python: '/py' }) + ).rejects.toThrow('absolute path') + } finally { + await rm(folder, { recursive: true, force: true }) + } + }) + it.each(['shutdown', 'destroyed', 'did-navigate', 'render-process-gone'])( 'does not start a kernel after %s while path authorization is pending', async (boundary) => { @@ -229,9 +269,9 @@ describe('notebook IPC', () => { const old = start({ sender: owner }, alias) const fresh = start({ sender: owner }, canonical) handlers.get('notebook:shutdownKernel')!({ sender: owner }, alias) - aliasAuthorization.resolve(canonical.filePath) + aliasAuthorization.resolve(`/real${canonical.filePath}`) await expect(old).resolves.toMatchObject({ status: 'failed' }) - canonicalAuthorization.resolve(canonical.filePath) + canonicalAuthorization.resolve(`/real${canonical.filePath}`) await expect(fresh).resolves.toEqual({ status: 'ready' }) handlers.get('notebook:execute')!({ sender: owner }, { ...canonical, code: 'canonical' }) diff --git a/src/main/ipc/notebook.ts b/src/main/ipc/notebook.ts index f599734a03c..f5ec3109beb 100644 --- a/src/main/ipc/notebook.ts +++ b/src/main/ipc/notebook.ts @@ -1,8 +1,12 @@ import { randomUUID } from 'node:crypto' +import { realpath } from 'node:fs/promises' import { dirname } from 'node:path' import { ipcMain, type WebContents } from 'electron' import type { Store } from '../persistence' -import { resolveAuthorizedPath } from './filesystem-auth' +import { + resolveDesktopAuthorizedPath, + resolveUserNamedRegularFile +} from './local-file-access-resolution' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' import { startNotebookKernel, type NotebookKernel } from '../notebook/notebook-kernel' import { @@ -57,6 +61,8 @@ function kernelsOf(owner: WebContents): Map { return kernels } +// Why the notebook path is user-named: it is an open tab, and it only picks the kernel's cwd and +// the venv folder. Inside a project it resolves to the real file, so the cwd is its real folder. export function registerNotebookHandlers(store: Store): void { ipcMain.handle( 'notebook:listPythonEnvironments', @@ -64,7 +70,7 @@ export function registerNotebookHandlers(store: Store): void { _event, args: { filePath: string; rootPath: string | null; runWorkspaceInterpreters: boolean } ): Promise => { - await resolveAuthorizedPath(args.filePath, store) + await resolveUserNamedRegularFile(args.filePath, store) // Why the unresolved path: rootPath is in the same (possibly symlinked) form, e.g. /tmp. return listPythonEnvironments(args.filePath, args.rootPath, { runWorkspaceInterpreters: args.runWorkspaceInterpreters === true @@ -96,8 +102,9 @@ export function registerNotebookHandlers(store: Store): void { starts.set(args.filePath, pending) pending.add(controller) try { - // Why: run from the notebook's folder so relative imports and data paths resolve as on disk. - const cwd = dirname(await resolveAuthorizedPath(args.filePath, store)) + // Why the real file's folder: relative imports and data paths resolve as on disk, even when + // the notebook was opened through a link. + const cwd = dirname(await realpath(await resolveUserNamedRegularFile(args.filePath, store))) if (controller.signal.aborted || owner.isDestroyed()) { return { status: 'failed', detail: 'The notebook closed before its kernel started.' } } @@ -144,9 +151,9 @@ export function registerNotebookHandlers(store: Store): void { _event, args: { filePath: string; rootPath: string | null; python: string } ): Promise => { - await resolveAuthorizedPath(args.filePath, store) + await resolveUserNamedRegularFile(args.filePath, store) if (args.rootPath) { - await resolveAuthorizedPath(args.rootPath, store) + await resolveDesktopAuthorizedPath(args.rootPath, store) } return createNotebookVenv(args.python, notebookVenvParent(args.filePath, args.rootPath)) } diff --git a/src/main/ipc/preflight-remote-ssh.test.ts b/src/main/ipc/preflight-remote-ssh.test.ts index 4ad02ec5d04..6fad3ec344f 100644 --- a/src/main/ipc/preflight-remote-ssh.test.ts +++ b/src/main/ipc/preflight-remote-ssh.test.ts @@ -92,9 +92,33 @@ vi.mock('../gitea/client', () => ({ })) import { registerPreflightHandlers } from './preflight' +import { detectAgentCommandsOnHost } from '../preflight/agent-detection' import { resetPreflightMocks, type HandlerMap } from './preflight-test-harness' describe('preflight', () => { + it('resolves exact Qoder command labels on the SSH host using the existing request', async () => { + const request = vi.fn().mockResolvedValue({ agents: ['qoder', 'unrelated'] }) + getActiveMultiplexerMock.mockReturnValue({ isDisposed: () => false, request }) + await expect( + detectAgentCommandsOnHost(['qodercli', 'qoder'], { connectionId: 'ssh-qoder' }) + ).resolves.toEqual(new Set(['qoder'])) + expect(request).toHaveBeenCalledWith('preflight.detectAgents', { + commands: [ + { id: 'qodercli', cmd: 'qodercli' }, + { id: 'qoder', cmd: 'qoder' } + ] + }) + expect(isCommandOnLocalPathMock).not.toHaveBeenCalled() + }) + + it('refuses command selection after SSH contact loss without probing the local host', async () => { + getActiveMultiplexerMock.mockReturnValue(null) + await expect( + detectAgentCommandsOnHost(['qodercli', 'qoder'], { connectionId: 'ssh-qoder' }) + ).rejects.toThrow('execution host connection') + expect(isCommandOnLocalPathMock).not.toHaveBeenCalled() + }) + const originalPlatform = process.platform const handlers: HandlerMap = {} diff --git a/src/main/ipc/pty-daemon-spawn-codex-auth.test.ts b/src/main/ipc/pty-daemon-spawn-codex-auth.test.ts index cb60984c50c..0ecb8da064e 100644 --- a/src/main/ipc/pty-daemon-spawn-codex-auth.test.ts +++ b/src/main/ipc/pty-daemon-spawn-codex-auth.test.ts @@ -7,7 +7,8 @@ import { import { expectedOmpStatusExtension, TEST_CODEX_HOME, - TEST_CODEX_AUTH_JSON + TEST_CODEX_AUTH_JSON, + TEST_MANAGED_ROOT } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures' @@ -15,6 +16,8 @@ import { join } from 'node:path' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' import { registerPtyHandlers, resolveCodexHomeAfterManagedAuthReadiness } from './pty' +const consumerConfigHome = join(TEST_MANAGED_ROOT, 'opencode-consumer-config') + vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) @@ -435,11 +438,15 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_OPENCODE_HOOK_PORT).toBe('4567') }) it('mirrors a user-provided OPENCODE_CONFIG_DIR into a source-scoped overlay on the daemon path', async () => { - const env = await daemonSpawnAndGetEnv({ OPENCODE_CONFIG_DIR: '/user/custom/opencode' }) + const env = await daemonSpawnAndGetEnv({ + OPENCODE_CONFIG_DIR: '/user/custom/opencode', + XDG_CONFIG_HOME: consumerConfigHome + }) // Why: OpenCode loads config from a single dir, so the user's path is mirrored into a source-scoped overlay, not passed through. expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith( expect.any(String), - '/user/custom/opencode' + '/user/custom/opencode', + join(consumerConfigHome, 'opencode') ) expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') @@ -448,11 +455,13 @@ describe('registerPtyHandlers', () => { it('uses source OpenCode config env instead of remirroring a parent overlay', async () => { const env = await daemonSpawnAndGetEnv({ OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay', - ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/user/custom/opencode' + ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/user/custom/opencode', + XDG_CONFIG_HOME: consumerConfigHome }) expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith( expect.any(String), - '/user/custom/opencode' + '/user/custom/opencode', + join(consumerConfigHome, 'opencode') ) expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index c7256a57f3e..fbab71bead1 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -58,6 +58,7 @@ import { _resetWslCachesForTests } from '../wsl' /** The mocked webContents each suite asserts sends against. */ export type PtyIpcTestWebContents = { + id: number on: Mock send: Mock removeListener: Mock @@ -92,6 +93,7 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { isVisible: () => true, isMinimized: () => false, webContents: { + id: 1, on: vi.fn(), send: vi.fn(), removeListener: vi.fn(), @@ -101,6 +103,7 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { const mainWindowIpcEvent = { sender: mainWindow.webContents } const foreignWindowIpcEvent = { sender: { + id: 2, on: vi.fn(), send: vi.fn(), removeListener: vi.fn(), diff --git a/src/main/ipc/pty-pane-restart-replace.test.ts b/src/main/ipc/pty-pane-restart-replace.test.ts index 50558a0f870..5fc47deac66 100644 --- a/src/main/ipc/pty-pane-restart-replace.test.ts +++ b/src/main/ipc/pty-pane-restart-replace.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it, vi } from 'vitest' +import { PtyBindingPersistenceOperations } from '../persistence/loading-store/pty-binding-persistence' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { setupPtyIpcSuite, type PtyIpcSuiteFixtures } from './pty-ipc-test-harness' -import { SessionNotFoundError } from '../daemon/daemon-errors' +import { registerSshPtyProvider, unregisterSshPtyProvider } from './pty/provider/registry' +import { toAppSshPtyId } from '../providers/ssh-pty-id' +import { TerminalKilledError } from '../daemon/daemon-pty-lifecycle-errors' import { makePaneKey } from '../../shared/stable-pane-id' import { registerPtyHandlers, setLocalPtyProvider } from './pty' import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' @@ -56,6 +59,34 @@ const leafId = '12121212-1212-4212-8212-121212121212' const paneKey = makePaneKey(tabId, leafId) type RestartHarness = ReturnType +type FakeSession = { + tabsByWorktree: Record + terminalLayoutsByTabId: Record< + string, + { + root: { type: 'leaf'; leafId: string } + activeLeafId: string + expandedLeafId: null + ptyIdsByLeafId: Record + } + > + terminalPtyIncarnationsByPaneKey: Record +} + +function seedSession(ptyId: string): FakeSession { + return { + tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId }] }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-old' } + } +} function registerWithFakes( mainWindow: PtyIpcSuiteFixtures['mainWindow'], @@ -78,13 +109,15 @@ function installRestartHarness( options: { shutdownFails?: boolean; shutdownGate?: Promise } = {} ) { let oldSessionAlive = true - const control = { shutdownFails: options.shutdownFails ?? false } + const control = { shutdownFails: options.shutdownFails ?? false, failNextWrite: false } + const boundAtFreshLaunch: (string | undefined)[] = [] const providerSpawn = vi.fn(async (spawnOptions: { attachOnly?: boolean }) => { if (!spawnOptions.attachOnly) { + boundAtFreshLaunch.push(leafBinding()) return { id: 'pty-new', incarnationId: 'inc-new' } } if (!oldSessionAlive) { - throw new SessionNotFoundError('pty-old') + throw new TerminalKilledError('pty-old') } return { id: 'pty-old', incarnationId: 'inc-old', isReattach: true } }) @@ -120,31 +153,75 @@ function installRestartHarness( } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restart path calls only the provider members this fake defines. setLocalPtyProvider(provider as unknown as Parameters[0]) - let session = { - tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-old' }] }, - terminalLayoutsByTabId: { - [tabId]: { - root: { type: 'leaf' as const, leafId }, - activeLeafId: leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [leafId]: 'pty-old' } - } - }, - terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-old' } - } + let session = seedSession('pty-old') const store = { - getWorkspaceSession: vi.fn(() => session), + getWorkspaceSession: vi.fn((_hostId?: string): FakeSession => session), setWorkspaceSession: vi.fn((next) => { session = next }), flushOrThrow: vi.fn(), - runDurableMutation: vi.fn(async (mutate: () => { value: T }) => mutate().value), - persistPtyBinding: vi.fn(), + runDurableMutation: vi.fn( + async ( + mutate: () => { value: T; persist?: boolean | 'if-dirty'; rollback?: () => void } + ) => { + const mutation = mutate() + if (mutation.persist !== false) { + if (control.failNextWrite) { + control.failNextWrite = false + mutation.rollback?.() + throw new Error('save failed') + } + durableBindings.push(leafBinding()) + } + return mutation.value + } + ), + getWorkspaceSessionHostIds: vi.fn(() => [ + 'local', + ...Object.keys(state.workspaceSessionsByHostId) + ]), getFolderWorkspace: vi.fn(() => undefined), getFolderWorkspaces: vi.fn(() => []), getProjectGroups: vi.fn(() => []), getRepos: vi.fn(() => []) } + const state: { + workspaceSession: FakeSession + workspaceSessionsByHostId: Record + } = { workspaceSession: session, workspaceSessionsByHostId: {} } + const durableBindings: (string | undefined)[] = [] + function leafBinding(hostId?: string): string | undefined { + return store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] + } + // Like the store, a missing host partition reads as an empty session, never the local one. + store.getWorkspaceSession.mockImplementation((hostId?: string) => + hostId && hostId !== 'local' + ? (state.workspaceSessionsByHostId[hostId] ?? { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + terminalPtyIncarnationsByPaneKey: {} + }) + : state.workspaceSession + ) + const bindingRuntime = { + state, + dirtyProfileStateDomains: new Set(), + runDurableMutation: store.runDurableMutation, + writeTimer: null, + pendingWrite: null, + quitFlushStarted: false, + writeGeneration: 0, + lastDurableWriteGeneration: 0 + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: binding writes read only state, write bookkeeping, durable mutation and the session partitions from these fakes. + const bindingArgs = [bindingRuntime, store] as unknown as ConstructorParameters< + typeof PtyBindingPersistenceOperations + > + const bindingOperations = new PtyBindingPersistenceOperations(...bindingArgs) + const storeWithRetirement = Object.assign(store, { + persistPtyBinding: vi.fn(bindingOperations.persistPtyBinding.bind(bindingOperations)), + retirePtyBinding: bindingOperations.retirePtyBinding.bind(bindingOperations) + }) const runtime = { setPtyController: vi.fn(), resolveTerminalPane: vi.fn(() => { @@ -165,7 +242,18 @@ function installRestartHarness( onPtyData: vi.fn(), intentionalPtyStops: new TerminalIntentionalStops() } - return { providerSpawn, shutdown, store, runtime, control } + return { + providerSpawn, + shutdown, + store: storeWithRetirement, + runtime, + control, + provider, + leafBinding, + durableBindings, + boundAtFreshLaunch, + partitions: state.workspaceSessionsByHostId + } } function restartSpawnArgs(extra: { replacesPtyId?: string } = {}) { @@ -283,4 +371,270 @@ describe('pty:spawn replacing a pane owner', () => { expect(exitPayloads('pty-old')).toHaveLength(1) expect(exitPayloads('pty-old')[0]).not.toHaveProperty('replacedByRestart') }) + it('swaps the stopped binding for the replacement in one write, never unbinding the pane', async () => { + const { providerSpawn, store, runtime, durableBindings, boundAtFreshLaunch } = + installRestartHarness() + const session = store.getWorkspaceSession() + const layout = structuredClone(session.terminalLayoutsByTabId[tabId]) + registerWithFakes(mainWindow, runtime, store) + await handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + const swapped = store.getWorkspaceSession() + expect(boundAtFreshLaunch).toEqual(['pty-old']) + expect(durableBindings).toEqual(['pty-new']) + expect(swapped.tabsByWorktree[worktreeId]).toEqual([ + { ...session.tabsByWorktree[worktreeId][0], ptyId: 'pty-new' } + ]) + expect(swapped.terminalLayoutsByTabId[tabId]).toEqual({ + ...layout, + ptyIdsByLeafId: { [leafId]: 'pty-new' } + }) + expect(swapped.terminalPtyIncarnationsByPaneKey).toEqual({ [paneKey]: 'inc-new' }) + expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + }) + + it('launches fresh when a stale snapshot re-publishes the stopped binding mid-spawn', async () => { + const { providerSpawn, store, runtime, leafBinding } = installRestartHarness() + runtime.createPreAllocatedTerminalHandle.mockImplementationOnce(() => { + // A debounced renderer layout patch still carrying the old id lands after the stop. + const current = store.getWorkspaceSession() + current.terminalLayoutsByTabId[tabId] = { + ...current.terminalLayoutsByTabId[tabId], + ptyIdsByLeafId: { [leafId]: 'pty-old' } + } + return 'term-restart' + }) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).resolves.toMatchObject({ id: 'pty-new' }) + expect(runtime.createPreAllocatedTerminalHandle).toHaveBeenCalled() + expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + expect(leafBinding()).toBe('pty-new') + }) + + it('swaps a binding the renderer withdrew before connecting the replacement', async () => { + const { providerSpawn, store, runtime, leafBinding } = installRestartHarness() + runtime.createPreAllocatedTerminalHandle.mockImplementationOnce(() => { + // A split tab's partial layout map, or an SSH terminated lease, lets the renderer clear land. + delete store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] + return 'term-restart' + }) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).resolves.toMatchObject({ id: 'pty-new' }) + expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + expect(leafBinding()).toBe('pty-new') + }) + + it('clears the stopped binding when the replacement fails, so the remount starts fresh', async () => { + const { providerSpawn, store, runtime, leafBinding } = installRestartHarness() + providerSpawn.mockRejectedValueOnce(new Error('spawn failed')) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).rejects.toThrow('spawn failed') + const boundAfterFailure = leafBinding() + // The renderer's recovery remount sends no replacesPtyId. + await expect(handlers.get('pty:spawn')!(null, restartSpawnArgs())).resolves.toMatchObject({ + id: 'pty-new' + }) + expect(boundAfterFailure).toBeUndefined() + expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + }) + + it('reaps the replacement when its binding save fails, and the remount starts fresh', async () => { + const { provider, providerSpawn, store, runtime, control, leafBinding } = + installRestartHarness() + control.failNextWrite = true + vi.spyOn(console, 'error').mockImplementation(() => {}) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).rejects.toThrow('ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED') + expect(provider.shutdown).toHaveBeenCalledWith( + 'pty-new', + expect.objectContaining({ immediate: true }) + ) + const boundAfterFailure = leafBinding() + await expect(handlers.get('pty:spawn')!(null, restartSpawnArgs())).resolves.toMatchObject({ + id: 'pty-new' + }) + expect(boundAfterFailure).toBeUndefined() + expect(providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + }) + + it('refuses an old restart after another owner has already claimed the pane', async () => { + const { shutdown, providerSpawn, store, runtime } = installRestartHarness() + store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] = 'successor' + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).rejects.toThrow('terminal_pane_owner_changed') + expect(shutdown).not.toHaveBeenCalled() + expect(providerSpawn).not.toHaveBeenCalled() + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + }) + + it('reaps the replacement when a successor binds the pane during its spawn', async () => { + const { provider, providerSpawn, store, runtime, leafBinding } = installRestartHarness() + providerSpawn.mockImplementationOnce(async () => { + store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] = 'successor' + return { id: 'pty-new', incarnationId: 'inc-new' } + }) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, restartSpawnArgs({ replacesPtyId: 'pty-old' })) + ).rejects.toThrow('terminal_pane_owner_changed') + expect(provider.shutdown).toHaveBeenCalledWith( + 'pty-new', + expect.objectContaining({ immediate: true }) + ) + expect(leafBinding()).toBe('successor') + }) + + /** The SSH pane lives only in its host partition; the local leaf keeps an unrelated id. */ + async function withSshRestart( + run: ( + host: RestartHarness & { oldId: string; newId: string; hostId: string }, + restart: (extra?: { replacesPtyId?: string }) => Promise + ) => Promise + ): Promise { + const harness = installRestartHarness() + const connectionId = 'restart-ssh' + const hostId = 'ssh:restart-ssh' + const oldId = toAppSshPtyId(connectionId, 'pty-old') + const newId = toAppSshPtyId(connectionId, 'pty-new') + harness.partitions[hostId] = seedSession(oldId) + Object.assign(harness.store, { + markSshRemotePtyLease: vi.fn(), + upsertSshRemotePtyLease: vi.fn(), + removeSshRemotePtyLease: vi.fn(), + supersedeSshRemotePtyLeasesForBoundPane: vi.fn() + }) + let oldAlive = true + harness.shutdown.mockImplementation(async () => { + oldAlive = false + }) + harness.providerSpawn.mockImplementation(async (options) => { + if (!options.attachOnly) { + return { id: newId, incarnationId: 'inc-new' } + } + if (!oldAlive) { + throw new TerminalKilledError(oldId) + } + return { id: oldId, incarnationId: 'inc-old', isReattach: true } + }) + registerSshPtyProvider( + connectionId, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: same fake provider surface used by the local restart fixture. + harness.provider as unknown as Parameters[1] + ) + const localSpawn = vi.fn(() => { + throw new Error('wrong execution host') + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the test must never reach the local provider. + setLocalPtyProvider({ + ...harness.provider, + spawn: localSpawn, + shutdown: localSpawn + } as unknown as Parameters[0]) + registerWithFakes(mainWindow, harness.runtime, harness.store) + try { + await run({ ...harness, oldId, newId, hostId }, async (extra = {}) => + handlers.get('pty:spawn')!(null, { ...restartSpawnArgs(extra), connectionId }) + ) + expect(localSpawn).not.toHaveBeenCalled() + expect(harness.leafBinding()).toBe('pty-old') + } finally { + unregisterSshPtyProvider(connectionId) + } + } + + it('swaps the direct-SSH host binding without touching the local provider', async () => { + await withSshRestart(async (host, restart) => { + await expect(restart({ replacesPtyId: host.oldId })).resolves.toMatchObject({ + id: host.newId + }) + expect(host.shutdown).toHaveBeenCalledWith( + host.oldId, + expect.objectContaining({ immediate: true }) + ) + expect(host.store.persistPtyBinding).toHaveBeenCalledWith(expect.any(Function), host.hostId) + expect(host.leafBinding(host.hostId)).toBe(host.newId) + expect(host.providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + }) + }) + + it('reaps a direct-SSH replacement when a successor binds the host pane during its spawn', async () => { + await withSshRestart(async (host, restart) => { + host.providerSpawn.mockImplementationOnce(async () => { + host.partitions[host.hostId].terminalLayoutsByTabId[tabId].ptyIdsByLeafId[leafId] = + 'successor' + return { id: host.newId, incarnationId: 'inc-new' } + }) + await expect(restart({ replacesPtyId: host.oldId })).rejects.toThrow( + 'terminal_pane_owner_changed' + ) + expect(host.shutdown).toHaveBeenCalledWith( + host.newId, + expect.objectContaining({ immediate: true }) + ) + expect(host.leafBinding(host.hostId)).toBe('successor') + }) + }) + + it('clears the direct-SSH stopped binding when the replacement fails, so the remount starts fresh', async () => { + await withSshRestart(async (host, restart) => { + host.providerSpawn.mockRejectedValueOnce(new Error('spawn failed')) + await expect(restart({ replacesPtyId: host.oldId })).rejects.toThrow('spawn failed') + const boundAfterFailure = host.leafBinding(host.hostId) + await expect(restart()).resolves.toMatchObject({ id: host.newId }) + expect(boundAfterFailure).toBeUndefined() + expect(host.providerSpawn.mock.calls.every(([options]) => !options.attachOnly)).toBe(true) + }) + }) + + it.each(['darwin', 'linux', 'win32'])( + 'restarts a folder workspace on %s without deleting its pane', + async (platform) => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + onTestFinished(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + }) + const { store, runtime, leafBinding } = installRestartHarness() + const folderId = 'folder:restart-folder' + const session = store.getWorkspaceSession() + session.tabsByWorktree[folderId] = session.tabsByWorktree[worktreeId].map((tab) => ({ + ...tab, + worktreeId: folderId + })) + session.tabsByWorktree[worktreeId] = [] + const folder = { + id: 'restart-folder', + folderPath: process.cwd(), + name: 'Restart', + projectGroupId: null + } + Object.assign(store, { + getFolderWorkspaces: vi.fn(() => [folder]), + getFolderWorkspace: vi.fn(() => folder) + }) + registerWithFakes(mainWindow, runtime, store) + await expect( + handlers.get('pty:spawn')!(null, { + ...restartSpawnArgs({ replacesPtyId: 'pty-old' }), + worktreeId: folderId, + cwd: process.cwd() + }) + ).resolves.toMatchObject({ id: 'pty-new' }) + expect(store.getWorkspaceSession().tabsByWorktree[folderId]).toHaveLength(1) + expect(leafBinding()).toBe('pty-new') + expect(store.getWorkspaceSession().terminalLayoutsByTabId[tabId].root).toEqual({ + type: 'leaf', + leafId + }) + } + ) }) diff --git a/src/main/ipc/pty-spawn-env-agent-overlays.test.ts b/src/main/ipc/pty-spawn-env-agent-overlays.test.ts index 4c7204f1980..c194cf04179 100644 --- a/src/main/ipc/pty-spawn-env-agent-overlays.test.ts +++ b/src/main/ipc/pty-spawn-env-agent-overlays.test.ts @@ -1,3 +1,4 @@ +import { join } from 'node:path' import { withFreshOmpLaunch } from '../../shared/omp-fresh-launch' import { describe, expect, it, vi } from 'vitest' import { @@ -7,10 +8,12 @@ import { mimoCodeBuildPtyEnvMock, piBuildPtyEnvMock } from './pty-ipc-mock-registry' -import { posixOnlyIt } from './pty-ipc-test-constants' +import { posixOnlyIt, TEST_MANAGED_ROOT } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' +const consumerConfigHome = join(TEST_MANAGED_ROOT, 'opencode-consumer-config') + vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) @@ -99,11 +102,13 @@ describe('registerPtyHandlers', () => { it('mirrors the original OpenCode source dir when launched from an Orca overlay shell', async () => { const env = await spawnAndGetEnv({ OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay', - ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/tmp/user-opencode-config' + ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/tmp/user-opencode-config', + XDG_CONFIG_HOME: consumerConfigHome }) expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith( expect.any(String), - '/tmp/user-opencode-config' + '/tmp/user-opencode-config', + join(consumerConfigHome, 'opencode') ) expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') @@ -112,10 +117,15 @@ describe('registerPtyHandlers', () => { it('does not treat inherited Orca OpenCode config as user config without a source dir', async () => { const env = await spawnAndGetEnv({ OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay', - ORCA_OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay' + ORCA_OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay', + XDG_CONFIG_HOME: consumerConfigHome }) - expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined) + expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith( + expect.any(String), + undefined, + join(consumerConfigHome, 'opencode') + ) expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config') expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config') expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() @@ -222,16 +232,20 @@ describe('registerPtyHandlers', () => { return '' }) - const env = await spawnAndGetEnv(undefined, { - HOME: '/home/pim', - SHELL: '/bin/zsh', - OPENCODE_CONFIG_DIR: undefined, - ORCA_OPENCODE_SOURCE_CONFIG_DIR: undefined - }) + const env = await spawnAndGetEnv( + { XDG_CONFIG_HOME: consumerConfigHome }, + { + HOME: '/home/pim', + SHELL: '/bin/zsh', + OPENCODE_CONFIG_DIR: undefined, + ORCA_OPENCODE_SOURCE_CONFIG_DIR: undefined + } + ) expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith( expect.any(String), - '/home/pim/company/opencode-config' + '/home/pim/company/opencode-config', + join(consumerConfigHome, 'opencode') ) expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay') diff --git a/src/main/ipc/pty/host-env/codex-resume.ts b/src/main/ipc/pty/host-env/codex-resume.ts index a7118cb08b8..463660e00d7 100644 --- a/src/main/ipc/pty/host-env/codex-resume.ts +++ b/src/main/ipc/pty/host-env/codex-resume.ts @@ -30,6 +30,7 @@ export type PrepareCodexResumeHomeArgs = { providerSession?: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv + useSelectedAccount?: boolean } export function prepareCodexResumeHome( @@ -48,7 +49,8 @@ export function prepareCodexResumeHome( preparation: prepareCodexSessionResume({ providerSession, target: args.target, - launchEnv: args.launchEnv + launchEnv: args.launchEnv, + ...(args.useSelectedAccount ? { useSelectedAccount: true } : {}) }) } } diff --git a/src/main/ipc/pty/host-env/opencode-config.ts b/src/main/ipc/pty/host-env/opencode-config.ts index 88dafabc88e..74aa1e46625 100644 --- a/src/main/ipc/pty/host-env/opencode-config.ts +++ b/src/main/ipc/pty/host-env/opencode-config.ts @@ -6,6 +6,8 @@ import { openCode2HookService, openCodeHookService } from '../../../opencode/hoo import { resolveOpenCodeSourceConfigDir, restoreOrStripOverlayEnv } from './pi-agent' import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command' import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' +import { resolveOpenCodeConfigDirectory } from '../../../../shared/opencode-config-directory' +import { readSessionShellStartupEnvVar } from '../../../pty/shell-startup-env' import type { BuildPtyHostEnvOptions } from './types' type OpenCodeSourceConfig = { @@ -41,7 +43,7 @@ export function applyOpenCodeStatusPluginEnv( config: OpenCodeSourceConfig, options: Pick< BuildPtyHostEnvOptions, - 'launchAgent' | 'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'isWsl' + 'launchAgent' | 'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'isWsl' | 'shellPath' >, command: string | undefined ): 'opencode' | 'opencode2' | null { @@ -69,7 +71,13 @@ export function applyOpenCodeStatusPluginEnv( env.ORCA_OPENCODE_AGENT = agent // WSL owns its config writes; only the guest overlay may enter a WSL pane. if (!options.isWsl) { - Object.assign(env, service.buildPtyEnv(id, config.directory)) + const executionEnv = { ...process.env, ...env } + const shellConfigHome = readSessionShellStartupEnvVar('XDG_CONFIG_HOME', env, options.shellPath) + const defaultConfigDir = resolveOpenCodeConfigDirectory({ + ...executionEnv, + XDG_CONFIG_HOME: shellConfigHome ?? executionEnv.XDG_CONFIG_HOME + }) + Object.assign(env, service.buildPtyEnv(id, config.directory, defaultConfigDir)) } if (env.OPENCODE_CONFIG_DIR) { // Shell startup can re-export the default; preserve this pane's overlay and original source. 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 ddae2771aba..fe42ca5735c 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,85 @@ afterEach(() => { }) describe('OpenCode installation uses the current enabled agents', () => { + it.each( + process.platform === 'win32' ? (['home', 'xdg'] as const) : (['home', 'xdg', 'shell'] as const) + )( + 'consumer config root follows the %s execution environment without redirecting JSON', + (kind) => { + const home = join(root, 'consumer-home') + const xdg = join(root, 'consumer-xdg') + const consumer = kind === 'home' ? join(home, '.config', 'opencode') : join(xdg, 'opencode') + mkdirSync(home, { recursive: true }) + mkdirSync(consumer, { recursive: true }) + const settings = '{"model":"consumer-model","description":"external-settings"}' + writeFileSync(join(consumer, 'opencode.json'), settings) + if (kind === 'shell') { + writeFileSync(join(home, '.zshrc'), `export XDG_CONFIG_HOME='${xdg}'\n`) + } + const input = { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: kind === 'xdg' ? xdg : '', + SHELL: kind === 'shell' ? '/bin/zsh' : '/bin/sh' + } + const env = buildPtyHostEnv( + 'folder-pane', + { ...input }, + { ...options, launchAgent: 'opencode' } + ) + expect(existsSync(plugin(consumer, 'opencode'))).toBe(true) + expect(existsSync(plugin(config, 'opencode'))).toBe(false) + expect(readFileSync(join(consumer, 'opencode.json'), 'utf8')).toBe(settings) + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + for (const key of ['HOME', 'USERPROFILE', 'XDG_CONFIG_HOME', 'SHELL'] as const) { + expect(env[key]).toBe(input[key]) + } + } + ) + + it.skipIf(process.platform === 'win32').each([ + { selected: '/bin/bash', inherited: '/bin/zsh', file: '.bash_profile' }, + { selected: '/bin/zsh', inherited: '/bin/bash', file: '.zshrc' } + ])('consumer config follows selected $selected over inherited $inherited', (selection) => { + const home = join(root, 'selected-shell-home') + const selectedXdg = join(root, 'selected-shell-xdg') + const inheritedXdg = join(root, 'inherited-shell-xdg') + mkdirSync(home) + writeFileSync( + join(home, '.bash_profile'), + `export XDG_CONFIG_HOME='${selection.file === '.bash_profile' ? selectedXdg : inheritedXdg}'\n` + ) + writeFileSync( + join(home, '.zshrc'), + `export XDG_CONFIG_HOME='${selection.file === '.zshrc' ? selectedXdg : inheritedXdg}'\n` + ) + const input = { HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: '', SHELL: selection.inherited } + const launchOptions = { + ...options, + launchAgent: 'opencode' as const, + shellPath: selection.selected + } + const env = buildPtyHostEnv('selected-shell-pane', input, launchOptions) + expect(existsSync(plugin(join(selectedXdg, 'opencode'), 'opencode'))).toBe(true) + expect(existsSync(plugin(join(inheritedXdg, 'opencode'), 'opencode'))).toBe(false) + expect(env.SHELL).toBe(selection.inherited) + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + }) + + it.each([false, true])('consumer config root is untouched with hooks disabled %s', (disabled) => { + const consumer = join(root, 'disabled-consumer') + buildPtyHostEnv( + 'pane', + { XDG_CONFIG_HOME: consumer }, + { + ...options, + agentStatusHooksEnabled: disabled, + disabledTuiAgents: disabled ? ['opencode', 'opencode2'] : [] + } + ) + expect(existsSync(join(consumer, 'opencode'))).toBe(false) + }) + it('refuses spawn if a prepared startup intent loses its owned installer', () => { mkdirSync(fixture.userData, { recursive: true }) writeFileSync( diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts index 20981d0cce4..c3dfe1ae6e9 100644 --- a/src/main/ipc/pty/host-env/types.ts +++ b/src/main/ipc/pty/host-env/types.ts @@ -23,6 +23,8 @@ export type BuildPtyHostEnvOptions = { launchCommand?: string /** Trusted agent identity for wrapped commands that cannot be recognized from text. */ launchAgent?: TuiAgent + /** Selected execution shell, which may differ from inherited SHELL. */ + shellPath?: string isWsl?: boolean /** Distro for WSL spawns (null = Windows default distro); drives the WSL hook relay + endpoint repoint. Only read when isWsl. */ wslDistro?: string | null @@ -53,6 +55,7 @@ export type PrepareCodexSessionResume = (args: { providerSession: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv + useSelectedAccount?: boolean }) => Promise export type CodexHomePtySpawnedLifecycleArgs = { diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts index bc0e86ccd2d..a7ae10bc036 100644 --- a/src/main/ipc/pty/ipc/spawn-begin.ts +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -13,11 +13,12 @@ import { pendingRuntimePaneCreatesByOwnerKey } from '../pane/spawn-reservation' import { resolveStablePaneOwner } from '../pane/stable-owner' +import { excludeReplacedPaneOwner } from '../pane/pane-owner-replacement' import type { PtyIpcSpawnState } from './spawn-state' import type { PtySpawnIpcArgs } from './spawn-types' /** The pane key a spawn names before preflight; null when it names no stable pane. */ -function resolveEarlyPaneKey(args: PtySpawnIpcArgs): string | null { +export function resolveEarlyPaneKey(args: PtySpawnIpcArgs): string | null { const leafId = typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null return typeof args.worktreeId === 'string' && @@ -45,12 +46,15 @@ export async function beginPtyIpcSpawn( const initialStablePanePtyId = (() => { try { return !args.connectionId && initialPaneKey - ? resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - initialPaneKey, - args.worktreeId, - args.connectionId + ? excludeReplacedPaneOwner( + resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + initialPaneKey, + args.worktreeId, + args.connectionId + ), + ctx.replacedPaneOwner )?.ptyId : undefined } catch { @@ -87,12 +91,15 @@ export async function beginPtyIpcSpawn( } ctx.earlyStablePaneOwner = initialPaneKey && args.worktreeId - ? resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - initialPaneKey, - args.worktreeId, - args.connectionId + ? excludeReplacedPaneOwner( + resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + initialPaneKey, + args.worktreeId, + args.connectionId + ), + ctx.replacedPaneOwner ) : null ctx.earlyWorktreeId = args.worktreeId diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 46fd8e714bb..41752886ab9 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -3,6 +3,7 @@ import { closeStartupQueryAuthorityForPty, getRelayPtyId } from '../provider/reg import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' import { recordCodexPaneAccountForSpawn } from '../host-env/codex-home' import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' +import { swapReplacedPaneBinding } from '../pane/pane-owner-replacement' import { claimSshPaneLease } from '../pane/ssh-pane-lease-claim' import { pendingByPaneKey, @@ -60,9 +61,13 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise

{ ctx.deps.trustedTerminalHandleEnv.add(ctx.preAllocatedHandle) } ctx.spawnTiming.mark('options') - const stablePaneOwnerCandidate = resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - ctx.reservationPaneKey, - args.worktreeId, - args.connectionId - ) + // Why exclude: the restart's own stop leaves its binding for the bind to swap, not to reattach. + const resolveOwner = () => + excludeReplacedPaneOwner( + resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.reservationPaneKey, + args.worktreeId, + args.connectionId + ), + ctx.replacedPaneOwner + ) + const stablePaneOwnerCandidate = resolveOwner() const expectedPtyId = stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.effectiveSessionId if (expectedPtyId) { @@ -56,14 +63,7 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { owner: stablePaneOwnerCandidate, worktreeId: args.worktreeId, connectionId: args.connectionId, - resolveOwner: () => - resolveStablePaneOwner( - ctx.deps.runtime, - ctx.deps.store, - ctx.reservationPaneKey, - args.worktreeId, - args.connectionId - ) + resolveOwner }) ctx.result = stablePaneSpawn.result ctx.stablePaneOwner = stablePaneSpawn.owner diff --git a/src/main/ipc/pty/ipc/spawn-qoder-command.ts b/src/main/ipc/pty/ipc/spawn-qoder-command.ts new file mode 100644 index 00000000000..27106c6bf63 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-qoder-command.ts @@ -0,0 +1,34 @@ +import { resolveQoderTerminalCommand } from '../../../runtime/qoder-terminal-command-resolution' +import { resolveStartupShell } from '../../../../shared/tui-agent-startup-shell' +import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows-terminal-shell' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function preparePtyIpcQoderCommand(ctx: PtyIpcSpawnState): Promise { + if (ctx.preAdoptedStablePane || ctx.args.launchAgent !== 'qoder') { + return + } + const connectionId = ctx.args.connectionId + const isWsl = ctx.codexSelectionTarget.runtime === 'wsl' + const platform = connectionId || isWsl ? 'linux' : process.platform + const launch = await resolveQoderTerminalCommand( + { + launchAgent: ctx.args.launchAgent, + command: ctx.launchCommand, + launchConfig: ctx.effectiveLaunchConfig + }, + { + connectionId, + context: connectionId ? undefined : { wslDistro: ctx.expectedWslDistro, wslDefault: isWsl }, + shell: resolveStartupShell( + platform, + resolveLocalWindowsAgentStartupShell({ + platform, + isRemote: Boolean(connectionId), + terminalWindowsShell: ctx.effectiveShellOverride + }) + ) + } + ) + ctx.launchCommand = launch.command + ctx.effectiveLaunchConfig = launch.launchConfig +} diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index 7efeb23538d..83e8a77841c 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -1,8 +1,14 @@ import { rejectPaneSpawnReservation, reserveIdlePaneSpawn } from '../pane/spawn-reservation' import { ptySizes } from '../delivery/visibility-state' -import { beginPtyIpcSpawn, resolveEarlyPaneSpawnReservationKey } from './spawn-begin' +import { + beginPtyIpcSpawn, + resolveEarlyPaneKey, + resolveEarlyPaneSpawnReservationKey +} from './spawn-begin' +import { releaseStoppedPaneBinding, stopReplacedPaneOwner } from '../pane/pane-owner-replacement' import { preparePtyIpcSpawnPreflight } from './spawn-preflight' import { assemblePtyIpcSpawnEnv } from './spawn-env' +import { preparePtyIpcQoderCommand } from './spawn-qoder-command' import { buildPtyIpcSpawnOptions } from './spawn-options' import { executePtyIpcSpawn } from './spawn-execute' import { commitPtyIpcSpawn } from './spawn-commit' @@ -42,9 +48,12 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg } try { if (args.replacesPtyId !== undefined) { - // Why: stop before resolving the pane owner, so the spawn below finds a dead owner and - // launches fresh instead of reattaching the process this restart exists to replace. - await deps.stopReplacedPty(args.replacesPtyId) + ctx.replacedPaneOwner = await stopReplacedPaneOwner(deps, { + replacesPtyId: args.replacesPtyId, + paneKey: resolveEarlyPaneKey(args), + worktreeId: args.worktreeId, + connectionId: args.connectionId + }) } triggerPtySpawnPushTargetMaterialization(deps, args) const early = await beginPtyIpcSpawn(ctx) @@ -53,6 +62,7 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg } await preparePtyIpcSpawnPreflight(ctx) await assemblePtyIpcSpawnEnv(ctx) + await preparePtyIpcQoderCommand(ctx) const earlyReserved = await buildPtyIpcSpawnOptions(ctx).catch((error: unknown) => { restoreProvisionalPtySize(ctx) throw error @@ -79,6 +89,9 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg ) ctx.pendingRegistrationPtyId = null } + if (ctx.replacedPaneOwner) { + await releaseStoppedPaneBinding(deps.store, ctx.replacedPaneOwner) + } // Why: once the reservation is created, any later throw — // spawn failure, persist failure, or a post-spawn helper such as // seedHeadlessTerminal/registerPty/track — must settle it. Otherwise diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts index b1240c4ec6e..d753ac4a6fd 100644 --- a/src/main/ipc/pty/ipc/spawn-state.ts +++ b/src/main/ipc/pty/ipc/spawn-state.ts @@ -8,6 +8,7 @@ import { createPtySpawnTiming } from '../../pty-spawn-timing' import { noCodexResumeLaunch, type CodexResumeLaunch } from '../host-env/codex-resume' import type { StablePaneOwner } from '../pane/stable-owner' import type { PaneSpawnReservation } from '../pane/spawn-reservation' +import type { ReplacedPaneOwner } from '../pane/pane-owner-replacement' import { localProvider } from '../provider/registry' import type { AdoptStablePaneResult, PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' @@ -25,6 +26,7 @@ export type PtyIpcSpawnState = { earlyWorktreeId: string | undefined paneSpawnReservationKey: string | null paneSpawnReservation: PaneSpawnReservation | null + replacedPaneOwner: ReplacedPaneOwner | null finishTerminalInstall: () => void result: PtySpawnResult stablePaneOwner: StablePaneOwner | null @@ -103,6 +105,7 @@ export function createPtyIpcSpawnState( earlyWorktreeId: undefined, paneSpawnReservationKey: null, paneSpawnReservation: null, + replacedPaneOwner: null, finishTerminalInstall: () => {}, result: { id: '' }, stablePaneOwner: null, diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index d1f340be17d..8e91799c429 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -104,6 +104,7 @@ export type PtySpawnIpcDeps = { providerSession?: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv + useSelectedAccount?: boolean }) => PreparedCodexResumeHome | null noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch resolveCodexResumeLaunch: ( diff --git a/src/main/ipc/pty/pane/pane-owner-replacement.test.ts b/src/main/ipc/pty/pane/pane-owner-replacement.test.ts new file mode 100644 index 00000000000..681a48ba616 --- /dev/null +++ b/src/main/ipc/pty/pane/pane-owner-replacement.test.ts @@ -0,0 +1,108 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from '../../../persistence/loading-store/profile-state-delayed-authority-fixture' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../../../persistence-session-fixtures' +import { swapReplacedPaneBinding, type ReplacedPaneOwner } from './pane-owner-replacement' + +vi.mock('../../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const worktreeId = 'repo-local::/fixture/local' +const tabId = 'restart-tab' + +function replaced(ptyId: string, hostId?: string): ReplacedPaneOwner { + return { ptyId, pane: { worktreeId, tabId, leafId: TEST_LEAF_1, hostId } } +} + +type FixtureStore = Awaited>['store'] + +/** The binding swap must survive the renderer's own pre-connect clear, which strict fences refuse. */ +async function expectSwapAfterRendererClear( + store: FixtureStore, + oldId: string, + newId: string, + hostId?: string +): Promise { + expect( + store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_1] + ).toBeUndefined() + const binding = { worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: newId, incarnationId: 'inc-new' } + expect( + await store.persistPtyBinding( + { ...binding, expectedBinding: { ptyId: oldId, incarnationId: 'inc-old' } }, + hostId + ) + ).toBe(false) + expect( + await store.persistPtyBinding( + swapReplacedPaneBinding(store, binding, replaced(oldId, hostId), hostId), + hostId + ) + ).toBe(true) + expect( + store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_1] + ).toBe(newId) +} + +it('swaps a local split pane whose renderer layout patch already dropped the stopped binding', async () => { + const { store } = await fixture() + const pane = { worktreeId, tabId } + await store.persistPtyBinding({ + ...pane, + leafId: TEST_LEAF_1, + ptyId: 'old', + incarnationId: 'inc-old' + }) + await store.persistPtyBinding({ ...pane, leafId: TEST_LEAF_2, ptyId: 'sibling' }) + const layouts = structuredClone(store.getWorkspaceSession().terminalLayoutsByTabId) + // The mounted restart clears only its own leaf before connecting; a partial map is honored. + delete layouts[tabId].ptyIdsByLeafId![TEST_LEAF_1] + store.patchWorkspaceSession({ terminalLayoutsByTabId: layouts }) + await expectSwapAfterRendererClear(store, 'old', 'new') + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_2] + ).toBe('sibling') +}) + +it('swaps an SSH pane whose terminated lease let the renderer clear withdraw the binding', async () => { + const { store } = await fixture() + const hostId = 'ssh:restart' + const oldId = 'ssh:restart@@remote-old' + await store.persistPtyBinding( + { worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: oldId, incarnationId: 'inc-old' }, + hostId + ) + store.upsertSshRemotePtyLease({ + targetId: 'restart', + ptyId: 'remote-old', + worktreeId, + tabId, + leafId: TEST_LEAF_1, + state: 'attached' + }) + // What the replacement stop records (finishPtyShutdown). + store.markSshRemotePtyLease('restart', 'remote-old', 'terminated') + const layouts = structuredClone(store.getWorkspaceSession(hostId).terminalLayoutsByTabId) + layouts[tabId].ptyIdsByLeafId = {} + store.patchWorkspaceSession({ terminalLayoutsByTabId: layouts }, hostId) + await expectSwapAfterRendererClear(store, oldId, 'ssh:restart@@remote-new', hostId) +}) + +it('refuses the swap when another owner holds the leaf', async () => { + const { store } = await fixture() + await store.persistPtyBinding({ worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: 'successor' }) + const binding = { worktreeId, tabId, leafId: TEST_LEAF_1, ptyId: 'new' } + expect( + await store.persistPtyBinding( + swapReplacedPaneBinding(store, binding, replaced('old'), undefined) + ) + ).toBe(false) + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[tabId].ptyIdsByLeafId?.[TEST_LEAF_1] + ).toBe('successor') +}) diff --git a/src/main/ipc/pty/pane/pane-owner-replacement.ts b/src/main/ipc/pty/pane/pane-owner-replacement.ts new file mode 100644 index 00000000000..0130ae8b702 --- /dev/null +++ b/src/main/ipc/pty/pane/pane-owner-replacement.ts @@ -0,0 +1,96 @@ +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { Store } from '../../../persistence' +import type { PersistPtyBindingArgs } from '../../../persistence/loading-store/pty-binding-persistence' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { resolveStablePaneOwner, type StablePaneOwner } from './stable-owner' + +/** The owner a replacing spawn stopped. Its binding stays until the replacement's bind swaps it. */ +export type ReplacedPaneOwner = { + ptyId: string + pane: { worktreeId: string; tabId: string; leafId: string; hostId: string | undefined } | null +} + +/** Caller holds the pane's spawn reservation, so no other spawn can claim the pane meanwhile. */ +export async function stopReplacedPaneOwner( + deps: { + runtime?: OrcaRuntimeService + store?: Store + stopReplacedPty: (id: string) => Promise + }, + args: { + replacesPtyId: string + paneKey: string | null + worktreeId: string | undefined + connectionId: string | null | undefined + } +): Promise { + const owner = resolveStablePaneOwner( + deps.runtime, + deps.store, + args.paneKey, + args.worktreeId, + args.connectionId + ) + if (owner && owner.ptyId !== args.replacesPtyId) { + throw new Error('terminal_pane_owner_changed') + } + await deps.stopReplacedPty(args.replacesPtyId) + const pane = args.paneKey ? parsePaneKey(args.paneKey) : null + return { + ptyId: args.replacesPtyId, + pane: + pane && args.worktreeId + ? { + worktreeId: args.worktreeId, + tabId: pane.tabId, + leafId: pane.leafId, + hostId: args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined + } + : null + } +} + +/** The stopped id is dead whatever its incarnation, so a binding naming it must not be reattached. */ +export function excludeReplacedPaneOwner( + owner: StablePaneOwner | null, + replaced: ReplacedPaneOwner | null +): StablePaneOwner | null { + return owner && owner.ptyId === replaced?.ptyId ? null : owner +} + +/** + * The replacement's bind is the swap: decided inside the bind's durable mutation, it takes a leaf + * still naming the stopped id, or one the renderer already cleared; any other owner wins. + */ +export function swapReplacedPaneBinding( + store: Store, + binding: PersistPtyBindingArgs, + replaced: ReplacedPaneOwner, + hostId: string | undefined +): () => PersistPtyBindingArgs | null { + return () => { + const bound = + store.getWorkspaceSession(hostId).terminalLayoutsByTabId?.[binding.tabId]?.ptyIdsByLeafId?.[ + binding.leafId + ] + // Unbound is ok: the renderer's pre-connect clear can withdraw it (split tab, SSH lease ended). + return bound === undefined || bound === replaced.ptyId ? binding : null + } +} + +/** Best effort: a failed replacement must not leave the stopped id bound for the remount to reattach. */ +export async function releaseStoppedPaneBinding( + store: Store | undefined, + replaced: ReplacedPaneOwner +): Promise { + if (!store || !replaced.pane) { + return + } + const { hostId, ...pane } = replaced.pane + try { + await store.retirePtyBinding({ ...pane, ptyId: replaced.ptyId }, hostId) + } catch (error) { + console.warn('[pty] could not clear the stopped pane binding after a failed restart:', error) + } +} diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts index f4dd8f32007..7a494522ec0 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -89,6 +89,7 @@ export function configureLocalPtyProvider(args: { }), launchCommand: ctx?.command, launchAgent: ctx?.launchAgent, + shellPath: ctx?.shellPath, isWsl: ctx?.isWsl, wslDistro: ctx?.wslDistro ?? null, agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 484f9166e4b..5effb7e682e 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -272,6 +272,7 @@ export async function prepareRuntimePtySpawn( stripInheritedOrcaCodexHome: ctx.stripInheritedOrcaCodexHome, launchCommand: ctx.launchCommand, launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + shellPath: ctx.daemonShellOverride, isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd), wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), diff --git a/src/main/ipc/qoder-desktop-execution-host.test.ts b/src/main/ipc/qoder-desktop-execution-host.test.ts new file mode 100644 index 00000000000..9d5955d9835 --- /dev/null +++ b/src/main/ipc/qoder-desktop-execution-host.test.ts @@ -0,0 +1,299 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures' +import { + registerPtyHandlers, + registerSshPtyProvider, + unregisterSshPtyProvider, + getLocalPtyProvider +} from './pty' +import { isCommandOnPath } from './preflight-command-exec' +import { detectWslCommandsOnPath } from './preflight-wsl-agent-detection' +import type * as FsPromises from 'node:fs/promises' +import type { Store } from '../persistence' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' +import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' +import type { PtySpawnIpcArgs } from './pty/ipc/spawn-types' + +vi.mock('./preflight-command-exec', () => ({ isCommandOnPath: vi.fn() })) +vi.mock('./local-agent-install-dir-detection', () => ({ + detectCommandsInInstallDirs: () => new Set() +})) +vi.mock('./preflight-wsl-agent-detection', () => ({ detectWslCommandsOnPath: vi.fn() })) +const { mux } = vi.hoisted(() => ({ mux: vi.fn() })) +vi.mock('../ssh/ssh-target-registry', () => ({ getActiveMultiplexer: mux })) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: vi.fn(async () => ({ isDirectory: () => true })) +})) +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +describe('desktop Qoder execution-host selection', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + const { setupDaemonAdapter, withWin32Platform } = createDaemonActiveProviderFixtures({ + handlers, + mainWindow + }) + const nativeProbe = vi.mocked(isCommandOnPath) + const wslProbe = vi.mocked(detectWslCommandsOnPath) + beforeEach(() => { + nativeProbe.mockReset().mockImplementation(async (cmd) => cmd === 'qodercli') + wslProbe.mockReset().mockResolvedValue(new Set(['qoder'])) + mux.mockReset() + }) + async function spawn(args: Partial = {}, store?: Store) { + const providerSpawn = setupDaemonAdapter() + registerPtyHandlers(mainWindow, undefined, undefined, undefined, undefined, store) + const result = await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: process.cwd(), + worktreeId: `repo-review::${process.cwd()}`, + launchAgent: 'qoder', + command: 'qodercli --resume original-id', + ...args + }) + return { providerSpawn, result } + } + function folderStore(folderPath: string): Store { + const folder: FolderWorkspace = { + id: 'review-folder', + projectGroupId: 'review-group', + name: 'review', + folderPath, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } + const store: Pick< + Store, + 'getFolderWorkspace' | 'getFolderWorkspaces' | 'getProjectGroups' | 'getRepos' + > = { + getFolderWorkspace: () => folder, + getFolderWorkspaces: () => [folder], + getProjectGroups: () => [], + getRepos: () => [] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These launches omit pane metadata; only folder path/status methods are reached, trust is disabled by the suite. + return store as Store + } + it.each([ + ['legacy-only', ['qodercli']], + ['both', ['qoder', 'qodercli']] + ] as const)('keeps legacy preference on %s native hosts', async (_, commands) => { + nativeProbe.mockImplementation(async (cmd) => commands.some((found) => found === cmd)) + const { providerSpawn } = await spawn() + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ command: 'qodercli --resume original-id' }) + ) + expect(nativeProbe).toHaveBeenCalledWith('qodercli') + expect(nativeProbe).toHaveBeenCalledWith('qoder') + expect(wslProbe).not.toHaveBeenCalled() + }) + it('preserves exact resume tokens and captured configuration on a native folder', async () => { + nativeProbe.mockImplementation(async (cmd) => cmd === 'qoder') + const plan = buildAgentResumeStartupPlan({ + agent: 'qoder', + providerSession: { key: 'session_id', id: 'session with spaces' }, + platform: 'darwin', + cmdOverrides: {} + }) + if (!plan) { + throw new Error('Missing Qoder resume plan') + } + const { providerSpawn, result } = await spawn( + { + worktreeId: 'folder:review-folder', + command: plan.launchCommand, + launchConfig: plan.launchConfig + }, + folderStore(process.cwd()) + ) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: "qoder '--resume' 'session with spaces'", + cwd: process.cwd(), + worktreeId: 'folder:review-folder' + }) + ) + expect(result).toMatchObject({ launchConfig: { ...plan.launchConfig, agentCommand: 'qoder' } }) + }) + it.each([ + { command: "echo 'qodercli --resume text'", launchAgent: 'qoder' }, + { command: 'qodercli --resume original-id', launchAgent: undefined }, + { command: '/caller/qodercli --resume original-id', launchAgent: 'qoder' }, + { command: 'qoder --resume original-id', launchAgent: 'qoder' } + ] as const)('leaves caller and shell commands unchanged: $command', async (args) => { + const { providerSpawn } = await spawn(args) + expect(providerSpawn).toHaveBeenCalledWith(expect.objectContaining({ command: args.command })) + expect(nativeProbe).not.toHaveBeenCalled() + expect(wslProbe).not.toHaveBeenCalled() + expect(mux).not.toHaveBeenCalled() + }) + it('uses the selected project WSL distro rather than installed native legacy', async () => { + await withWin32Platform(async () => { + const { providerSpawn } = await spawn({ + cwd: '/work/project', + worktreeId: 'repo-review::/work/project', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'project-review', + distro: 'Debian', + reason: 'project-override', + cacheKey: 'review' + } + } + }) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'qoder --resume original-id', + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + ) + expect(wslProbe).toHaveBeenCalledWith({ distro: 'Debian' }, ['qodercli', 'qoder']) + expect(nativeProbe).not.toHaveBeenCalled() + }) + }) + it('retains a repo-less UNC folder execution distro', async () => { + await withWin32Platform(async () => { + const { providerSpawn } = await spawn( + { + cwd: String.raw`\\wsl.localhost\Ubuntu\home\review\folder`, + worktreeId: 'folder:review-folder', + shellOverride: 'wsl.exe' + }, + folderStore(String.raw`\\wsl.localhost\Ubuntu\home\review\folder`) + ) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'qoder --resume original-id', + terminalWindowsWslDistro: 'Ubuntu', + cwd: '//wsl.localhost/Ubuntu/home/review/folder' + }) + ) + expect(wslProbe).toHaveBeenCalledWith({ distro: 'Ubuntu' }, ['qodercli', 'qoder']) + expect(nativeProbe).not.toHaveBeenCalled() + }) + }) + it('keeps native Windows launches on the host shell', async () => { + await withWin32Platform(async () => { + nativeProbe.mockImplementation(async (cmd) => cmd === 'qoder') + const { providerSpawn } = await spawn({ + cwd: 'C:\\review', + worktreeId: 'repo-review::C:\\review', + shellOverride: 'cmd.exe' + }) + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'qoder --resume original-id', + shellOverride: 'cmd.exe', + terminalWindowsWslDistro: null + }) + ) + expect(wslProbe).not.toHaveBeenCalled() + }) + }) + it.each(['modern-only', 'legacy-only', 'missing', 'disposed'] as const)( + 'lets SSH own detection and refuse absent contact: %s', + async (host) => { + const remoteSpawn = setupDaemonAdapter() + registerSshPtyProvider('ssh-qoder-review', getLocalPtyProvider()) + const localSpawn = setupDaemonAdapter() + const request = vi + .fn() + .mockResolvedValue({ agents: [host === 'modern-only' ? 'qoder' : 'qodercli'] }) + mux.mockReturnValue( + host === 'missing' ? null : { isDisposed: () => host === 'disposed', request } + ) + registerPtyHandlers(mainWindow) + try { + const pending = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/remote/folder', + worktreeId: 'repo-ssh::/remote/folder', + connectionId: 'ssh-qoder-review', + launchAgent: 'qoder', + command: "qodercli '--resume' 'same remote id'" + }) + if (host === 'missing' || host === 'disposed') { + await expect(pending).rejects.toThrow('execution host connection') + expect(remoteSpawn).not.toHaveBeenCalled() + expect(request).not.toHaveBeenCalled() + } else { + await pending + expect(remoteSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/remote/folder', + command: `${host === 'modern-only' ? 'qoder' : 'qodercli'} '--resume' 'same remote id'` + }) + ) + expect(request).toHaveBeenCalledWith('preflight.detectAgents', { + commands: [ + { id: 'qodercli', cmd: 'qodercli' }, + { id: 'qoder', cmd: 'qoder' } + ] + }) + } + expect(localSpawn).not.toHaveBeenCalled() + expect(nativeProbe).not.toHaveBeenCalled() + expect(wslProbe).not.toHaveBeenCalled() + expect(mux).toHaveBeenCalledWith('ssh-qoder-review') + } finally { + unregisterSshPtyProvider('ssh-qoder-review') + } + } + ) +}) diff --git a/src/main/ipc/qoder-independent-renderer-spawn-review.test.ts b/src/main/ipc/qoder-independent-renderer-spawn-review.test.ts new file mode 100644 index 00000000000..a9c20cc9ad3 --- /dev/null +++ b/src/main/ipc/qoder-independent-renderer-spawn-review.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures' +import { registerPtyHandlers } from './pty' +import { detectAgentCommandsOnHost } from '../preflight/agent-detection' +import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' +import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' +import { trackMock } from './pty-ipc-mock-registry' + +vi.mock('../preflight/agent-detection', () => ({ + detectAgentCommandsOnHost: vi.fn(async () => new Set(['qoder'])) +})) +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +describe('independent renderer Qoder provider spawn boundary', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + const { setupDaemonAdapter } = createDaemonActiveProviderFixtures({ handlers, mainWindow }) + it.each(['start', 'resume'] as const)( + 'selects modern-only executable for desktop %s', + async (mode) => { + const plan = + mode === 'start' + ? buildAgentStartupPlan({ + agent: 'qoder', + prompt: 'review prompt', + agentArgs: null, + cmdOverrides: {}, + platform: 'darwin' + }) + : buildAgentResumeStartupPlan({ + agent: 'qoder', + providerSession: { key: 'session_id', id: 'original-review-session' }, + cmdOverrides: {}, + platform: 'darwin' + }) + if (!plan) { + throw new Error('Missing existing Qoder startup plan') + } + const physicalSpawn = setupDaemonAdapter() + registerPtyHandlers(mainWindow) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: process.cwd(), + command: plan.launchCommand, + launchAgent: 'qoder', + launchConfig: plan.launchConfig, + ...(mode === 'start' ? { telemetry: agentStartedTelemetry('qoder', 'orchestration') } : {}) + }) + expect(physicalSpawn.mock.calls.at(-1)?.[0].command).toBe( + plan.launchCommand.replace(/^qodercli/, 'qoder') + ) + expect(detectAgentCommandsOnHost).toHaveBeenCalled() + const events = trackMock.mock.calls.filter(([event]) => event === 'agent_started') + if (mode === 'start') { + expect(events).toEqual([ + [ + 'agent_started', + { agent_kind: 'qoder', launch_source: 'orchestration', request_kind: 'new' } + ] + ]) + } else { + expect(events).toEqual([]) + } + } + ) + it.each([ + 'qoder --resume original-review-session', + '/caller/qodercli --resume original-review-session' + ])('preserves an explicitly chosen command: %s', async (command) => { + const physicalSpawn = setupDaemonAdapter() + registerPtyHandlers(mainWindow) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: process.cwd(), + command, + launchAgent: 'qoder' + }) + expect(physicalSpawn.mock.calls.at(-1)?.[0].command).toBe(command) + expect(trackMock).not.toHaveBeenCalledWith('agent_started', expect.anything()) + }) +}) diff --git a/src/main/ipc/runtime-upload-file-stream.test.ts b/src/main/ipc/runtime-upload-file-stream.test.ts index 4d11bc18652..87d2c66c61a 100644 --- a/src/main/ipc/runtime-upload-file-stream.test.ts +++ b/src/main/ipc/runtime-upload-file-stream.test.ts @@ -35,7 +35,6 @@ vi.mock('./runtime-environment-transport-routing', () => ({ callRuntimeEnvironment: (...args: Parameters) => callRuntimeEnvironment(...args) })) -vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) // Why: see filesystem-runtime-upload-staging.test.ts — a real over-limit fixture // would allocate gigabytes on Windows. vi.mock('./runtime-import-limits', async (importOriginal) => ({ diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts index 28271774ce0..079549e4711 100644 --- a/src/main/ipc/runtime-upload-file-stream.ts +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -5,7 +5,6 @@ import type { RuntimeUploadFileStreamRequest, StagedRuntimeUploadFileIdentity } from '../../shared/runtime-upload-staging-contract' -import { authorizeExternalPath } from './filesystem-auth' import { formatByteCeiling, REMOTE_IMPORT_MAX_FILE_BYTES } from './runtime-import-limits' import { isRuntimeEnvironmentManuallyDisconnected, @@ -39,9 +38,6 @@ export async function streamExternalFileToRuntime( ): Promise<{ byteLength: number }> { const sourcePath = resolveEntrySourcePath(args.sourceRootPath, args.entryRelativePath) - // Why: parity with staging — an OS drop authorizes the paths it hands over. - authorizeExternalPath(sourcePath) - // Why: relativePath is the hidden .orca-upload- temp destination, so a // dropped file names its source instead of a path the user never chose. const displayPath = args.entryRelativePath || basename(args.sourceRootPath) @@ -189,7 +185,7 @@ async function sendChunk( } function resolveEntrySourcePath(sourceRootPath: string, entryRelativePath: string): string { - // Why: staging resolves before authorizing, so the streamer has to agree on + // Why: staging resolves the source first, so the streamer has to agree on // the same absolute path or the two checks can disagree. const root = resolve(sourceRootPath) return entryRelativePath ? join(root, entryRelativePath) : root diff --git a/src/main/ipc/runtime-upload-slice-boundaries.test.ts b/src/main/ipc/runtime-upload-slice-boundaries.test.ts index 0c2bd457b1f..6a7b580e086 100644 --- a/src/main/ipc/runtime-upload-slice-boundaries.test.ts +++ b/src/main/ipc/runtime-upload-slice-boundaries.test.ts @@ -16,7 +16,6 @@ import type { StagedRuntimeUploadFileIdentity } from '../../shared/runtime-uploa // Why: real limits, real host write flags ('wx' then 'a') and the real chunk // schema — the slice loop is exercised exactly at the boundaries it must respect. -vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) type ChunkParams = { relativePath: string; contentBase64: string; append: boolean } type CallOptions = { expectedEnvironmentRuntimeId?: string; signal?: AbortSignal } diff --git a/src/main/ipc/tui-agent-detection-commands.test.ts b/src/main/ipc/tui-agent-detection-commands.test.ts index b6d536f732b..4d1ca779012 100644 --- a/src/main/ipc/tui-agent-detection-commands.test.ts +++ b/src/main/ipc/tui-agent-detection-commands.test.ts @@ -6,6 +6,24 @@ import { } from './tui-agent-detection-commands' describe('tui agent detection commands', () => { + it.each(['darwin', 'linux', 'win32', 'wsl'] as const)( + 'detects modern and legacy Qoder once on %s', + (runtime) => { + const commands = KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter( + (command) => command.id === 'qoder' + ) + for (const found of [ + new Set(['qoder']), + new Set(['qodercli']), + new Set(['qoder', 'qodercli']) + ]) { + expect(resolveDetectedTuiAgentIds(commands, found, runtime)).toEqual(['qoder']) + } + expect(resolveDetectedTuiAgentIds(commands, new Set(['qoder-unrelated']), runtime)).toEqual( + [] + ) + } + ) it('requires Claude before reporting Claude Agent Teams', () => { const commands = KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter( (command) => command.id === 'claude-agent-teams' diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index 526741afa35..e7fecbe60da 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -119,6 +119,29 @@ describe('Jira issue operations', () => { ) }) + it('scopes create-time assignable search by project, with the Server username param', async () => { + jiraRequestMock.mockResolvedValueOnce([{ accountId: 'acc-1', displayName: 'Ada' }]) + const { listAssignableUsersForProject } = await import('./issues') + + const cloudUsers = await listAssignableUsersForProject('ALP', 'ada', 'site-1') + + expect(jiraRequestMock).toHaveBeenCalledWith( + expect.anything(), + '/rest/api/3/user/assignable/search?project=ALP&maxResults=50&query=ada' + ) + expect(cloudUsers).toEqual([expect.objectContaining({ accountId: 'acc-1' })]) + + getClientsMock.mockReturnValue([makeServerEntry()]) + jiraRequestMock.mockResolvedValueOnce([]) + + await listAssignableUsersForProject('ALP', 'ada', 'server-1') + + expect(jiraRequestMock).toHaveBeenLastCalledWith( + expect.anything(), + '/rest/api/2/user/assignable/search?project=ALP&maxResults=50&username=ada' + ) + }) + it('loads Jira summaries without descriptions, rendered fields, or attachment media', async () => { jiraRequestMock.mockResolvedValueOnce({ id: 'issue-1', diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 450b6e19d72..74d185468f3 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -6,6 +6,7 @@ export { getIssueComments } from './jira-issue-comments' export { listProjects } from './jira-project-queries' export { listAssignableUsers, + listAssignableUsersForProject, listCreateFields, listIssueTypes, listPriorities, diff --git a/src/main/jira/jira-issue-create-metadata.ts b/src/main/jira/jira-issue-create-metadata.ts index 90325930afa..070107f9b3f 100644 --- a/src/main/jira/jira-issue-create-metadata.ts +++ b/src/main/jira/jira-issue-create-metadata.ts @@ -162,17 +162,18 @@ export async function searchUsers(query?: string, siteId?: string | null): Promi } } -export async function listAssignableUsers( - key: string, - query?: string, - siteId?: string | null +async function searchAssignableUsers( + scope: { issueKey: string } | { project: string }, + query: string | undefined, + siteId: string | null | undefined, + label: string ): Promise { const entry = getClients(siteId)[0] if (!entry) { return [] } const isServer = entry.site.authType === 'server' - const params = new URLSearchParams({ issueKey: key, maxResults: '50' }) + const params = new URLSearchParams({ ...scope, maxResults: '50' }) if (query?.trim()) { // Server/DC filters assignable users by `username`; `query` is Cloud-only. params.set(isServer ? 'username' : 'query', query.trim()) @@ -189,9 +190,31 @@ export async function listAssignableUsers( clearToken(entry.site.id) throw error } - console.warn('[jira] listAssignableUsers failed:', error) + console.warn(`[jira] ${label} failed:`, error) return [] } finally { release() } } + +export async function listAssignableUsers( + key: string, + query?: string, + siteId?: string | null +): Promise { + return searchAssignableUsers({ issueKey: key }, query, siteId, 'listAssignableUsers') +} + +// Creation has no issue key yet, so assignability is scoped by project. +export async function listAssignableUsersForProject( + projectIdOrKey: string, + query?: string, + siteId?: string | null +): Promise { + return searchAssignableUsers( + { project: projectIdOrKey }, + query, + siteId, + 'listAssignableUsersForProject' + ) +} 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 a52ff73288c..5e4574b60eb 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( @@ -39,6 +43,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-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 ca274120a7b..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 @@ -71,6 +71,40 @@ export class JournalRowWriter { }) } + /** 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( 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-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index 04323e36bb2..c04e354fb89 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 @@ -70,6 +70,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-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index 33ef8080c03..b1922b913ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -12,6 +12,7 @@ import { import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' @@ -121,9 +122,19 @@ async function settledTurn() { const { items } = await host.journalSnapshot(SESSION) const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) const [settled] = [...selectStructuredAgentSettledTurns(items).values()] - return { turn, settled } + const label = settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }).key + // Every error row a reader's transcript shows, the cut turn's derived notice included. + const notices = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap((item) => + item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] + ) + return { turn, settled, label, notices } } +/** A turn nobody stopped reads like a finished one, with exactly one row saying it stopped. */ +const ONE_NOTICE = [ + 'Codex stopped while this response was in progress. You can continue in this conversation.' +] + function lastSummary(statuses: AgentSessionStatusEvent[]) { const last = statuses.at(-1) return last?.type === 'status' ? last.session : null @@ -136,11 +147,11 @@ describe('a turn cut short by closing its provider', () => { await host.close(SESSION, 'user-close') expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' }) - const { turn, settled } = await settledTurn() + const { turn, label, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) - expect( - settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'interruptedAfter' }) + expect(label).toBe('interruptedAfter') + // The person's own close explains itself. + expect(notices).toEqual([]) }) it('leaves a close the user did not aim at this chat as news', async () => { @@ -150,12 +161,12 @@ describe('a turn cut short by closing its provider', () => { await host.close(SESSION, 'evict') expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'interruption' }) - const { turn, settled } = await settledTurn() + const { turn, label, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - expect( - settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'failedAfter' }) + // News for the sidebar; the turn reads like a finished one beside its one notice. + expect(label).toBe('workedFor') + expect(notices).toEqual(ONE_NOTICE) }) it("records the user's close on a turn no adapter settled, through the host's fallback", async () => { @@ -247,10 +258,9 @@ describe('a turn cut short by closing its provider', () => { expect(statuses.findLast((event) => event.type === 'status')).toMatchObject({ session: { status: 'idle', turnOutcome: 'interruption' } }) - const { settled } = await settledTurn() - expect( - settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }) - ).toMatchObject({ key: 'failedAfter' }) + const { label, notices } = await settledTurn() + expect(label).toBe('workedFor') + expect(notices).toEqual(ONE_NOTICE) }) it("keeps the user's cancellation when a close aborts after the provider settled, then retries", async () => { @@ -297,9 +307,10 @@ describe('a turn cut short by closing its provider', () => { await host.collaboratorsForTests().lifetime.idleSweep.tick() expect(closeCalls).toBe(1) - const { turn } = await settledTurn() + const { turn, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn?.outcome).toBe(verdict.outcome) + expect(notices).toEqual(cause === 'evict' ? ONE_NOTICE : []) } ) @@ -309,9 +320,10 @@ describe('a turn cut short by closing its provider', () => { await host.flushAllStreamedEvents({ trigger: 'quit' }) - const { turn } = await settledTurn() + const { turn, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') + expect(notices).toEqual(ONE_NOTICE) }) it('leaves a crash the provider saw before the user closed the chat as news', async () => { @@ -319,9 +331,10 @@ describe('a turn cut short by closing its provider', () => { await runningTurn() // The provider reports its own exit, which it saw first, as it closes: no verdict. await host.close(SESSION, 'user-close') - const { turn } = await settledTurn() + const { turn, notices } = await settledTurn() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') + expect(notices).toEqual(ONE_NOTICE) }) it.each(['user-close', 'evict'] as const)( 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-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 7e237615089..d31a79e5f54 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -38,6 +38,7 @@ import { } from './structured-agent-session-adapter' import { resettleOpenStructuredAgentSessionConversation } from './structured-agent-session-conversation-open' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { @@ -216,6 +217,34 @@ describe('a turn a crash cut short mid-tool', () => { expect(completedStructuredAgentTurnSeconds(timing)).toBe(27) }) + // The turn bar reads like a finished turn, so this row is the one place the chat says why. + it('explains the cut once, with one notice row and a turn bar that does not repeat it', async () => { + openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) + + await host.restoreReadableSessions() + + const { items } = await host.journalSnapshot(SESSION) + // As a reader's transcript shows it: the stored row is the explanation, so none is derived. + const statusRows = withNativeChatCutTurnNotices(items, { agentName: 'Claude' }).flatMap( + (item) => (item.body.kind === 'status' ? [item.body] : []) + ) + expect(statusRows).toEqual([ + expect.objectContaining({ + text: 'Claude stopped while this response was in progress. You can continue in this conversation.', + failure: expect.objectContaining({ kind: 'providerExited' }), + tone: 'error' + }) + ]) + const [timing] = selectStructuredAgentTurnTimings(items).values() + expect( + describeNativeChatTurnStatus({ + elapsedSeconds: 0, + workedSeconds: completedStructuredAgentTurnSeconds(timing), + verdict: timing?.verdict + }) + ).toEqual({ key: 'workedFor', duration: '27s' }) + }) + it('ends at the pre-crash renewal when the child outlived Orca and recovery stopped it', async () => { // The orphan is alive at relaunch, but its output went nowhere: none of that is work shown. let alive = true @@ -298,7 +327,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () unsubscribe() }) - it('reports the revision to the status feed as an interruption, which the chat folds as failed', async () => { + it('reports the revision to the status feed as an interruption, which the chat folds as worked', async () => { const published: AgentSessionStatusSummary[] = [] openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }), @@ -314,7 +343,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () await host.reconcileRestartLeases() await drainSession() - // The sidebar's red Failed, then the folded "Failed after 27s". + // The sidebar's red Failed until seen; the turn folds as "Worked for 27s" beside its notice row. await vi.waitFor(() => expect(outcomes().at(-1)).toBe('interruption')) const [timing] = selectStructuredAgentTurnTimings( (await host.journalSnapshot(SESSION)).items @@ -325,7 +354,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () workedSeconds: completedStructuredAgentTurnSeconds(timing), verdict: timing?.verdict }) - ).toEqual({ key: 'failedAfter', duration: '27s' }) + ).toEqual({ key: 'workedFor', duration: '27s' }) }) it('revises nothing twice, whoever re-runs the settle', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 39ff1c7ba51..f0372c8e58d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -4,6 +4,7 @@ import { type SubmissionRejectionFact } from '../../../shared/agent-session-failure' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -229,7 +230,7 @@ export async function settleStaleStructuredAgentSessionState(input: { ) // Per attempt: a retry re-partitions only what is left, and a reused chunk id would skip it. const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}` - const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}` + const settlementId = `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:${input.fence}:${generation}` const mutations: JournalLifecycleMutationInput[] = [] for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) @@ -261,7 +262,7 @@ export async function settleStaleStructuredAgentSessionState(input: { // Named by the death it explains, so a retry after a partly written settle adds no second row. identity: { provider: 'orca', - clientMessageId: `stale-session:${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` + clientMessageId: `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}` }, // The death evidence is Orca's log text, never a sentence for a person: the row says only // that the provider stopped. 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-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-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index e8dfe9f7f5e..0857e3565d6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -183,8 +183,8 @@ describe('a turn recovery settled after its host went away', () => { } ) - // The chat's turn bar and the tab's mark read one verdict: a turn nobody stopped failed, and - // must never show the done tick of a finished turn. + // The tab's mark reads a turn nobody stopped as failed; the chat's turn bar reads it like a + // finished turn, since the chat's notice row says why it stopped. it.each([ [ 'a restart', @@ -208,7 +208,7 @@ describe('a turn recovery settled after its host went away', () => { ) ] ] as const)( - 'reads Failed after N, marked failed, for a turn cut off by %s', + 'reads Worked for N, marked failed, for a turn cut off by %s', async (_label, cut) => { const session = await sessionWithRunningTurn() session.recoverAt(RECOVERED) @@ -221,7 +221,7 @@ describe('a turn recovery settled after its host went away', () => { ...selectStructuredAgentSettledTurns(session.journal.snapshot().items).values() ] expect(settled && formatNativeChatTurnStatusLabel({ elapsedSeconds: 0, ...settled })).toBe( - 'Failed after 1s' + 'Worked for 1s' ) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 27c09bc2bbe..c748748ccd4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -22,6 +22,7 @@ import { type AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { RESTART_CONTINUATION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, @@ -120,7 +121,10 @@ function restartNoteWriter( return } await session.journal.appendItem( - { provider: 'orca', clientMessageId: `restart-continuation:${sessionId}:${host.now()}` }, + { + provider: 'orca', + clientMessageId: `${RESTART_CONTINUATION_ROW_PREFIX}${sessionId}:${host.now()}` + }, { kind: 'status', text, ...(tone ? { tone } : {}) }, // About the conversation, not any turn in it. { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } 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 b1cbc83a48e..4602630960a 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 @@ -1,12 +1,15 @@ import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { + AGENT_SESSION_RESTART_CONTINUATION_NOTE, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE } from '../../../shared/agent-session-restart-continuation' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' import { interruptedRestart, + QUIT_CUT_NOTICE, + readerNotes, statusNotes, throwAfterContinuationAccepted } from './structured-agent-session-restart-interruption-test-harness' @@ -43,6 +46,8 @@ it('files nothing for a chat the user moved on in before its attempt, and spends expect(await capsule.listFailed(NOW)).toEqual([]) expect(await capsule.list(NOW)).toEqual([]) expect(await statusNotes(host)).toEqual([]) + // No note took over, so the cut turn keeps its one notice. + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) }) // The continuation is accepted and its agent then fails to start: the message is rejected with the @@ -51,6 +56,8 @@ it('says so in the chat when the agent cannot start for the continuation', async const { host, acquire } = await interruptedRestart() await host.restartResume.list() acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + // Before the action, the quit's cut reads with its one notice. + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) await host.restartResume.continueAfterRestart([SESSION], 'modal') @@ -61,6 +68,24 @@ it('says so in the chat when the agent cannot start for the continuation', async text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, tone: 'error' }) + // The refusal note is now the one explanation: the cut is not said a second time beside it. + expect(await readerNotes(host)).toEqual(await statusNotes(host)) +}) + +// A continuation that carries on, chosen in the prompt or automatically at launch, does not stand in +// for the cut: its note is written after its own message, so the cut keeps its notice throughout. +it("keeps the quit's notice through a continuation that carries the chat on", async () => { + const { host } = await interruptedRestart() + await host.restartResume.list() + expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(result.continued).toMatchObject([{ outcome: 'continued' }]) + expect(await readerNotes(host)).toEqual([ + QUIT_CUT_NOTICE, + { text: AGENT_SESSION_RESTART_CONTINUATION_NOTE, tone: undefined } + ]) }) // A send that throws after Orca may have taken it cannot be proven undelivered: filed unconfirmed, 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 fe97834a189..59d478cbb40 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 @@ -1,4 +1,8 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice' // A chat interrupted mid-turn by a restart, rebuilt on a fresh host over the same store, for the // restart-resume ownership and failure tests. @@ -177,11 +181,30 @@ export function throwAfterContinuationAccepted(): void { } export async function statusNotes(host: StructuredAgentSessionHost) { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + return notesOf((await host.journalSnapshot(SESSION)).items) +} + +/** The notes a reader's transcript shows, the cut turn's derived notice included. */ +export async function readerNotes(host: StructuredAgentSessionHost) { + return notesOf( + withNativeChatCutTurnNotices((await host.journalSnapshot(SESSION)).items, { + agentName: 'Codex' + }) + ) +} + +function notesOf(items: readonly AgentJournalRenderItem[]) { + return items.flatMap((item) => item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] ) } +/** The one row the quit's cut turn reads with when nothing else explains it. */ +export const QUIT_CUT_NOTICE = { + text: 'Codex stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' +} + /** A continuation the host refuses because the user's own message was accepted first: another * client's send lands after the action reserved the offer, just before the continuation is * accepted. `userAnswers` instead has the user send before or after the whole attempt. */ 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-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 4ae9f59a015..50056eedd03 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -158,7 +158,8 @@ describe('a restart between a Stop and its turn end', () => { expect(errorRows).toEqual([]) }) - it('reads Failed after N, marked failed, when nobody stopped it', async () => { + // The notice row is the one explanation; the turn bar reads like a finished turn. + it('reads Worked for N, marked failed, when nobody stopped it', async () => { await runningTurn(CODEX_TURN) await restartAndSettle() @@ -166,7 +167,7 @@ describe('a restart between a Stop and its turn end', () => { const { turn, label, mark, errorRows } = settled() expect(turn).toMatchObject({ state: 'interrupted' }) expect(turn).not.toHaveProperty('outcome') - expect(label).toMatch(/^Failed after /) + expect(label).toMatch(/^Worked for /) expect(mark).toBe('failed') expect(errorRows).toEqual([ expect.stringContaining('stopped while this response was in progress') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts index 37ee57e79ed..8760e378525 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts @@ -1,3 +1,4 @@ +import '../../runtime/rpc/unused-default-rpc-methods.test-fixture' import { createHash } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 232b101e603..5a0b2f6de30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -1,4 +1,5 @@ import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' @@ -193,5 +194,5 @@ function logExitFailure( } function providerExitSettlementId(event: UnexpectedExitLifecycleEvent): string { - return `provider-exit:${event.sessionId}:${event.fence}:${event.acquisitionGeneration}` + return `${PROVIDER_EXIT_ROW_PREFIX}${event.sessionId}:${event.fence}:${event.acquisitionGeneration}` } diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 7a6d137ebfa..b4b30912b09 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -121,7 +121,11 @@ export class OpenCodeHookService { } } - buildPtyEnv(ptyId: string, existingConfigDir?: string | undefined): Record { + buildPtyEnv( + ptyId: string, + existingConfigDir?: string | undefined, + defaultConfigDir?: string + ): Record { if (!isUsableId(ptyId)) { // Why: on a bad id, still preserve a user-set OPENCODE_CONFIG_DIR; only the Orca status plugin is forfeited. return existingConfigDir ? { OPENCODE_CONFIG_DIR: existingConfigDir } : {} @@ -131,7 +135,7 @@ export class OpenCodeHookService { const managedConfigDir = this.getSharedConfigDir() if (!existingConfigDir || existingConfigDir === managedConfigDir) { try { - this.writePluginToConfigDir(resolveOpenCodeConfigDirectory()) + this.writePluginToConfigDir(defaultConfigDir ?? resolveOpenCodeConfigDirectory()) return {} } catch { return {} diff --git a/src/main/opencode/opencode-launch-model-config.test.ts b/src/main/opencode/opencode-launch-model-config.test.ts new file mode 100644 index 00000000000..65d183e156f --- /dev/null +++ b/src/main/opencode/opencode-launch-model-config.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { resolveOpenCodeLaunchModelConfig } from './opencode-launch-model-config' + +describe('per-launch OpenCode model config', () => { + it('retains credentials, permissions and other agents while overriding the resolved primary model', () => { + const original = { + model: 'provider/old', + default_agent: 'reviewer', + providers: { + custom: { apiKey: 'placeholder-key', models: { preferred: { name: 'Preferred' } } } + }, + permissions: [{ tool: 'shell', action: 'ask' }], + agents: { + reviewer: { model: 'provider/agent-old', system: 'Review only', permissions: [] }, + build: { model: 'provider/build', steps: 5 }, + nested: { mode: 'subagent', model: 'provider/nested' } + } + } + const configContent = JSON.stringify(original) + const merged = resolveOpenCodeLaunchModelConfig({ + configContent, + primaryAgent: 'reviewer', + model: 'provider/preferred' + }) + expect(merged).not.toBeNull() + expect(JSON.parse(merged ?? 'null')).toEqual({ + ...original, + model: 'provider/preferred', + agents: { + ...original.agents, + reviewer: { ...original.agents.reviewer, model: 'provider/preferred' } + } + }) + expect(configContent).toBe(JSON.stringify(original)) + }) + + it('adds an override for the externally resolved built-in primary agent without guessing its name', () => { + expect( + resolveOpenCodeLaunchModelConfig({ + configContent: undefined, + primaryAgent: 'plan', + model: 'provider/preferred' + }) + ).toBe('{"model":"provider/preferred","agents":{"plan":{"model":"provider/preferred"}}}') + }) + + it.each(['{', 'null', '[]', '{"agents":[]}', '{"agents":{"build":null}}'])( + 'refuses malformed inline config %s', + (configContent) => { + expect( + resolveOpenCodeLaunchModelConfig({ + configContent, + primaryAgent: 'build', + model: 'provider/a' + }) + ).toBeNull() + } + ) + + it('refuses oversized config and absent resolved preferences', () => { + expect( + resolveOpenCodeLaunchModelConfig({ + configContent: ' '.repeat(1_048_577), + primaryAgent: 'build', + model: 'provider/a' + }) + ).toBeNull() + expect( + resolveOpenCodeLaunchModelConfig({ + configContent: '{}', + primaryAgent: '', + model: 'provider/a' + }) + ).toBeNull() + }) +}) diff --git a/src/main/opencode/opencode-launch-model-config.ts b/src/main/opencode/opencode-launch-model-config.ts new file mode 100644 index 00000000000..c8a1d75f5ae --- /dev/null +++ b/src/main/opencode/opencode-launch-model-config.ts @@ -0,0 +1,33 @@ +import { z } from 'zod' + +const launchConfig = z + .object({ agents: z.record(z.string(), z.record(z.string(), z.unknown())).optional() }) + .passthrough() + +export function resolveOpenCodeLaunchModelConfig(options: { + configContent: string | undefined + primaryAgent: string + model: string +}): string | null { + if (!options.primaryAgent || !options.model || (options.configContent?.length ?? 0) > 1_048_576) { + return null + } + try { + const raw: unknown = options.configContent?.trim() ? JSON.parse(options.configContent) : {} + const parsed = launchConfig.safeParse(raw) + if (!parsed.success) { + return null + } + const config = parsed.data + return JSON.stringify({ + ...config, + model: options.model, + agents: { + ...config.agents, + [options.primaryAgent]: { ...config.agents?.[options.primaryAgent], model: options.model } + } + }) + } catch { + return null + } +} diff --git a/src/main/opencode/opencode-launch-model-context-cleanup.test.ts b/src/main/opencode/opencode-launch-model-context-cleanup.test.ts new file mode 100644 index 00000000000..4acf1ba72de --- /dev/null +++ b/src/main/opencode/opencode-launch-model-context-cleanup.test.ts @@ -0,0 +1,137 @@ +import { z } from 'zod' +import { ChildProcess } from 'node:child_process' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawnProcess } from '../../shared/child-process/run-process' +import { + forceTerminateProcessTree, + signalProcessTree +} from '../../shared/child-process/process-tree-termination' +import { probeOpenCodeLaunchModelContext } from './opencode-launch-model-context' +import { readFetchResponseJsonWithinLimit } from '../../shared/fetch-response-body' + +vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: vi.fn() })) +vi.mock('../../shared/child-process/process-tree-termination', () => ({ + signalProcessTree: vi.fn(), + forceTerminateProcessTree: vi.fn() +})) + +vi.mock('../../shared/fetch-response-body', () => ({ readFetchResponseJsonWithinLimit: vi.fn() })) + +class ProbeChild extends ChildProcess { + override stdin = new PassThrough() + override stdout = new PassThrough() + override stderr = new PassThrough() + override pid = 123456 + override exitCode: number | null = null + override stdio: [PassThrough, PassThrough, PassThrough, undefined, undefined] = [ + this.stdin, + this.stdout, + this.stderr, + undefined, + undefined + ] +} + +const directory = '/private/project' +const model = { id: 'model-b', providerID: 'private-proof', enabled: true } +const snapshot = { + model: { location: { directory }, data: [model] }, + agent: { + location: { directory }, + data: [{ id: 'build', mode: 'primary', hidden: false, model }] + }, + 'model/default': { location: { directory }, data: model }, + config: [{ type: 'document', info: { default_agent: 'build' } }] +} +const platform = Object.getOwnPropertyDescriptor(process, 'platform') +let child: ProbeChild +let closeDuringFetch = false +function close() { + child.exitCode = 0 + child.emit('exit', 0, null) + child.emit('close', 0, null) +} +const options = { executable: '/private/opencode', cwd: directory, env: {} } + +describe('OpenCode model probe termination evidence', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.mocked(readFetchResponseJsonWithinLimit).mockImplementation((response) => response.json()) + child = new ProbeChild() + closeDuringFetch = false + vi.mocked(spawnProcess).mockImplementation(() => { + queueMicrotask(() => child.stdout.write('server listening on http://127.0.0.1:45678\n')) + return child + }) + vi.stubGlobal( + 'fetch', + vi.fn(async (input: URL) => { + const endpoint = z + .enum(['model', 'agent', 'model/default', 'config']) + .parse(input.pathname.slice('/api/'.length)) + const response = snapshot[endpoint] + if (closeDuringFetch && endpoint === 'config') { + queueMicrotask(close) + } + return new Response(JSON.stringify(response), { status: 200 }) + }) + ) + vi.mocked(signalProcessTree).mockImplementation(async () => { + close() + return true + }) + vi.mocked(forceTerminateProcessTree).mockResolvedValue(false) + }) + + afterEach(() => { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + vi.unstubAllGlobals() + }) + + it('cancels all unread responses if the bounded body reader fails', async () => { + const cancel = vi.fn() + vi.mocked(readFetchResponseJsonWithinLimit).mockRejectedValue(new Error('body_reader_failed')) + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(new ReadableStream({ cancel }), { status: 200 })) + ) + expect(await probeOpenCodeLaunchModelContext(options)).toBeNull() + expect(cancel).toHaveBeenCalledTimes(4) + expect(signalProcessTree).toHaveBeenCalledOnce() + }) + + it('accepts an already-closed Windows probe without signaling its former pid', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + closeDuringFetch = true + vi.mocked(signalProcessTree).mockResolvedValue(false) + expect(await probeOpenCodeLaunchModelContext(options)).toMatchObject({ primaryAgent: 'build' }) + expect(signalProcessTree).not.toHaveBeenCalled() + expect(forceTerminateProcessTree).not.toHaveBeenCalled() + }) + + it('does not force a POSIX group after the graceful close', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + expect(await probeOpenCodeLaunchModelContext(options)).toMatchObject({ primaryAgent: 'build' }) + expect(signalProcessTree).toHaveBeenCalledOnce() + expect(forceTerminateProcessTree).not.toHaveBeenCalled() + }) + + it('keeps a root exit with inherited pipes unverified and avoids its former pid', async () => { + vi.mocked(signalProcessTree).mockImplementation(async () => { + child.exitCode = 0 + child.emit('exit', 0, null) + return false + }) + expect(await probeOpenCodeLaunchModelContext(options)).toBeNull() + expect(forceTerminateProcessTree).not.toHaveBeenCalled() + }) + + it('requires verified termination when a still-live probe does not close', async () => { + vi.mocked(signalProcessTree).mockResolvedValue(true) + expect(await probeOpenCodeLaunchModelContext(options)).toBeNull() + expect(forceTerminateProcessTree).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/opencode/opencode-launch-model-context.test.ts b/src/main/opencode/opencode-launch-model-context.test.ts new file mode 100644 index 00000000000..faf19bfdedf --- /dev/null +++ b/src/main/opencode/opencode-launch-model-context.test.ts @@ -0,0 +1,142 @@ +import { mkdtemp, mkdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + parseOpenCodeLaunchModelContext, + probeOpenCodeLaunchModelContext +} from './opencode-launch-model-context' +import { resolveOpenCodeLaunchModelConfig } from './opencode-launch-model-config' + +const directory = '/private/project' +const snapshot = { + directory, + models: { + location: { directory }, + data: [ + { id: 'one', providerID: 'provider', enabled: true }, + { id: 'disabled', providerID: 'provider', enabled: false } + ] + }, + agents: { + location: { directory }, + data: [ + { + id: 'reviewer', + mode: 'primary', + hidden: false, + model: { id: 'one', providerID: 'provider' } + }, + { id: 'build', mode: 'primary', hidden: false }, + { id: 'hidden', mode: 'primary', hidden: true }, + { id: 'child', mode: 'subagent', hidden: false } + ] + }, + config: [{ type: 'document', info: { default_agent: 'reviewer' } }], + defaultModel: { + location: { directory }, + data: { id: 'one', providerID: 'provider', enabled: true } + } +} + +describe('OpenCode execution-host model context', () => { + it('uses the configured visible primary and exact enabled provider/model pairs', () => { + expect(parseOpenCodeLaunchModelContext(snapshot)).toEqual({ + primaryAgent: 'reviewer', + availableModels: ['provider/one'], + primaryModel: 'provider/one' + }) + }) + + it('uses actual returned primary ordering and skips hidden agents and children', () => { + const agents = { + location: { directory }, + data: [...snapshot.agents.data.slice(2), ...snapshot.agents.data.slice(0, 2)] + } + expect(parseOpenCodeLaunchModelContext({ ...snapshot, agents })?.primaryAgent).toBe('reviewer') + expect( + parseOpenCodeLaunchModelContext({ + ...snapshot, + defaultModel: { location: { directory }, data: null } + }) + ).toBeNull() + }) + + it('refuses an uninitialized, malformed or foreign-location response', () => { + for (const models of [ + null, + { location: { directory }, data: [] }, + { location: { directory: '/other' }, data: snapshot.models.data } + ]) { + expect(parseOpenCodeLaunchModelContext({ ...snapshot, models })).toBeNull() + } + expect( + parseOpenCodeLaunchModelContext({ + ...snapshot, + agents: { location: { directory }, data: [] } + }) + ).toBeNull() + }) +}) + +describe.skipIf(!process.env.ORCA_REAL_OPENCODE_CLI_TEST)( + 'real current OpenCode model preflight', + () => { + it('waits for initialization and verifies a custom primary before and after a private overlay', async () => { + const executable = process.env.ORCA_REAL_OPENCODE_CLI_TEST + if (!executable) { + throw new Error('Real OpenCode executable required') + } + const home = await mkdtemp(join(tmpdir(), 'orca-opencode-model-context-')) + try { + const env: NodeJS.ProcessEnv = {} + for (const [key, value] of Object.entries(process.env)) { + if (!/API_KEY|TOKEN|SECRET|AUTH|CREDENTIAL|OPENCODE_|ORCA_/.test(key)) { + env[key] = value + } + } + for (const [key, child] of [ + ['HOME', 'home'], + ['XDG_CONFIG_HOME', 'config'], + ['XDG_DATA_HOME', 'data'], + ['XDG_STATE_HOME', 'state'], + ['XDG_CACHE_HOME', 'cache'] + ]) { + const path = join(home, child) + await mkdir(path, { mode: 0o700 }) + env[key] = path + } + env.ORCA_BACKGROUND_LAUNCH = '1' + env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ + default_agent: 'reviewer', + model: 'opencode/big-pickle', + agents: { + reviewer: { mode: 'primary', system: 'Review only', model: 'opencode/big-pickle' } + } + }) + const options = { executable, cwd: process.cwd(), env } + const before = await probeOpenCodeLaunchModelContext(options) + expect(before?.primaryAgent).toBe('reviewer') + expect(before?.primaryModel).toBe('opencode/big-pickle') + expect(before?.availableModels).toContain('opencode/fledge-alpha-free') + const config = resolveOpenCodeLaunchModelConfig({ + configContent: env.OPENCODE_CONFIG_CONTENT, + primaryAgent: before?.primaryAgent ?? '', + model: 'opencode/fledge-alpha-free' + }) + expect(config).not.toBeNull() + const after = await probeOpenCodeLaunchModelContext({ + ...options, + env: { ...env, OPENCODE_CONFIG_CONTENT: config ?? '' }, + expectedPrimaryAgent: 'reviewer', + expectedPrimaryModel: 'opencode/fledge-alpha-free' + }) + expect(after?.primaryAgent).toBe('reviewer') + expect(after?.primaryModel).toBe('opencode/fledge-alpha-free') + expect(env.OPENCODE_CONFIG_CONTENT).toContain('opencode/big-pickle') + } finally { + await rm(home, { recursive: true, force: true }) + } + }, 30_000) + } +) diff --git a/src/main/opencode/opencode-launch-model-context.ts b/src/main/opencode/opencode-launch-model-context.ts new file mode 100644 index 00000000000..ce1098d8acf --- /dev/null +++ b/src/main/opencode/opencode-launch-model-context.ts @@ -0,0 +1,206 @@ +import { randomBytes } from 'node:crypto' +import { setTimeout as delay } from 'node:timers/promises' +import { z } from 'zod' +import { spawnProcess } from '../../shared/child-process/run-process' +import { + forceTerminateProcessTree, + signalProcessTree +} from '../../shared/child-process/process-tree-termination' +import { withTimeout } from '../../shared/promise-timeout-fallback' +import { createOutputSink } from '../../shared/child-process/bounded-output-sink' +import { readFetchResponseJsonWithinLimit } from '../../shared/fetch-response-body' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' + +const model = z.object({ id: z.string(), providerID: z.string() }) +const availableModel = model.extend({ enabled: z.boolean() }) +const agent = z.object({ + id: z.string(), + mode: z.enum(['primary', 'subagent', 'all']), + hidden: z.boolean(), + model: model.optional() +}) +const location = z.object({ directory: z.string() }) +const modelResponse = z.object({ location, data: availableModel.array() }) +const agentResponse = z.object({ location, data: agent.array() }) +const defaultModelResponse = z.object({ location, data: availableModel.nullable() }) +const configResponse = z.array( + z.object({ + type: z.enum(['directory', 'document']), + info: z.object({ default_agent: z.string().optional() }).optional() + }) +) + +export type OpenCodeLaunchModelContext = { + primaryAgent: string + availableModels: string[] + primaryModel: string | null +} + +export function parseOpenCodeLaunchModelContext(options: { + directory: string + models: unknown + agents: unknown + defaultModel: unknown + config: unknown +}): OpenCodeLaunchModelContext | null { + const models = modelResponse.safeParse(options.models) + const agents = agentResponse.safeParse(options.agents) + const defaultModel = defaultModelResponse.safeParse(options.defaultModel) + const config = configResponse.safeParse(options.config) + if ( + !models.success || + !agents.success || + !defaultModel.success || + !config.success || + models.data.location.directory !== options.directory || + agents.data.location.directory !== options.directory || + defaultModel.data.location.directory !== options.directory || + !defaultModel.data.data?.enabled || + agents.data.data.length === 0 + ) { + return null + } + // Installed 2.0.16 returns the selected default first; newer source has a different order. + const primary = agents.data.data.find((item) => item.mode !== 'subagent' && !item.hidden) + const configured = config.data.reduce( + (value, entry) => entry.info?.default_agent ?? value, + undefined + ) + const available = models.data.data.filter((item) => item.enabled) + if ( + !primary || + (configured !== undefined && primary.id !== configured) || + !available.some( + (item) => + item.id === defaultModel.data.data?.id && + item.providerID === defaultModel.data.data?.providerID + ) + ) { + return null + } + return { + primaryAgent: primary.id, + availableModels: available.map((item) => `${item.providerID}/${item.id}`), + primaryModel: primary.model ? `${primary.model.providerID}/${primary.model.id}` : null + } +} + +export async function probeOpenCodeLaunchModelContext(options: { + executable: string + cwd: string + env: NodeJS.ProcessEnv + signal?: AbortSignal + expectedPrimaryAgent?: string + expectedPrimaryModel?: string +}): Promise { + const password = randomBytes(32).toString('base64url') + const child = spawnProcess({ + program: options.executable, + args: ['serve', '--hostname', '127.0.0.1', '--port', '0'], + cwd: options.cwd, + env: { + ...options.env, + OPENCODE_SERVER_USERNAME: 'opencode', + OPENCODE_SERVER_PASSWORD: password + }, + detached: true + }) + const output = createOutputSink(4096) + let childClosed = false + let rootExited = false + const closed = new Promise((resolve) => { + child.once('close', () => { + childClosed = true + resolve(true) + }) + }) + let failed = false + child.on('error', () => { + failed = true + }) + child.once('exit', () => { + rootExited = true + failed = true + }) + child.stdout.on('data', output.write) + child.stdout.on('error', () => { + failed = true + }) + child.stderr.on('error', () => { + failed = true + }) + child.stderr.resume() + child.stdin.on('error', () => { + failed = true + }) + child.stdin.end() + const signal = AbortSignal.any([ + AbortSignal.timeout(10_000), + ...(options.signal ? [options.signal] : []) + ]) + let context: OpenCodeLaunchModelContext | null = null + let stopped = false + try { + while (!failed && !signal.aborted && !output.truncated()) { + const address = output + .text() + .match(/^server listening on (http:\/\/127\.0\.0\.1:\d+)\s*$/m)?.[1] + if (address) { + const read = async (endpoint: string): Promise => { + const url = new URL(`/api/${endpoint}`, address) + url.searchParams.set('location[directory]', options.cwd) + const response = await fetch(url, { + headers: { + Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}` + }, + redirect: 'error', + signal + }) + try { + if (!response.ok) { + throw new Error('OpenCode model preflight refused') + } + return await readFetchResponseJsonWithinLimit(response, 1_048_576) + } finally { + await cancelUnreadResponseBody(response) + } + } + const [models, agents, defaultModel, config] = await Promise.all([ + read('model'), + read('agent'), + read('model/default'), + read('config') + ]) + context = parseOpenCodeLaunchModelContext({ + directory: options.cwd, + models, + agents, + defaultModel, + config + }) + if ( + context && + (!options.expectedPrimaryAgent || + context.primaryAgent === options.expectedPrimaryAgent) && + (!options.expectedPrimaryModel || context.primaryModel === options.expectedPrimaryModel) + ) { + break + } + context = null + } + await delay(100, undefined, { signal }) + } + } catch { + context = null + } finally { + if (!childClosed && !rootExited) { + await signalProcessTree(child, 'SIGTERM') + } + stopped = childClosed || (await withTimeout(closed, 1_500, false)) + // A reaped root's PID can be reused while descendants still hold its pipes. + if (!stopped && !rootExited) { + stopped = await forceTerminateProcessTree(child) + } + } + return stopped ? context : null +} diff --git a/src/main/opencode/opencode-model-availability.test.ts b/src/main/opencode/opencode-model-availability.test.ts new file mode 100644 index 00000000000..f8b3fdf552c --- /dev/null +++ b/src/main/opencode/opencode-model-availability.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' +import { + probeOpenCodeModelAvailability, + resolveOpenCodeDirectModelExecutable +} from './opencode-model-availability' +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: vi.fn() })) +const options = { + command: '/private/opencode', + model: 'opencode/fledge-alpha-free', + cwd: '/tmp/project', + env: { OPENCODE_CONFIG_DIR: '/private/config', XDG_DATA_HOME: '/private/account' } +} +describe('OpenCode model catalog validation', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(resolveCommandOnLocalPath).mockResolvedValue('/resolved/opencode') + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + timedOut: false, + stdout: 'opencode/fledge-alpha-free\nopencode/big-pickle\n', + stderr: '', + signal: null + }) + }) + it('checks exact catalog membership using the execution scope', async () => { + expect(await probeOpenCodeModelAvailability(options)).toBe(true) + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ + program: '/resolved/opencode', + args: ['models'], + cwd: options.cwd, + env: options.env + }) + ) + }) + it('resolves the default PATH executable before checking model availability', async () => { + expect(await resolveOpenCodeDirectModelExecutable({ ...options, command: 'opencode' })).toBe( + '/resolved/opencode' + ) + expect(resolveCommandOnLocalPath).toHaveBeenCalledWith('opencode', { + env: options.env, + cwd: options.cwd + }) + }) + it('rejects the real invalid selector that falls back to Big Pickle', async () => { + expect(await probeOpenCodeModelAvailability({ ...options, model: 'gpt-5-nano' })).toBe(false) + }) + it('does not accept substrings or verbose metadata', async () => { + expect(await probeOpenCodeModelAvailability({ ...options, model: 'fledge-alpha-free' })).toBe( + false + ) + }) + it('refuses timed out catalog probes even with matching partial output', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + timedOut: true, + stdout: options.model, + stderr: '', + signal: null + }) + expect(await probeOpenCodeModelAvailability(options)).toBe(false) + }) + it('refuses a truncated catalog even when its retained head contains the model', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + timedOut: false, + outputTruncated: true, + stdout: `${options.model}\n`, + stderr: '', + signal: null + }) + expect(await probeOpenCodeModelAvailability(options)).toBe(false) + }) + it('refuses the unterminated catalog shape captured from OpenCode 2.0.16', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + timedOut: false, + outputTruncated: false, + stdout: `${options.model}\npriv`, + stderr: '', + signal: null + }) + expect(await probeOpenCodeModelAvailability(options)).toBe(false) + }) + it.each(['\n', '\r\n'])('accepts a complete catalog with %j line endings', async (ending) => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + timedOut: false, + outputTruncated: false, + stdout: `${options.model}${ending}`, + stderr: '', + signal: null + }) + expect(await probeOpenCodeModelAvailability(options)).toBe(true) + }) + it('refuses WSL until its exact guest launch environment is available', async () => { + expect(await probeOpenCodeModelAvailability({ ...options, wsl: { distro: 'Ubuntu' } })).toBe( + false + ) + expect(runProcess).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/opencode/opencode-model-availability.ts b/src/main/opencode/opencode-model-availability.ts new file mode 100644 index 00000000000..ce507de9969 --- /dev/null +++ b/src/main/opencode/opencode-model-availability.ts @@ -0,0 +1,59 @@ +import { resolveStartupShell, tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' +import { runProcess } from '../../shared/child-process/run-process' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' + +export async function resolveOpenCodeDirectModelExecutable(options: { + command: string | undefined + model: string + env: NodeJS.ProcessEnv + cwd?: string + wsl?: { distro?: string } +}): Promise { + // WSL needs the same guest account/profile environment as its actual launch. + if (options.wsl || !options.cwd) { + return null + } + const parsed = tokenizeStartupCommand( + options.command ?? '', + resolveStartupShell(process.platform) + ) + if ( + !parsed.ok || + parsed.tokens.length !== 1 || + parsed.spans.some((span) => span.divergesFromShell) + ) { + return null + } + return resolveCommandOnLocalPath(parsed.tokens[0], { + env: options.env, + cwd: options.cwd + }) +} + +export async function probeOpenCodeModelAvailability( + options: Parameters[0] +): Promise { + const executable = await resolveOpenCodeDirectModelExecutable(options) + if (!executable) { + return false + } + try { + const result = await runProcess({ + program: executable, + args: ['models'], + cwd: options.cwd, + env: options.env, + timeoutMs: 10_000, + maxOutputBytes: 1_048_576 + }) + return ( + result.code === 0 && + !result.timedOut && + !result.outputTruncated && + result.stdout.endsWith('\n') && + result.stdout.split(/\r?\n/).some((line) => line === options.model) + ) + } catch { + return false + } +} diff --git a/src/main/opencode/opencode-model-startup-plan.test.ts b/src/main/opencode/opencode-model-startup-plan.test.ts new file mode 100644 index 00000000000..46771e630bc --- /dev/null +++ b/src/main/opencode/opencode-model-startup-plan.test.ts @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' +import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' +import { + probeOpenCodeModelAvailability, + resolveOpenCodeDirectModelExecutable +} from './opencode-model-availability' +import { probeOpenCodeLaunchModelContext } from './opencode-launch-model-context' +import { buildExecutionHostAgentStartupPlan } from './opencode-model-startup-plan' + +vi.mock('../managed-data-accounts/launch-environment', () => ({ + applyManagedDataAccountEnvironment: vi.fn() +})) +vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: vi.fn() })) +vi.mock('./opencode-model-availability', () => ({ + resolveOpenCodeDirectModelExecutable: vi.fn(), + probeOpenCodeModelAvailability: vi.fn() +})) +vi.mock('./opencode-launch-model-context', () => ({ probeOpenCodeLaunchModelContext: vi.fn() })) + +const preferred = 'opencode/fledge-alpha-free' +const original = JSON.stringify({ + default_agent: 'reviewer', + providers: { custom: { apiKey: 'placeholder' } }, + agents: { reviewer: { system: 'Review only', model: 'opencode/big-pickle' } } +}) +function scope() { + return { + inputs: resolveAgentStartupPlanInputs({ + agent: 'opencode', + settings: { + agentCmdOverrides: {}, + agentDefaultEnv: { opencode: { OPENCODE_CONFIG_CONTENT: original } } + }, + platform: process.platform, + isRemote: false, + sessionOptions: { model: preferred } + }), + cwd: '/private/project', + prompt: 'Read only', + hostIdentity: 'host' + } +} + +describe('execution-host OpenCode model startup', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.mocked(resolveOpenCodeDirectModelExecutable).mockResolvedValue('/private/opencode') + vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ + version: '2.0.16', + pluginApi: 'v2', + promptMode: 'prefill' + }) + vi.mocked(probeOpenCodeModelAvailability).mockResolvedValue(true) + vi.mocked(probeOpenCodeLaunchModelContext) + .mockResolvedValueOnce({ + primaryAgent: 'reviewer', + availableModels: [preferred], + primaryModel: 'opencode/big-pickle' + }) + .mockResolvedValue({ + primaryAgent: 'reviewer', + availableModels: [preferred], + primaryModel: preferred + }) + }) + + it('refuses unsupported effort preferences before probing or dropping them', async () => { + const options = scope() + options.inputs.sessionOptions = { model: preferred, effort: 'high' } + await expect(buildExecutionHostAgentStartupPlan(options)).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + expect(probeOpenCodeLaunchModelContext).not.toHaveBeenCalled() + expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() + }) + + it('launches current v2 in isolation and keeps one-time model preferences out of resume state', async () => { + const options = scope() + const plan = await buildExecutionHostAgentStartupPlan(options) + expect(plan?.launchCommand).toContain('--standalone') + expect(plan?.launchCommand).not.toContain('--model') + expect(plan?.sessionOptions).toEqual({ model: preferred }) + expect(JSON.parse(plan?.env?.OPENCODE_CONFIG_CONTENT ?? 'null')).toEqual({ + default_agent: 'reviewer', + model: preferred, + providers: { custom: { apiKey: 'placeholder' } }, + agents: { reviewer: { system: 'Review only', model: preferred } } + }) + expect(plan?.launchConfig).toEqual({ + agentCommand: 'opencode', + agentArgs: '', + agentEnv: { OPENCODE_CONFIG_CONTENT: original } + }) + expect(options.inputs.agentEnv.OPENCODE_CONFIG_CONTENT).toBe(original) + }) + + it('uses the selected account environment in both authoritative probes', async () => { + vi.mocked(applyManagedDataAccountEnvironment).mockImplementation((env) => { + env.XDG_DATA_HOME = '/selected/data' + env.OPENCODE_DB = 'opencode.db' + }) + await buildExecutionHostAgentStartupPlan(scope()) + for (const call of vi.mocked(probeOpenCodeLaunchModelContext).mock.calls) { + expect(call[0].env).toMatchObject({ + XDG_DATA_HOME: '/selected/data', + OPENCODE_DB: 'opencode.db' + }) + } + }) + + it.each(['1.18.30', '1.18.32'])( + 'preserves the verified legacy launch route %s', + async (version) => { + vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ + version, + pluginApi: 'v1', + promptMode: 'submit' + }) + expect((await buildExecutionHostAgentStartupPlan(scope()))?.launchCommand).toContain( + '--model' + ) + expect(probeOpenCodeLaunchModelContext).not.toHaveBeenCalled() + } + ) + + it('preserves ordinary launches without any private server probe', async () => { + const options = scope() + options.inputs.sessionOptions = undefined + expect((await buildExecutionHostAgentStartupPlan(options))?.launchCommand).not.toContain( + '--standalone' + ) + expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() + }) + + it.each(['--agent build', '--continue', '--server http://localhost'])( + 'refuses current-v2 manual launch arguments %s', + async (agentArgs) => { + const options = scope() + options.inputs.agentArgs = agentArgs + await expect(buildExecutionHostAgentStartupPlan(options)).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + expect(probeOpenCodeLaunchModelContext).not.toHaveBeenCalled() + } + ) + + it('refuses unavailable models and mismatched observed model preferences', async () => { + vi.mocked(probeOpenCodeLaunchModelContext) + .mockReset() + .mockResolvedValue({ primaryAgent: 'reviewer', availableModels: [], primaryModel: null }) + await expect(buildExecutionHostAgentStartupPlan(scope())).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + vi.mocked(probeOpenCodeLaunchModelContext) + .mockReset() + .mockResolvedValue({ + primaryAgent: 'reviewer', + availableModels: [preferred], + primaryModel: 'opencode/big-pickle' + }) + await expect(buildExecutionHostAgentStartupPlan(scope())).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + }) + + it('refuses remote and WSL execution before touching the local binary', async () => { + const options = scope() + options.inputs.isRemote = true + await expect(buildExecutionHostAgentStartupPlan(options)).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + options.inputs.isRemote = false + await expect( + buildExecutionHostAgentStartupPlan({ ...options, isWsl: true }) + ).rejects.toMatchObject({ code: 'capability_unsupported' }) + expect(resolveOpenCodeDirectModelExecutable).not.toHaveBeenCalled() + }) + + it.each(['1.18.31', '1.18.33', '2.0.17'])( + 'refuses unverified version %s before model probing', + async (version) => { + vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ + version, + pluginApi: 'v2', + promptMode: 'prefill' + }) + await expect(buildExecutionHostAgentStartupPlan(scope())).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + expect(probeOpenCodeLaunchModelContext).not.toHaveBeenCalled() + expect(probeOpenCodeModelAvailability).not.toHaveBeenCalled() + } + ) + + it('resolves the default bare launch command through the execution host', async () => { + await buildExecutionHostAgentStartupPlan(scope()) + expect(resolveOpenCodeDirectModelExecutable).toHaveBeenCalledWith( + expect.objectContaining({ command: 'opencode' }) + ) + }) + + it.each(['blank-model', 'effort-only'])( + 'refuses invalid preferences before probing', + async (scenario) => { + const options = scope() + options.inputs.sessionOptions = + scenario === 'blank-model' ? { model: '' } : { effort: 'high' } + await expect(buildExecutionHostAgentStartupPlan(options)).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + expect(resolveOpenCodeDirectModelExecutable).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/opencode/opencode-model-startup-plan.ts b/src/main/opencode/opencode-model-startup-plan.ts new file mode 100644 index 00000000000..f0878bd5177 --- /dev/null +++ b/src/main/opencode/opencode-model-startup-plan.ts @@ -0,0 +1,175 @@ +import type { AgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' +import { buildSleepingAgentLaunchConfig } from '../../shared/sleeping-agent-launch-config' +import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' +import type { TuiAgent } from '../../shared/tui-agent' +import { probeOpenCodeLaunchModelContext } from './opencode-launch-model-context' +import { resolveOpenCodeLaunchModelConfig } from './opencode-launch-model-config' +import { isVerifiedOpenCodeLegacyModelVersion } from './opencode-model-version-policy' +import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' +import { OrchestrationError } from '../runtime/orchestration/orchestration-error' +import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' +import { + probeOpenCodeModelAvailability, + resolveOpenCodeDirectModelExecutable +} from './opencode-model-availability' + +type StartupScope = { + inputs: AgentStartupPlanInputs + cwd: string + isWsl?: boolean + hostIdentity?: string + signal?: AbortSignal +} + +function refuseModel(): never { + throw new OrchestrationError( + 'capability_unsupported', + 'The execution host cannot verify this OpenCode model launch.' + ) +} + +export async function prepareOpenCodeModelStartupInputs( + options: StartupScope +): Promise<{ inputs: AgentStartupPlanInputs; launchConfig?: SleepingAgentLaunchConfig }> { + const { inputs } = options + const model = inputs.sessionOptions?.model + if ( + inputs.agent !== 'opencode' || + !Object.values(inputs.sessionOptions ?? {}).some((value) => value !== undefined) + ) { + return { inputs } + } + if (typeof model !== 'string' || model.trim().length === 0) { + refuseModel() + } + if ( + Object.entries(inputs.sessionOptions ?? {}).some( + ([key, value]) => key !== 'model' && value !== undefined + ) || + inputs.isRemote || + options.isWsl || + inputs.platform !== process.platform || + options.signal?.aborted + ) { + refuseModel() + } + const command = + inputs.cmdOverrides.opencode || + getTuiAgentLaunchCommand(TUI_AGENT_CONFIG.opencode, inputs.platform) + const env: Record = {} + for (const [key, value] of Object.entries({ ...process.env, ...inputs.agentEnv })) { + if (value !== undefined) { + env[key] = value + } + } + applyManagedDataAccountEnvironment(env, { launchAgent: 'opencode' }) + const executable = await resolveOpenCodeDirectModelExecutable({ + command, + model, + cwd: options.cwd, + env + }) + if (!executable) { + refuseModel() + } + const capabilities = await probeOpenCodeLaunchCapabilities({ + command, + agent: 'opencode', + cwd: options.cwd, + env, + hostIdentity: options.hostIdentity + }) + if (isVerifiedOpenCodeLegacyModelVersion(capabilities?.version)) { + if (!(await probeOpenCodeModelAvailability({ command, model, cwd: options.cwd, env }))) { + refuseModel() + } + return { inputs } + } + if (capabilities?.version !== '2.0.16' || inputs.agentArgs?.trim()) { + refuseModel() + } + const before = await probeOpenCodeLaunchModelContext({ + executable, + cwd: options.cwd, + env, + signal: options.signal + }).catch(() => null) + if (!before?.availableModels.includes(model)) { + refuseModel() + } + const configContent = resolveOpenCodeLaunchModelConfig({ + configContent: env.OPENCODE_CONFIG_CONTENT, + primaryAgent: before.primaryAgent, + model + }) + if (configContent === null) { + refuseModel() + } + const after = await probeOpenCodeLaunchModelContext({ + executable, + cwd: options.cwd, + env: { ...env, OPENCODE_CONFIG_CONTENT: configContent }, + expectedPrimaryAgent: before.primaryAgent, + expectedPrimaryModel: model, + signal: options.signal + }).catch(() => null) + if ( + after?.primaryAgent !== before.primaryAgent || + after.primaryModel !== model || + !after.availableModels.includes(model) + ) { + refuseModel() + } + return { + inputs: { + ...inputs, + agentArgs: '--standalone', + agentEnv: { ...inputs.agentEnv, OPENCODE_CONFIG_CONTENT: configContent }, + sessionOptions: undefined + }, + launchConfig: buildSleepingAgentLaunchConfig({ + agentCommand: command, + agentArgs: inputs.agentArgs, + agentEnv: inputs.agentEnv + }) + } +} + +export async function buildExecutionHostAgentStartupPlan( + options: StartupScope & { + prompt: string + promptDelivery?: 'auto-submit' | 'draft' + } +) { + const prepared = await prepareOpenCodeModelStartupInputs(options) + if (prepared.launchConfig && options.promptDelivery === 'draft') { + refuseModel() + } + const plan = + options.promptDelivery === 'draft' + ? buildAgentDraftLaunchPlan({ ...prepared.inputs, draft: options.prompt }) + : buildAgentStartupPlan({ + ...prepared.inputs, + prompt: options.prompt, + allowEmptyPromptLaunch: true + }) + if (plan && prepared.launchConfig) { + plan.launchConfig = prepared.launchConfig + plan.sessionOptions = { ...options.inputs.sessionOptions } + } + return plan +} + +export function assertOpenCodeModelLaunchPreferencesAbsent( + agent: TuiAgent | undefined, + preferences: Readonly> | undefined +): void { + if ( + agent === 'opencode' && + Object.values(preferences ?? {}).some((value) => value !== undefined) + ) { + refuseModel() + } +} diff --git a/src/main/opencode/opencode-model-version-policy.test.ts b/src/main/opencode/opencode-model-version-policy.test.ts new file mode 100644 index 00000000000..8c8c4f7247e --- /dev/null +++ b/src/main/opencode/opencode-model-version-policy.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { isVerifiedOpenCodeLegacyModelVersion } from './opencode-model-version-policy' + +describe('verified legacy OpenCode model versions', () => { + it.each(['1.18.30', '1.18.32'])('supports the verified model semantics of %s', (version) => { + expect(isVerifiedOpenCodeLegacyModelVersion(version)).toBe(true) + }) + + it.each([null, undefined, '1.18.29', '1.18.31', '1.18.33', '1.18.32-beta', '2.0.16'])( + 'keeps unverified version %s unsupported', + (version) => { + expect(isVerifiedOpenCodeLegacyModelVersion(version)).toBe(false) + } + ) +}) diff --git a/src/main/opencode/opencode-model-version-policy.ts b/src/main/opencode/opencode-model-version-policy.ts new file mode 100644 index 00000000000..e741a50f89e --- /dev/null +++ b/src/main/opencode/opencode-model-version-policy.ts @@ -0,0 +1,6 @@ +const VERIFIED_LEGACY_MODEL_VERSIONS = new Set(['1.18.30', '1.18.32']) + +export function isVerifiedOpenCodeLegacyModelVersion(version: string | null | undefined): boolean { + // Plugin API compatibility alone does not verify model selection or invalid-model behavior. + return typeof version === 'string' && VERIFIED_LEGACY_MODEL_VERSIONS.has(version) +} diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts index 854882d287f..21ec4ee0936 100644 --- a/src/main/opencode/opencode-pty-launch.test.ts +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -91,32 +91,40 @@ describe('execution-host OpenCode launch preparation', () => { 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' + it.each(['2.0.12', '2.0.16'])( + 'removes only the automatic %s prompt argument, retaining explicit run and manual flags', + async (version) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities(version)) + 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'") } - 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) => { @@ -178,9 +186,16 @@ describe('execution-host OpenCode launch preparation', () => { 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) => { + it.each([ + { version: '1.1.23', nativeIntent: false }, + { version: '2.0.12', nativeIntent: true }, + { version: '2.0.16', nativeIntent: true }, + { version: '2.0.17', nativeIntent: false }, + { version: '2.0.21', nativeIntent: false }, + { version: 'unknown', nativeIntent: false } + ])( + 'gates native intent against the executing $version capability', + async ({ version, nativeIntent }) => { probe.mockResolvedValue(getOpenCodeCliCapabilities(version)) const envToDelete: string[] = [] const fingerprint = createHash('sha256').update('task').digest('hex') @@ -195,10 +210,8 @@ describe('execution-host OpenCode launch preparation', () => { 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') + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBe(nativeIntent ? fingerprint : undefined) + expect(envToDelete.includes(OPENCODE_STARTUP_PROMPT_SHA256_ENV)).toBe(!nativeIntent) } ) @@ -266,7 +279,7 @@ describe('execution-host OpenCode launch preparation', () => { expect(finalEnv).not.toHaveProperty('KEEP_DELETED') }) - it.each(['1.1.23', '2.0.16'])( + it.each(['1.1.23', '2.0.12', '2.0.16'])( 'selects the probed %s plugin for the execution host', async (version) => { const capabilities = getOpenCodeCliCapabilities(version) diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts index 2e505ee2a8f..ec241a35a6b 100644 --- a/src/main/opencode/opencode-startup-prompt-source.test.ts +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -42,7 +42,7 @@ class Editor extends EventEmitter { type FixtureLocation = { directory: string; workspaceID?: string } -function fixture() { +function fixture(version = '2.0.16') { const editor = new Editor() const input = new EventEmitter() const memory = { settled: false, expiresAt: Date.now() + 20000 } @@ -52,7 +52,7 @@ function fixture() { const sync = vi.fn(async (_location: FixtureLocation) => {}) const dispatch = vi.fn(() => editor.replace('')) const ctx = { - app: { version: '2.0.16' }, + app: { version }, renderer: { keyInput: input, currentFocusedEditor: editor }, storage: { memory: () => [memory, (mutate: (draft: typeof memory) => void) => mutate(memory)] }, keymap: { dispatch }, @@ -90,6 +90,41 @@ afterEach(() => { }) describe('installed-version native prompt intent plugin', () => { + it.each(['2.0.12', '2.0.16'])( + 'hydrates the source-reviewed %s location and delivers the same intent only once', + async (version) => { + const f = fixture(version) + let hydrate = () => {} + f.sync.mockImplementation( + () => + new Promise((resolve) => { + hydrate = resolve + }) + ) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(500) + expect(f.sync).toHaveBeenCalledExactlyOnceWith(f.ctx.location) + expect(claim).not.toHaveBeenCalled() + expect(insert).not.toHaveBeenCalled() + hydrate() + await vi.advanceTimersByTimeAsync(1000) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + expect(insert).toHaveBeenCalledExactlyOnceWith(prompt) + expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit') + expect(f.memory.settled).toBe(true) + const reloadDispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await reloadDispose() + } finally { + await dispose() + } + } + ) + it('waits for the current home location after the startup directory changes', async () => { const f = fixture() let location: FixtureLocation = { directory: '/private/home/private-folder' } diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts index 4bb37861102..94e08869d6d 100644 --- a/src/main/opencode/opencode-startup-prompt-source.ts +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -1,3 +1,4 @@ +import { OPENCODE_NATIVE_PROMPT_VERSIONS } from '../../shared/opencode-cli-version' import { cancelUnreadResponseBody } from '../lib/unread-response-body' import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' import { @@ -40,7 +41,7 @@ async function submitStartupPrompt(ctx) { 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; + if (!${JSON.stringify(OPENCODE_NATIVE_PROMPT_VERSIONS)}.includes(ctx?.app?.version) || !/^[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" || diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index c90544f7502..c75d238dee8 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -4,6 +4,8 @@ import { isTerminalLeafId } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { rollbackFailedPtyBinding } from './pty-binding-write-rollback' import { cloneWorkspaceSessionState } from '../restoring-sessions/session-owner-fields' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from '../restoring-sessions/workspace-session-write-rollback' +import { clearReplacedPaneBinding } from './replaced-pane-binding' import type { PtyBindingSourceExpectation } from './store' @@ -67,6 +69,59 @@ export class PtyBindingPersistenceOperations { this[ptyBindingPersistenceOperationsContext] = { runtime, sessions } } + /** Clears a stopped process's binding, keeping the pane; fenced on the id, which is dead in any incarnation. */ + async retirePtyBinding( + binding: Pick, + hostId?: string | null + ): Promise { + const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext] + const resolved = resolveHostId(hostId) + const publish = (session: WorkspaceSessionState): void => { + if (resolved === LOCAL_EXECUTION_HOST_ID) { + runtime.state.workspaceSession = session + } else { + runtime.state.workspaceSessionsByHostId = { + ...runtime.state.workspaceSessionsByHostId, + [resolved]: session + } + } + runtime.dirtyProfileStateDomains?.add( + resolved === LOCAL_EXECUTION_HOST_ID ? 'workspaceSession' : 'workspaceSessionsByHostId' + ) + } + return runtime.runDurableMutation(() => { + const session = sessions.getWorkspaceSession(resolved) + const currentId = + session.terminalLayoutsByTabId[binding.tabId]?.ptyIdsByLeafId?.[binding.leafId] + if (!currentId) { + return { value: true, persist: 'if-dirty' } + } + if (currentId !== binding.ptyId) { + return { value: false, persist: false } + } + if (!session.tabsByWorktree[binding.worktreeId]?.some((tab) => tab.id === binding.tabId)) { + return { value: false, persist: false } + } + const before = cloneWorkspaceSessionState(session) + const retired = clearReplacedPaneBinding(session, { ...binding, parentTabId: binding.tabId }) + // Host retirement must not run renderer snapshot repair, which would put the old binding back. + publish(retired) + const staged = cloneWorkspaceSessionState(retired) + return { + value: true, + rollback: () => { + publish( + rollbackWorkspaceSessionAfterFailedAsyncWrite( + before, + staged, + sessions.getWorkspaceSession(resolved) + ) + ) + } + } + }) + } + async persistPtyBinding( input: PersistPtyBindingArgs | (() => PersistPtyBindingArgs | null), hostId?: string | null diff --git a/src/main/persistence/loading-store/pty-binding-restart-retirement.test.ts b/src/main/persistence/loading-store/pty-binding-restart-retirement.test.ts new file mode 100644 index 00000000000..2f34af1fb0d --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-restart-retirement.test.ts @@ -0,0 +1,83 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { TEST_LEAF_1 } from '../../persistence-session-fixtures' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'restart-tab', + leafId: TEST_LEAF_1, + ptyId: 'old', + incarnationId: 'old-incarnation' +} + +it.each([undefined, 'ssh:restart'])( + 'durably retires only the process binding on %s', + async (hostId) => { + const { store, readState } = await fixture() + await store.persistPtyBinding(binding, hostId) + const before = structuredClone(store.getWorkspaceSession(hostId)) + expect(await store.retirePtyBinding(binding, hostId)).toBe(true) + const retired = store.getWorkspaceSession(hostId) + expect(retired.terminalLayoutsByTabId[binding.tabId]).toEqual({ + ...before.terminalLayoutsByTabId[binding.tabId], + ptyIdsByLeafId: {} + }) + expect(retired.tabsByWorktree[binding.worktreeId]).toEqual( + before.tabsByWorktree[binding.worktreeId].map((tab) => + tab.id === binding.tabId ? { ...tab, ptyId: null } : tab + ) + ) + const durable = hostId + ? readState().workspaceSessionsByHostId?.[hostId] + : readState().workspaceSession + expect(durable?.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({}) + await store.persistPtyBinding( + { ...binding, ptyId: 'new', incarnationId: 'new-incarnation' }, + hostId + ) + expect(await store.retirePtyBinding(binding, hostId)).toBe(false) + expect( + store.getWorkspaceSession(hostId).terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId?.[ + binding.leafId + ] + ).toBe('new') + } +) + +it('clears the stopped id whatever incarnation the binding carries', async () => { + const { store } = await fixture() + await store.persistPtyBinding({ ...binding, incarnationId: 'republished-incarnation' }) + expect(await store.retirePtyBinding(binding)).toBe(true) + expect(store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual( + {} + ) +}) + +it('restores the old binding after a known save failure and allows another attempt', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding) + const gate = authority.pause() + const rejected = expect(store.retirePtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId?.[ + binding.leafId + ] + ).toBe('old') + expect(await store.retirePtyBinding(binding)).toBe(true) +}) diff --git a/src/main/persistence/loading-store/replaced-pane-binding.ts b/src/main/persistence/loading-store/replaced-pane-binding.ts new file mode 100644 index 00000000000..5cefc332677 --- /dev/null +++ b/src/main/persistence/loading-store/replaced-pane-binding.ts @@ -0,0 +1,33 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { RetiredTerminalSurface } from '../../runtime/mobile-session-terminal-retirement' + +/** The caller fences the old owner; a restart keeps the pane and removes only its process binding. */ +export function clearReplacedPaneBinding( + session: WorkspaceSessionState, + surface: RetiredTerminalSurface +): WorkspaceSessionState { + const layout = session.terminalLayoutsByTabId[surface.parentTabId] + if (!layout || layout.ptyIdsByLeafId?.[surface.leafId] !== surface.ptyId) { + return session + } + const ptyIdsByLeafId = { ...layout.ptyIdsByLeafId } + delete ptyIdsByLeafId[surface.leafId] + const terminalPtyIncarnationsByPaneKey = { ...session.terminalPtyIncarnationsByPaneKey } + delete terminalPtyIncarnationsByPaneKey[`${surface.parentTabId}:${surface.leafId}`] + return { + ...session, + terminalPtyIncarnationsByPaneKey, + terminalLayoutsByTabId: { + ...session.terminalLayoutsByTabId, + [surface.parentTabId]: { ...layout, ptyIdsByLeafId } + }, + tabsByWorktree: { + ...session.tabsByWorktree, + [surface.worktreeId]: (session.tabsByWorktree[surface.worktreeId] ?? []).map((tab) => + tab.id === surface.parentTabId && tab.ptyId === surface.ptyId + ? { ...tab, ptyId: null } + : tab + ) + } + } +} diff --git a/src/main/preflight/agent-command-detection.ts b/src/main/preflight/agent-command-detection.ts new file mode 100644 index 00000000000..3712036337c --- /dev/null +++ b/src/main/preflight/agent-command-detection.ts @@ -0,0 +1,48 @@ +import { z } from 'zod' +import { getActiveMultiplexer } from '../ssh/ssh-target-registry' +import { detectWslCommandsOnPath } from '../ipc/preflight-wsl-agent-detection' +import { detectCommandsInInstallDirs } from '../ipc/local-agent-install-dir-detection' +import { + getPreflightWslTarget, + type PreflightRuntimeContext +} from '../ipc/preflight-runtime-target' +import { isCommandOnPath } from '../ipc/preflight-command-exec' + +export async function detectAgentCommandsOnHost( + commands: readonly string[], + options: { connectionId?: string | null; context?: PreflightRuntimeContext } = {} +): Promise> { + if (options.connectionId) { + const mux = getActiveMultiplexer(options.connectionId) + if (!mux || mux.isDisposed()) { + throw new Error('Agent command resolution requires the execution host connection.') + } + const result = z.object({ agents: z.array(z.string()) }).parse( + await mux.request('preflight.detectAgents', { + commands: commands.map((cmd) => ({ id: cmd, cmd })) + }) + ) + return new Set(result.agents.filter((cmd) => commands.includes(cmd))) + } + const context = options.context + const wslTarget = getPreflightWslTarget(context) + if (wslTarget) { + return detectWslCommandsOnPath(wslTarget, commands) + } + const pathChecks = await Promise.all( + commands.map(async (cmd) => ({ + cmd, + installedOnPath: await isCommandOnPath(cmd) + })) + ) + const missedCommands = pathChecks.filter((check) => !check.installedOnPath).map(({ cmd }) => cmd) + // Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution + // computes user install dirs once instead of blocking once per missed CLI. + const installDirCommands = detectCommandsInInstallDirs(missedCommands) + const foundCommands = new Set( + pathChecks + .filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd)) + .map(({ cmd }) => cmd) + ) + return foundCommands +} diff --git a/src/main/preflight/agent-detection.ts b/src/main/preflight/agent-detection.ts index c06f9ee7f12..92484812c3b 100644 --- a/src/main/preflight/agent-detection.ts +++ b/src/main/preflight/agent-detection.ts @@ -16,11 +16,7 @@ import { getGiteaAuthStatus } from '../gitea/client' import { _resetKnownHostsCache } from '../gitlab/gl-utils' import { mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' import { getActiveMultiplexer } from '../ssh/ssh-target-registry' -import { - detectWslCommandsOnPath, - type WslPreflightTarget -} from '../ipc/preflight-wsl-agent-detection' -import { detectCommandsInInstallDirs } from '../ipc/local-agent-install-dir-detection' +import type { WslPreflightTarget } from '../ipc/preflight-wsl-agent-detection' import { getPreflightWslTarget, type PreflightRuntimeContext @@ -33,7 +29,6 @@ import { execLocalPreflightCommandOrThrow, findRunnableLocalCommand, isCommandAvailable, - isCommandOnPath, shellQuote } from '../ipc/preflight-command-exec' import { @@ -47,6 +42,8 @@ import { } from '../ipc/tui-agent-detection-commands' import { invalidateWslGuestEnvironment } from '../wsl/wsl-guest-environment' import { prunePreflightWslCache } from '../preflight-wsl-cache' +import { detectAgentCommandsOnHost } from './agent-command-detection' +export { detectAgentCommandsOnHost } from './agent-command-detection' export type PreflightStatus = { git: { installed: boolean } @@ -148,38 +145,14 @@ async function detectCommandRuntime( } export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise { - const wslTarget = getPreflightWslTarget(context) - if (wslTarget) { - const foundCommands = await detectWslCommandsOnPath( - wslTarget, - getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, 'wsl') - ) - return resolveDetectedTuiAgentIds(KNOWN_TUI_AGENT_DETECTION_COMMANDS, foundCommands, 'wsl') - } - - const probeCommands = getTuiAgentDetectionProbeCommands( + const commands = getTuiAgentDetectionProbeCommands( KNOWN_TUI_AGENT_DETECTION_COMMANDS, - process.platform - ) - const pathChecks = await Promise.all( - probeCommands.map(async (cmd) => ({ - cmd, - installedOnPath: await isCommandOnPath(cmd) - })) - ) - const missedCommands = pathChecks.filter((check) => !check.installedOnPath).map(({ cmd }) => cmd) - // Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution - // computes user install dirs once instead of blocking once per missed CLI. - const installDirCommands = detectCommandsInInstallDirs(missedCommands) - const foundCommands = new Set( - pathChecks - .filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd)) - .map(({ cmd }) => cmd) + getPreflightWslTarget(context) ? 'wsl' : process.platform ) return resolveDetectedTuiAgentIds( KNOWN_TUI_AGENT_DETECTION_COMMANDS, - foundCommands, - process.platform + await detectAgentCommandsOnHost(commands, { context }), + getPreflightWslTarget(context) ? 'wsl' : process.platform ) } 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/agent-foreground-process-remote-evidence.test.ts b/src/main/providers/agent-foreground-process-remote-evidence.test.ts index 274a24271b7..2e28a9c6107 100644 --- a/src/main/providers/agent-foreground-process-remote-evidence.test.ts +++ b/src/main/providers/agent-foreground-process-remote-evidence.test.ts @@ -75,6 +75,71 @@ describe('host-stamped remote foreground resolver', () => { expect(evidence).toMatchObject({ verdict: 'unverifiable', reason }) }) + it.each(['?', '??', '-', '0', '0,0'])( + 'keeps the OpenCode TUI live beside a detached service with tty %s', + (tty) => { + const rows = rowsFor(['opencode'], { tty: 'ttys096' }) + rows.push({ + pid: 102, + ppid: 101, + pgid: 102, + tpgid: 0, + tty, + startTime: 'service-start', + stat: 'Ss', + command: 'opencode serve --hostname 127.0.0.1 --port 59374' + }) + expect( + resolveRemoteForegroundEvidence( + { rootPid: 100, fallbackProcess: 'zsh' }, + { ...metadata, platform: 'darwin' }, + rows + ) + ).toMatchObject({ + verdict: 'live', + processName: 'opencode', + fence: { process: { pid: 101, startTime: 'candidate-0' }, tty: 'ttys096' } + }) + } + ) + + it.each(['?', '??', '-', '0', '0,0'])( + 'refuses a foreground-group member whose controlling tty is %s', + (tty) => { + const rows = rowsFor(['opencode']) + rows[1].tty = tty + expect( + resolveRemoteForegroundEvidence({ rootPid: 100, fallbackProcess: 'zsh' }, metadata, rows) + ).toMatchObject({ verdict: 'unverifiable', reason: 'fence_incomplete' }) + } + ) + + it.each(['?', '??', '-', '0', '0,0'])('refuses a root without a controlling tty: %s', (tty) => { + const rows = rowsFor(['opencode'], { tty }) + expect( + resolveRemoteForegroundEvidence({ rootPid: 100, fallbackProcess: 'zsh' }, metadata, rows) + ).toMatchObject({ verdict: 'unverifiable', reason: 'fence_incomplete' }) + }) + + it('refuses a descendant attached to another real terminal', () => { + const rows = rowsFor(['opencode', '/bin/bash']) + rows[2].pgid = 102 + rows[2].tty = '/dev/pts/3' + expect( + resolveRemoteForegroundEvidence({ rootPid: 100, fallbackProcess: 'zsh' }, metadata, rows) + ).toMatchObject({ verdict: 'unverifiable', reason: 'tty_boundary' }) + }) + + it('still refuses a detached multiplexer without a session fence', () => { + const rows = rowsFor(['opencode', 'tmux new-session']) + rows[2].pgid = 102 + rows[2].tpgid = 0 + rows[2].tty = '??' + expect( + resolveRemoteForegroundEvidence({ rootPid: 100, fallbackProcess: 'zsh' }, metadata, rows) + ).toMatchObject({ verdict: 'unverifiable', reason: 'multiplexer_boundary' }) + }) + it('always degrades SSH-to-Windows without a job/console foreground primitive', () => { expect( resolveRemoteForegroundEvidence( diff --git a/src/main/providers/agent-foreground-process-remote-evidence.ts b/src/main/providers/agent-foreground-process-remote-evidence.ts index 90c5e794fbd..fd34cc19731 100644 --- a/src/main/providers/agent-foreground-process-remote-evidence.ts +++ b/src/main/providers/agent-foreground-process-remote-evidence.ts @@ -1,3 +1,4 @@ +import { hasControllingTty } from '../../shared/posix-controlling-terminal' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import type { PosixFence, @@ -58,7 +59,7 @@ export function resolveRemoteForegroundEvidenceFromRows( if (!root || root.pgid === undefined || root.tpgid === undefined) { return { ...metadata, verdict: 'unverifiable', reason: 'anchor_missing' } } - if (!root.tty || root.tty === '?' || root.tpgid <= 0 || !root.startTime) { + if (!hasControllingTty(root.tty) || root.tpgid <= 0 || !root.startTime) { return { ...metadata, verdict: 'unverifiable', reason: 'fence_incomplete' } } const index = buildProcessTableIndex(rows) @@ -73,7 +74,7 @@ export function resolveRemoteForegroundEvidenceFromRows( const descendants = collectDescendantRows(index, root.pid) // A child that owns another terminal/session is outside this PTY's // authority. Do not silently treat it as an idle shell. - if (descendants.some((row) => row.tty !== undefined && row.tty !== '?' && row.tty !== root.tty)) { + if (descendants.some((row) => hasControllingTty(row.tty) && row.tty !== root.tty)) { return { ...metadata, verdict: 'unverifiable', reason: 'tty_boundary' } } // Multiplexers can make a descendant appear foreground while the user is @@ -82,9 +83,7 @@ export function resolveRemoteForegroundEvidenceFromRows( if ([root, ...descendants].some((row) => /(?:^|\s)(?:tmux|screen)(?:\s|$)/i.test(row.command))) { return { ...metadata, verdict: 'unverifiable', reason: 'multiplexer_boundary' } } - if ( - descendants.some((row) => row.pgid === root.tpgid && (row.tty === undefined || row.tty === '?')) - ) { + if (descendants.some((row) => row.pgid === root.tpgid && !hasControllingTty(row.tty))) { return { ...metadata, verdict: 'unverifiable', reason: 'fence_incomplete' } } const foreground = descendants.filter((row) => row.pgid === root.tpgid && row.tty === root.tty) diff --git a/src/main/proxy-guarded-fetch-call-site-audit.test.ts b/src/main/proxy-guarded-fetch-call-site-audit.test.ts index b92b266f427..53d22bad37f 100644 --- a/src/main/proxy-guarded-fetch-call-site-audit.test.ts +++ b/src/main/proxy-guarded-fetch-call-site-audit.test.ts @@ -14,7 +14,7 @@ import { describe, expect, it } from 'vitest' // means a call site was added, removed, or moved: re-audit the file and update the count. const AUDITED_NON_NET_FETCH_CALLS = new Map([ // Isolated cookie-jar session, proxied by createOpenCodeRequestSession before any request. - ['main/rate-limits/opencode-go-usage-fetcher.ts', 2], + ['main/rate-limits/opencode-go-usage-fetcher.ts', 3], // Isolated cookie-jar session that does NOT apply the proxy — a pre-existing gap, not a // regression: no proxy has ever reached this partition. Keep it listed so it stays visible. ['main/rate-limits/minimax/minimax-request-context.ts', 2], diff --git a/src/main/pty/posix-pty-process-groups.ts b/src/main/pty/posix-pty-process-groups.ts index 1f7f05f73a1..2607875b269 100644 --- a/src/main/pty/posix-pty-process-groups.ts +++ b/src/main/pty/posix-pty-process-groups.ts @@ -1,3 +1,4 @@ +import { hasControllingTty } from '../../shared/posix-controlling-terminal' import { recordSelfInitiatedTreeKill } from '../crash-reporting/self-initiated-tree-kill-log' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { @@ -68,10 +69,6 @@ function readSelectionResult(result: ProcessResult, option: 'p' | 't'): string { return output } -function hasControllingTty(tty: string): boolean { - return tty !== '?' && tty !== '??' && tty !== '-' && tty !== '0' && !/^0,\d+$/.test(tty) -} - function* processTableQueries(rootPid: number): Generator { if (psDialect !== 'all') { try { diff --git a/src/main/qoder/real-cli.test.ts b/src/main/qoder/real-cli.test.ts index 7cd1e8a139d..5048c14bef0 100644 --- a/src/main/qoder/real-cli.test.ts +++ b/src/main/qoder/real-cli.test.ts @@ -9,14 +9,13 @@ import { stat, writeFile } from 'node:fs/promises' -import { createServer } from 'node:http' +import { randomUUID } from 'node:crypto' import { homedir, tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' import { expect, it, vi } from 'vitest' import { runProcess } from '../../shared/child-process/run-process' -import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state' -import { normalizeAndAccept } from '../../shared/agent-hook-listener-test-harness' +import { makePaneKey } from '../../shared/stable-pane-id' import { parseQoderSessionFile } from '../ai-vault/session-scanner-qoder-parser' const sandbox = vi.hoisted(() => ({ home: '' })) @@ -27,6 +26,7 @@ vi.mock('node:os', async (importOriginal) => { vi.mock('electron', () => ({ app: { getPath: () => sandbox.home } })) import { qoderHookService } from './hook-service' import { markQoderWorkspaceTrusted } from './workspace-trust' +import { AgentHookServer } from '../agent-hooks/server' it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( 'generates and resumes a real task through managed hooks', @@ -35,28 +35,22 @@ it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( sandbox.home = await realpath(await mkdtemp(join(tmpdir(), 'orca-qoder-real-'))) const config = join(sandbox.home, '.qoder') const workspace = join(sandbox.home, 'folder') + const proofFile = `qoder-proof-${randomUUID()}.txt` + const marker = `QODER_MANAGED_PROOF_${randomUUID()}` const statuses: { event: unknown state: unknown agent: unknown session: string | undefined }[] = [] - const listener = createHookListenerState() - const server = createServer(async (request, response) => { - let content = '' - for await (const chunk of request) { - content += chunk - } - const fields = new URLSearchParams(content) - const payload = JSON.parse(fields.get('payload') ?? '{}') - const normalized = normalizeAndAccept(listener, 'qoder', payload) + const server = new AgentHookServer() + server.subscribeEnrichedStatus((normalized) => { statuses.push({ - event: payload.hook_event_name, - state: normalized?.payload.state, - agent: normalized?.payload.agentType, - session: normalized?.providerSession?.id + event: normalized.hookEventName, + state: normalized.payload.state, + agent: normalized.payload.agentType, + session: normalized.providerSession?.id }) - response.writeHead(request.url === '/hook/qoder' ? 200 : 404).end() }) try { await mkdir(workspace) @@ -68,23 +62,18 @@ it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( ) expect(qoderHookService.install().state).toBe('installed') markQoderWorkspaceTrusted(workspace, sandbox.home) - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) - const address = server.address() - if (!address || typeof address === 'string') { - throw new Error('No test receiver port') - } + await server.start({ env: 'production', userDataPath: join(sandbox.home, 'orca') }) const env = { ...process.env, HOME: sandbox.home, USERPROFILE: sandbox.home, - ORCA_AGENT_HOOK_PORT: String(address.port), - ORCA_AGENT_HOOK_TOKEN: 'test-token', - ORCA_PANE_KEY: 'qoder-proof-pane', - ORCA_AGENT_HOOK_ENDPOINT: '', - ORCA_AGENT_HOOK_TRANSPORT: '', + ...server.buildPtyEnv(), + ORCA_PANE_KEY: makePaneKey(randomUUID(), randomUUID()), ORCA_BACKGROUND_LAUNCH: '1' } const command = process.env.ORCA_QODER_CLI_PATH ?? join(realHome, '.local', 'bin', 'qodercli') + const version = await runProcess({ program: command, args: ['--version'], env }) + expect(version.code).toBe(0) const args = [ '--config-dir', config, @@ -100,15 +89,14 @@ it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( ...args, '--permission-mode', 'accept_edits', - 'Create proof.txt containing exactly QODER_MANAGED_PROOF. Then reply QODER_MANAGED_COMPLETE.' + `Create ${proofFile} containing exactly ${marker}. Then reply QODER_MANAGED_COMPLETE.` ], env, timeoutMs: 90000 }) expect(generated.code).toBe(0) - expect((await readFile(join(workspace, 'proof.txt'), 'utf8')).trim()).toBe( - 'QODER_MANAGED_PROOF' - ) + expect(generated.stdout.trim()).toBe('QODER_MANAGED_COMPLETE') + expect((await readFile(join(workspace, proofFile), 'utf8')).trim()).toBe(marker) const session = statuses.find((s) => s.event === 'SessionStart')?.session expect(session).toBeTruthy() if (!session) { @@ -120,13 +108,18 @@ it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( ...args, '--resume', session, - 'What exact marker did you write? Reply only with that marker.' + '--permission-mode', + 'accept_edits', + `Recall the exact marker you wrote earlier without reading any file. Create resumed.txt containing that marker followed by a newline and QODER_RESUMED_CHANGE. Reply only with the original marker.` ], env, timeoutMs: 90000 }) expect(resumed.code).toBe(0) - expect(resumed.stdout.trim()).toBe('QODER_MANAGED_PROOF') + expect(resumed.stdout.trim()).toBe(marker) + expect((await readFile(join(workspace, 'resumed.txt'), 'utf8')).trim()).toBe( + `${marker}\nQODER_RESUMED_CHANGE` + ) const projects = join(config, 'projects') const transcript = (await readdir(projects, { recursive: true })).find( (path) => path.endsWith(`${session}.jsonl`) && !path.includes('subagents') @@ -142,8 +135,17 @@ it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( modifiedAt: modified.mtime.toISOString() }) expect(history).toMatchObject({ agent: 'qoder', sessionId: session, cwd: workspace }) - expect(history?.previewMessages.at(-1)?.text).toBe('QODER_MANAGED_PROOF') + expect(history?.previewMessages.at(-1)?.text).toBe(marker) expect(history?.resumeCommand).toContain(`qodercli --resume '${session}'`) + expect(server.getStatusSnapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agentType: 'qoder', + state: 'done', + providerSession: expect.objectContaining({ id: session }) + }) + ]) + ) expect(statuses).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -162,9 +164,20 @@ it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( evidencePath, JSON.stringify( { + command, + version: version.stdout.trim(), + proofFile, + marker, + generatedExitCode: generated.code, + resumedExitCode: resumed.code, generated: generated.stdout.trim(), resumed: resumed.stdout.trim(), statuses, + canonicalSnapshot: server.getStatusSnapshot().map((row) => ({ + agent: row.agentType, + state: row.state, + session: row.providerSession?.id + })), history: { agent: history?.agent, sessionId: history?.sessionId, @@ -178,7 +191,7 @@ it.skipIf(process.env.ORCA_REAL_QODER_CLI_TEST !== '1')( ) } } finally { - await new Promise((resolve) => server.close(() => resolve())) + server.stop() await rm(sandbox.home, { recursive: true, force: true }) sandbox.home = '' } diff --git a/src/main/rate-limits/claude-fetcher-extra-usage.test.ts b/src/main/rate-limits/claude-fetcher-extra-usage.test.ts new file mode 100644 index 00000000000..fb56512bd8c --- /dev/null +++ b/src/main/rate-limits/claude-fetcher-extra-usage.test.ts @@ -0,0 +1,302 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' +import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' + +const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( + () => ({ + netFetchMock: vi.fn(), + readFileMock: vi.fn(), + resolveProxyMock: vi.fn(), + setProxyMock: vi.fn(), + appGetPathMock: vi.fn() + }) +) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock +})) + +vi.mock('electron', () => ({ + app: { + getPath: appGetPathMock + }, + net: { + fetch: netFetchMock + }, + session: { + defaultSession: { + resolveProxy: resolveProxyMock, + setProxy: setProxyMock + } + } +})) + +vi.mock('./claude-pty', () => ({ + fetchViaPty: vi.fn() +})) + +vi.mock('../claude-accounts/keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(), + writeManagedClaudeKeychainCredentials: vi.fn() +})) + +function oauthPrep(): ClaudeRuntimeAuthPreparation { + return { + configDir: '/Users/test/.claude', + envPatch: { CLAUDE_CONFIG_DIR: '/Users/test/.claude' }, + stripAuthEnv: false, + provenance: 'system' + } +} + +async function fetchUsageResponse(body: string) { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { accessToken: 'oauth-token', expiresAt: Date.now() + 60_000 } + }) + ) + netFetchMock.mockResolvedValueOnce(new Response(body, { status: 200 })) + return fetchClaudeRateLimits({ authPreparation: oauthPrep() }) +} + +describe('fetchClaudeRateLimits extra usage', () => { + beforeEach(() => { + primeClaudeFetcherMocks({ + netFetchMock, + readFileMock, + resolveProxyMock, + setProxyMock, + appGetPathMock + }) + }) + + afterEach(() => { + restorePlatform() + }) + + it('maps the usage-credits spend object into a capped balance in major units', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + spend: { + used: { amount_minor: 5000, currency: 'EUR', exponent: 2 }, + limit: { amount_minor: 200000, currency: 'EUR', exponent: 2 }, + percent: 2.5, + enabled: true, + balance: { amount_minor: 1000, currency: 'EUR', exponent: 2 } + } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + extraUsage: { + balance: 10, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5, + currencyCode: 'EUR', + enabled: true, + disabledReason: null, + resetsAt: null + } + }) + }) + + it('keeps the usage-credits cap visible without inventing an out-of-credits balance', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + spend: { + used: { amount_minor: 0, currency: 'EUR', exponent: 2 }, + limit: { amount_minor: 200000, currency: 'EUR', exponent: 2 }, + percent: 0, + enabled: false, + disabled_reason: 'out_of_credits', + balance: null + } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + extraUsage: { + balance: null, + spent: 0, + spendLimit: 2000, + spentPercent: 0, + currencyCode: 'EUR', + enabled: false, + disabledReason: 'out_of_credits' + } + }) + }) + + it('keeps legacy extra_usage spend data without inventing a missing balance', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + extra_usage: { + is_enabled: true, + monthly_limit: 200000, + used_credits: 5000, + utilization: 2.5, + currency: 'EUR', + decimal_places: 2 + } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + extraUsage: { + balance: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5, + currencyCode: 'EUR', + enabled: true + } + }) + }) + it('keeps a missing balance unknown while preserving a known cap', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"spend":{"limit":{"amount_minor":200000,"currency":"EUR"},"enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spendLimit: 2000, + currencyCode: 'EUR' + }) + }) + + it.each([1000, 0])('preserves a balance-only EUR amount of %s minor units', async (amount) => { + const result = await fetchUsageResponse( + JSON.stringify({ + five_hour: { utilization: 10 }, + spend: { balance: { amount_minor: amount, currency: 'EUR' }, enabled: true } + }) + ) + expect(result.extraUsage).toMatchObject({ + balance: amount / 100, + currencyCode: 'EUR', + spendLimit: null + }) + }) + + it('keeps a numeric overflow balance unknown and derives a finite percentage', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"spend":{"used":{"amount_minor":5000},"limit":{"amount_minor":200000},"balance":{"amount_minor":1e309},"percent":1e309,"enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5 + }) + }) + + it('keeps nullable legacy spend unknown despite a supplied percentage', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":null,"utilization":10,"currency":"EUR","is_enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spent: null, + spendLimit: 2000, + spentPercent: 10 + }) + }) + + it('derives legacy percentage when the response percentage overflows', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":5000,"utilization":1e309,"currency":"EUR","is_enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5 + }) + }) + + it('does not turn an overflowing legacy spend into a known amount', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":1e309,"currency":"EUR","is_enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spent: null, + spendLimit: 2000, + spentPercent: null + }) + }) + + it.each(['null', '1e309'])('omits a legacy cap whose amount is %s', async (limit) => { + const result = await fetchUsageResponse( + `{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":${limit},"used_credits":5000,"currency":"EUR","is_enabled":true}}` + ) + expect(result.extraUsage ?? null).toBeNull() + }) + + it.each([-1, 0.5, 309])( + 'omits malformed legacy precision %s rather than displaying zero', + async (exponent) => { + const result = await fetchUsageResponse( + JSON.stringify({ + five_hour: { utilization: 10 }, + extra_usage: { + monthly_limit: 200000, + used_credits: 5000, + decimal_places: exponent, + currency: 'EUR', + is_enabled: true + } + }) + ) + expect(result.extraUsage ?? null).toBeNull() + } + ) +}) diff --git a/src/main/rate-limits/claude-oauth-usage-request.ts b/src/main/rate-limits/claude-oauth-usage-request.ts index 9565a064514..b3bca9298aa 100644 --- a/src/main/rate-limits/claude-oauth-usage-request.ts +++ b/src/main/rate-limits/claude-oauth-usage-request.ts @@ -4,6 +4,11 @@ import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' import { createOAuthUsageError } from './claude-oauth-usage-error' import { mapClaudeUsageWindow, type ClaudeUsageWindowInput } from './claude-usage-window' import { abortedClaudeRateLimitResult } from './claude-usage-result' +import { + mapClaudeExtraUsage, + type ClaudeOAuthExtraUsage, + type ClaudeOAuthSpend +} from './claude-usage-credits' const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' const API_TIMEOUT_MS = 10_000 @@ -22,6 +27,8 @@ type OAuthUsageResponse = { fable_weekly?: ClaudeUsageWindowInput fable_seven_day?: ClaudeUsageWindowInput seven_day_fable?: ClaudeUsageWindowInput + spend?: ClaudeOAuthSpend + extra_usage?: ClaudeOAuthExtraUsage limits?: OAuthUsageLimit[] | null } @@ -90,6 +97,7 @@ export async function fetchClaudeOAuthUsage( session: mapClaudeUsageWindow(data.five_hour, 300), weekly: mapClaudeUsageWindow(data.seven_day, 10080), fableWeekly: mapFableWeeklyWindow(data), + extraUsage: mapClaudeExtraUsage(data), updatedAt: Date.now(), error: null, status: 'ok' diff --git a/src/main/rate-limits/claude-usage-credits.ts b/src/main/rate-limits/claude-usage-credits.ts new file mode 100644 index 00000000000..9f01fd63064 --- /dev/null +++ b/src/main/rate-limits/claude-usage-credits.ts @@ -0,0 +1,127 @@ +import type { ExtraUsageBalance } from '../../shared/rate-limit-types' + +// Money is reported as minor units plus an exponent (e.g. 200000 @ exp 2 = €2000). +type OAuthMoney = { + amount_minor?: number + currency?: string + exponent?: number +} + +// Optional richer spend fields are retained for response compatibility. +export type ClaudeOAuthSpend = { + used?: OAuthMoney + limit?: OAuthMoney + percent?: number + enabled?: boolean + disabled_reason?: string | null + cap?: { money?: OAuthMoney | null } | null + balance?: OAuthMoney | number | null +} + +// Legacy shape kept as a fallback for older responses that only send `extra_usage`. +export type ClaudeOAuthExtraUsage = { + is_enabled?: boolean + monthly_limit?: number | null + used_credits?: number | null + utilization?: number | null + currency?: string + decimal_places?: number + disabled_reason?: string | null +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)) +} + +function moneyToMajor(money: OAuthMoney | number | null | undefined): number | null { + if (typeof money === 'number') { + return Number.isFinite(money) ? money : null + } + if (!money || typeof money.amount_minor !== 'number' || !Number.isFinite(money.amount_minor)) { + return null + } + const exponent = typeof money.exponent === 'number' ? money.exponent : 2 + if (!Number.isInteger(exponent) || exponent < 0 || exponent > 6) { + return null + } + return money.amount_minor / 10 ** exponent +} + +// Prefer the richer `spend` object; fall back to the legacy `extra_usage` shape. +export function mapClaudeExtraUsage(data: { + spend?: ClaudeOAuthSpend + extra_usage?: ClaudeOAuthExtraUsage +}): ExtraUsageBalance | null { + return mapSpend(data.spend) ?? mapLegacyExtraUsage(data.extra_usage) +} + +function mapSpend(spend: ClaudeOAuthSpend | undefined): ExtraUsageBalance | null { + if (!spend) { + return null + } + const spent = moneyToMajor(spend.used) + const spendLimit = moneyToMajor(spend.limit) ?? moneyToMajor(spend.cap?.money) + const balance = moneyToMajor(spend.balance) + // Nothing worth showing when the account has neither a configured cap nor a balance. + if (spendLimit === null && balance === null) { + return null + } + const spentPercent = + typeof spend.percent === 'number' && Number.isFinite(spend.percent) + ? clampPercent(spend.percent) + : spendLimit !== null && spendLimit > 0 && spent !== null + ? clampPercent((spent / spendLimit) * 100) + : null + return { + balance, + unit: 'currency', + currencyCode: + spend.used?.currency ?? + spend.limit?.currency ?? + spend.cap?.money?.currency ?? + (typeof spend.balance === 'object' && spend.balance !== null + ? spend.balance.currency + : undefined) ?? + 'USD', + enabled: spend.enabled === true, + disabledReason: spend.disabled_reason ?? null, + spent, + spendLimit, + spentPercent, + resetsAt: null + } +} + +function mapLegacyExtraUsage(extra: ClaudeOAuthExtraUsage | undefined): ExtraUsageBalance | null { + if (!extra) { + return null + } + const spendLimit = moneyToMajor({ + amount_minor: extra.monthly_limit ?? undefined, + exponent: extra.decimal_places + }) + if (spendLimit === null) { + return null + } + const spent = moneyToMajor({ + amount_minor: extra.used_credits ?? undefined, + exponent: extra.decimal_places + }) + const spentPercent = + typeof extra.utilization === 'number' && Number.isFinite(extra.utilization) + ? clampPercent(extra.utilization) + : spendLimit > 0 && spent !== null + ? clampPercent((spent / spendLimit) * 100) + : null + return { + balance: null, + unit: 'currency', + currencyCode: extra.currency?.trim() || 'USD', + enabled: extra.is_enabled === true, + disabledReason: extra.disabled_reason ?? null, + spent, + spendLimit, + spentPercent, + resetsAt: null + } +} diff --git a/src/main/rate-limits/claude-usage-result.ts b/src/main/rate-limits/claude-usage-result.ts index aea522e673b..a29cb38d4aa 100644 --- a/src/main/rate-limits/claude-usage-result.ts +++ b/src/main/rate-limits/claude-usage-result.ts @@ -112,7 +112,9 @@ export function mergeClaudeUsageWindows( ...primary, session: primary.session ?? supplement.session, weekly: primary.weekly ?? supplement.weekly, - fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null + fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null, + // Only OAuth reports the extra-usage cap; keep it when the CLI supplements windows. + extraUsage: primary.extraUsage ?? supplement.extraUsage ?? null } } diff --git a/src/main/rate-limits/codex-credit-balance.ts b/src/main/rate-limits/codex-credit-balance.ts new file mode 100644 index 00000000000..ece52f2df64 --- /dev/null +++ b/src/main/rate-limits/codex-credit-balance.ts @@ -0,0 +1,29 @@ +import type { ExtraUsageBalance } from '../../shared/rate-limit-types' +import type { CodexRateLimitWindowsSnapshot } from './codex-rate-limit-window-classification' + +// Codex's pay-as-you-go credit balance, reported alongside the rate windows. +// `balance` is a plain credit count (string), not a currency amount. +type RpcCredits = NonNullable + +// Codex credits are a unitless count, not currency. Only surface the balance +// when the account actually has credits (or unlimited) so accounts that never +// bought any don't get an empty "0 credits" row. +export function mapCodexCredits(raw: RpcCredits | null | undefined): ExtraUsageBalance | null { + if (!raw || (raw.hasCredits !== true && raw.unlimited !== true)) { + return null + } + const parsed = + typeof raw.balance === 'string' && raw.balance.trim() !== '' ? Number(raw.balance) : raw.balance + if ((typeof parsed !== 'number' || !Number.isFinite(parsed)) && raw.unlimited !== true) { + return null + } + const balance = typeof parsed === 'number' && Number.isFinite(parsed) ? Math.max(0, parsed) : 0 + return { + balance, + unit: 'credits', + unlimited: raw.unlimited === true, + enabled: raw.hasCredits === true || raw.unlimited === true, + disabledReason: null, + resetsAt: null + } +} diff --git a/src/main/rate-limits/codex-fetcher-credit-balance.test.ts b/src/main/rate-limits/codex-fetcher-credit-balance.test.ts new file mode 100644 index 00000000000..780687c9a09 --- /dev/null +++ b/src/main/rate-limits/codex-fetcher-credit-balance.test.ts @@ -0,0 +1,137 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { childSpawnMock, readFileMock } = vi.hoisted(() => ({ + childSpawnMock: vi.fn(), + readFileMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) +vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) +vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) +vi.mock('node-pty', () => ({ spawn: vi.fn() })) +vi.mock('./codex-auth-presence', () => ({ + probeCodexAuthPresence: vi.fn(async () => 'present') +})) + +import { fetchCodexRateLimits } from './codex-fetcher' + +function parseRpcRequest(line: string): { id?: number; method?: string } { + const parsed: unknown = JSON.parse(line) + if (typeof parsed !== 'object' || parsed === null) { + return {} + } + return { + id: 'id' in parsed && typeof parsed.id === 'number' ? parsed.id : undefined, + method: 'method' in parsed && typeof parsed.method === 'string' ? parsed.method : undefined + } +} + +// Like the real app-server, the fake dies on stdin EOF or a signal. +class FakeRpcChild extends EventEmitter { + readonly stdout = new EventEmitter() + readonly stderr = new EventEmitter() + exitCode: number | null = null + rateLimits: unknown = null + readonly kill = vi.fn(() => { + this.exitNow() + return true + }) + readonly stdin = Object.assign(new EventEmitter(), { + end: vi.fn(() => this.exitNow()), + write: vi.fn((line: string) => { + const request = parseRpcRequest(line) + if (request.method === 'initialize') { + this.reply(request.id, {}) + } + if (request.method === 'account/rateLimits/read') { + this.reply(request.id, { rateLimits: this.rateLimits }) + } + }) + }) + + private reply(id: number | undefined, result: unknown): void { + setTimeout(() => { + this.stdout.emit('data', Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`)) + }, 0) + } + + private exitNow(): void { + this.exitCode = 0 + this.emit('exit', 0, null) + } +} + +async function fetchWithRateLimits(rateLimits: unknown) { + const child = new FakeRpcChild() + child.rateLimits = rateLimits + childSpawnMock.mockReturnValue(child) + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + return resultPromise +} + +describe('fetchCodexRateLimits credit balance', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + readFileMock.mockRejectedValue(new Error('no auth fixture')) + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('maps the RPC credits object into a Codex credit-count balance', async () => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + secondary: null, + credits: { hasCredits: true, unlimited: false, balance: '500' } + }) + + expect(result.extraUsage).toMatchObject({ + balance: 500, + unit: 'credits', + unlimited: false, + enabled: true + }) + }) + + it('omits the Codex balance when the account has no credits', async () => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + credits: { hasCredits: false, unlimited: false, balance: '0' } + }) + + expect(result.extraUsage ?? null).toBeNull() + }) + it.each([undefined, null, '', ' ', 'invalid', 'Infinity', '1e999'])( + 'omits unavailable credit balance %s despite hasCredits', + async (balance) => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + credits: { hasCredits: true, unlimited: false, balance } + }) + expect(result.extraUsage ?? null).toBeNull() + } + ) + + it('preserves an explicitly reported zero credit balance', async () => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + credits: { hasCredits: true, unlimited: false, balance: '0' } + }) + expect(result.extraUsage).toMatchObject({ unit: 'credits', unlimited: false, balance: 0 }) + }) + + it('keeps the unlimited indication without requiring a numeric balance', async () => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + credits: { hasCredits: false, unlimited: true, balance: null } + }) + expect(result.extraUsage).toMatchObject({ unit: 'credits', unlimited: true, enabled: true }) + }) +}) diff --git a/src/main/rate-limits/codex-rate-limit-window-classification.ts b/src/main/rate-limits/codex-rate-limit-window-classification.ts index cdf667b6306..a19363bed8a 100644 --- a/src/main/rate-limits/codex-rate-limit-window-classification.ts +++ b/src/main/rate-limits/codex-rate-limit-window-classification.ts @@ -13,6 +13,12 @@ export type CodexRateWindowSnapshot = { export type CodexRateLimitWindowsSnapshot = { primary?: CodexRateWindowSnapshot | null secondary?: CodexRateWindowSnapshot | null + /** Pay-as-you-go credit balance, when the app-server reports one. */ + credits?: { + hasCredits?: boolean + unlimited?: boolean + balance?: string | number + } | null } type MappableCodexRateWindowSnapshot = CodexRateWindowSnapshot & { usedPercent: number } diff --git a/src/main/rate-limits/codex-rpc-rate-limit-probe.ts b/src/main/rate-limits/codex-rpc-rate-limit-probe.ts index ededfa9c349..0c59403c35f 100644 --- a/src/main/rate-limits/codex-rpc-rate-limit-probe.ts +++ b/src/main/rate-limits/codex-rpc-rate-limit-probe.ts @@ -12,6 +12,7 @@ import { CODEX_WEEKLY_WINDOW_MINUTES, type CodexRateLimitWindowsSnapshot } from './codex-rate-limit-window-classification' +import { mapCodexCredits } from './codex-credit-balance' import type { CodexRateLimitFetchOptions } from './codex-rate-limit-fetch-options' import { abortedCodexRateLimitResult } from './codex-rate-limit-fetch-result' import { mapCodexRateLimitWindow } from './codex-rate-limit-window-mapper' @@ -244,12 +245,14 @@ export function readCodexRateLimitsViaRpc( const wrapper = message.result as RpcRateLimitsResponse | undefined const classified = classifyCodexRateLimitWindows(wrapper?.rateLimits) const credits = mapRpcRateLimitResetCredits(wrapper?.rateLimitResetCredits) + const extraUsage = mapCodexCredits(wrapper?.rateLimits?.credits) settle( { provider: 'codex', session: mapCodexRateLimitWindow(classified.session, CODEX_SESSION_WINDOW_MINUTES), weekly: mapCodexRateLimitWindow(classified.weekly, CODEX_WEEKLY_WINDOW_MINUTES), ...(credits !== undefined ? { rateLimitResetCredits: credits } : {}), + ...(extraUsage ? { extraUsage } : {}), updatedAt: Date.now(), error: null, status: 'ok' 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/rate-limits/opencode-go-status-parsing.test.ts b/src/main/rate-limits/opencode-go-status-parsing.test.ts index 1116dcad71b..b77f12f80c8 100644 --- a/src/main/rate-limits/opencode-go-status-parsing.test.ts +++ b/src/main/rate-limits/opencode-go-status-parsing.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' import { + isOpenCodeGoExplicitNoAccessPayload, + parseOpenCodeGoBillingStatusPayload, parseOpenCodeGoStatusPayload, parseOpenCodeGoUsageApiPayload } from './opencode-go-status-parsing' @@ -120,6 +122,76 @@ describe('parseOpenCodeGoStatusPayload', () => { }) }) +describe('parseOpenCodeGoBillingStatusPayload', () => { + it('maps the verified PAYG micro-cent string into USD major units', () => { + expect( + parseOpenCodeGoBillingStatusPayload( + JSON.stringify({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '2786781005' + }) + ) + ).toBe(27.86781005) + }) + + it('preserves zero and negative balances', () => { + expect( + parseOpenCodeGoBillingStatusPayload( + JSON.stringify({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '0' + }) + ) + ).toBe(0) + expect( + parseOpenCodeGoBillingStatusPayload( + JSON.stringify({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '-125000000' + }) + ) + ).toBe(-1.25) + }) + + it.each([ + { billingMode: 'credit', mode: 'pay-as-you-go', balanceMicroCents: '100000000' }, + { billingMode: 'seat', mode: 'pay-as-you-go', balanceMicroCents: '100000000' }, + { billingMode: 'prepaid', mode: 'invoiceable', balanceMicroCents: '100000000' }, + { billingMode: 'prepaid', mode: 'pay-as-you-go', balanceMicroCents: 100000000 }, + { billingMode: 'prepaid', mode: 'pay-as-you-go', balanceMicroCents: '1.5' }, + { + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '9007199254740992' + } + ])('rejects unsupported or unsafe billing payloads: $billingMode/$mode', (payload) => { + expect(parseOpenCodeGoBillingStatusPayload(JSON.stringify(payload))).toBeNull() + }) + + it('fails closed for malformed and non-object bodies', () => { + expect(parseOpenCodeGoBillingStatusPayload('{not json')).toBeNull() + expect(parseOpenCodeGoBillingStatusPayload('[]')).toBeNull() + expect(parseOpenCodeGoBillingStatusPayload('')).toBeNull() + }) +}) + +describe('isOpenCodeGoExplicitNoAccessPayload', () => { + it('accepts only explicit JSON null or access:null without an error verdict', () => { + expect(isOpenCodeGoExplicitNoAccessPayload('null')).toBe(true) + expect(isOpenCodeGoExplicitNoAccessPayload('{"access":null}')).toBe(true) + expect( + isOpenCodeGoExplicitNoAccessPayload('{"access":null,"error":{"type":"AuthError"}}') + ).toBe(false) + expect(isOpenCodeGoExplicitNoAccessPayload('{"access":{}}')).toBe(false) + expect(isOpenCodeGoExplicitNoAccessPayload('{}')).toBe(false) + expect(isOpenCodeGoExplicitNoAccessPayload('')).toBe(false) + expect(isOpenCodeGoExplicitNoAccessPayload('{not json')).toBe(false) + }) +}) + describe('parseOpenCodeGoUsageApiPayload', () => { it('clamps an out-of-range percent and tolerates a missing resetsAt', () => { const parsed = parseOpenCodeGoUsageApiPayload( diff --git a/src/main/rate-limits/opencode-go-status-parsing.ts b/src/main/rate-limits/opencode-go-status-parsing.ts index cc98fd477ed..c4635792fc9 100644 --- a/src/main/rate-limits/opencode-go-status-parsing.ts +++ b/src/main/rate-limits/opencode-go-status-parsing.ts @@ -1,4 +1,4 @@ -import type { RateLimitWindow } from '../../shared/rate-limit-types' +import type { ExtraUsageBalance, RateLimitWindow } from '../../shared/rate-limit-types' const SESSION_WINDOW_MINUTES = 300 const WEEKLY_WINDOW_MINUTES = 10_080 @@ -100,6 +100,61 @@ export function parseOpenCodeGoUsageApiPayload(text: string): OpenCodeGoUsageWin } } +// Console BillingStatus uses integer-string LedgerMicroCents (100,000,000 per USD). +export function parseOpenCodeGoBillingStatusPayload(text: string): number | null { + if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) { + return null + } + let payload: unknown + try { + payload = JSON.parse(text) + } catch { + return null + } + if ( + !isRecord(payload) || + payload.billingMode !== 'prepaid' || + payload.mode !== 'pay-as-you-go' || + typeof payload.balanceMicroCents !== 'string' || + !/^-?\d+$/.test(payload.balanceMicroCents) + ) { + return null + } + const microCents = Number(payload.balanceMicroCents) + return Number.isSafeInteger(microCents) ? microCents / 100_000_000 : null +} + +export function makeOpenCodeGoZenBalance( + balance: number | null, + disabledReason: string | null = null +): ExtraUsageBalance { + return { + balance, + unit: 'currency', + currencyCode: 'USD', + enabled: balance !== null, + disabledReason, + spent: null, + spendLimit: null, + spentPercent: null, + resetsAt: null + } +} + +export function isOpenCodeGoExplicitNoAccessPayload(text: string): boolean { + if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) { + return false + } + try { + const payload: unknown = JSON.parse(text) + return ( + payload === null || (isRecord(payload) && payload.access === null && !('error' in payload)) + ) + } catch { + return false + } +} + export function parseOpenCodeGoStatusPayload(text: string): OpenCodeGoUsageWindows | null { if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) { return null diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts index 9e07f83f472..0c65c2fb788 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts @@ -15,6 +15,7 @@ import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-u const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f' const CONSOLE_STATUS_URL = 'https://opencode.ai/console/api/go/status' +const CONSOLE_BILLING_STATUS_URL = 'https://opencode.ai/console/api/billing/status' const LEGACY_WORKSPACE_GO_URL = /https:\/\/opencode\.ai\/workspace\/[^/]+\/go/ function makeResponse(body: string, status = 200): Response { @@ -259,8 +260,10 @@ describe('fetchOpenCodeGoRateLimits', () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + .mockResolvedValueOnce(makeResponse('unavailable', 503)) .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + .mockResolvedValueOnce(makeResponse('unavailable', 503)) const proxySettings = { httpProxyUrl: 'http://proxy.example:8080', @@ -343,6 +346,254 @@ describe('fetchOpenCodeGoRateLimits', () => { }) }) + it.each(['discovery', 'override'])('accepts an org_ workspace from %s', async (source) => { + netFetchMock.mockImplementation(async (url) => { + if (String(url).startsWith('https://opencode.ai/_server')) { + return makeResponse('id: "org_CONSOLE123"') + } + return url === CONSOLE_STATUS_URL + ? makeJsonResponse(STATUS_WITH_MONTHLY) + : makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '2786781005' + }) + }) + + const result = await fetchOpenCodeGoRateLimits( + '__Host-console_session=placeholder', + source === 'override' ? 'org_CONSOLE123' : undefined + ) + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(30) + expect(result.extraUsage?.balance).toBe(27.86781005) + expect(netFetchMock).toHaveBeenCalledWith( + CONSOLE_BILLING_STATUS_URL, + expect.objectContaining({ + headers: expect.objectContaining({ 'x-org-id': 'org_CONSOLE123' }) + }) + ) + }) + + it('enriches the selected cookie workspace with its verified PAYG Zen balance', async () => { + netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)).mockResolvedValueOnce( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '2786781005' + }) + ) + + const result = await fetchOpenCodeGoRateLimits( + '__Host-console_session=consoleTok', + 'wrk_OVERRIDE123' + ) + + expect(netFetchMock).toHaveBeenNthCalledWith( + 2, + CONSOLE_BILLING_STATUS_URL, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'x-org-id': 'wrk_OVERRIDE123', + Accept: 'application/json' + }) + }) + ) + expect(netFetchMock.mock.calls[1][1].headers).not.toHaveProperty('Cookie') + expect(result.extraUsage).toEqual({ + balance: 27.86781005, + unit: 'currency', + currencyCode: 'USD', + enabled: true, + disabledReason: null, + spent: null, + spendLimit: null, + spentPercent: null, + resetsAt: null + }) + }) + + it.each([ + [ + 'unsupported mode', + makeJsonResponse({ billingMode: 'seat', mode: 'pay-as-you-go' }), + 'billing-unavailable' + ], + ['billing failure', makeResponse('unavailable', 503), 'refresh-failed'] + ])( + 'keeps valid quota when optional billing has an %s', + async (_label, billingResponse, disabledReason) => { + netFetchMock + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + .mockResolvedValueOnce(billingResponse) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(30) + expect(result.extraUsage).toEqual( + expect.objectContaining({ balance: null, enabled: false, disabledReason }) + ) + } + ) + + it('returns a verified balance-only snapshot for explicit access:null', async () => { + netFetchMock.mockResolvedValueOnce(makeJsonResponse({ access: null })).mockResolvedValueOnce( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '0' + }) + ) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(result).toEqual( + expect.objectContaining({ + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + error: null, + status: 'ok', + extraUsage: expect.objectContaining({ balance: 0, currencyCode: 'USD' }) + }) + ) + }) + + it('returns a verified balance-only snapshot for an explicit JSON null status', async () => { + netFetchMock.mockResolvedValueOnce(makeJsonResponse(null)).mockResolvedValueOnce( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '-125000000' + }) + ) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(result.status).toBe('ok') + expect(result.session).toBeNull() + expect(result.extraUsage).toEqual(expect.objectContaining({ balance: -1.25 })) + }) + + it('aborts billing before returning a generic malformed-usage error', async () => { + let billingSignal: AbortSignal | undefined + netFetchMock + .mockResolvedValueOnce(makeJsonResponse({ access: {} })) + .mockImplementationOnce((_url, init: RequestInit) => { + billingSignal = init.signal ?? undefined + return new Promise((resolve, reject) => { + billingSignal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + setTimeout( + () => + resolve( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '100000000' + }) + ), + 100 + ) + }) + }) + + const resultPromise = fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + await vi.advanceTimersByTimeAsync(0) + const abortedBeforeBillingResponse = billingSignal?.aborted + await vi.advanceTimersByTimeAsync(100) + const result = await resultPromise + + expect(abortedBeforeBillingResponse).toBe(true) + expect(result.status).toBe('error') + expect(result.error).toBe('Could not parse usage data') + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + }) + + it('prefers a later valid Go workspace over an earlier balance-only candidate', async () => { + netFetchMock + .mockResolvedValueOnce(makeResponse('id: "wrk_FIRST" id: "wrk_SECOND"')) + .mockResolvedValueOnce(makeJsonResponse({ access: null })) + .mockResolvedValueOnce( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '900000000' + }) + ) + .mockResolvedValueOnce(makeJsonResponse(STATUS_NO_MONTHLY)) + .mockResolvedValueOnce(makeResponse('unavailable', 503)) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken') + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(10) + expect(result.extraUsage).toEqual( + expect.objectContaining({ balance: null, enabled: false, disabledReason: 'refresh-failed' }) + ) + expect(netFetchMock).toHaveBeenNthCalledWith( + 4, + CONSOLE_STATUS_URL, + expect.objectContaining({ headers: expect.objectContaining({ 'x-org-id': 'wrk_SECOND' }) }) + ) + }) + + it('aborts and settles an optional billing timeout before clearing the cookie jar', async () => { + let billingSignal: AbortSignal | undefined + netFetchMock + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + .mockImplementationOnce((_url, init: RequestInit) => { + billingSignal = init.signal ?? undefined + return new Promise((_resolve, reject) => { + billingSignal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + }) + }) + + const resultPromise = fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + await vi.advanceTimersByTimeAsync(5_000) + const result = await resultPromise + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(30) + expect(billingSignal?.aborted).toBe(true) + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + }) + + it('aborts and settles billing before continuing after a non-ok usage response', async () => { + let billingSignal: AbortSignal | undefined + let billingSettled = false + netFetchMock + .mockResolvedValueOnce(makeResponse('Unauthorized', 401)) + .mockImplementationOnce((_url, init: RequestInit) => { + billingSignal = init.signal ?? undefined + return new Promise((_resolve, reject) => { + billingSignal?.addEventListener( + 'abort', + () => { + billingSettled = true + reject(new Error('aborted')) + }, + { once: true } + ) + }) + }) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(result.status).toBe('error') + expect(result.error).toContain('Usage fetch failed (401)') + expect(billingSignal?.aborted).toBe(true) + expect(billingSettled).toBe(true) + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + }) + it('returns ok with null monthly when the month meter is absent', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) @@ -400,7 +651,7 @@ describe('fetchOpenCodeGoRateLimits', () => { const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') - expect(netFetchMock).toHaveBeenCalledTimes(1) + expect(netFetchMock).toHaveBeenCalledTimes(2) expect(requestedUrls().some((url) => LEGACY_WORKSPACE_GO_URL.test(url))).toBe(false) expect(netFetchMock).toHaveBeenCalledWith( CONSOLE_STATUS_URL, diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.ts b/src/main/rate-limits/opencode-go-usage-fetcher.ts index 451d2f316bc..d8ebf61d17d 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.ts @@ -1,17 +1,24 @@ import type { Session } from 'electron' import { randomUUID } from 'node:crypto' import type { NetworkProxySettings } from '../../shared/network-proxy' -import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import type { ExtraUsageBalance, ProviderRateLimits } from '../../shared/rate-limit-types' import { clearOpenCodeSessionCookies, createOpenCodeRequestSession, OPENCODE_BASE_URL } from './opencode-go-request-session' -import { parseOpenCodeGoStatusPayload } from './opencode-go-status-parsing' +import { + isOpenCodeGoExplicitNoAccessPayload, + makeOpenCodeGoZenBalance, + parseOpenCodeGoBillingStatusPayload, + parseOpenCodeGoStatusPayload +} from './opencode-go-status-parsing' const OPENCODE_SERVER_URL = 'https://opencode.ai/_server' const OPENCODE_GO_STATUS_URL = `${OPENCODE_BASE_URL}/console/api/go/status` +const OPENCODE_BILLING_STATUS_URL = `${OPENCODE_BASE_URL}/console/api/billing/status` const API_TIMEOUT_MS = 15_000 +const OPTIONAL_BILLING_TIMEOUT_MS = 5_000 // Server-function hash for the workspaces endpoint — stable identifier used by // the opencode.ai SST/TanStack router server-fn protocol. @@ -59,12 +66,9 @@ function parseAuthCookies(raw: string): { name: string; value: string }[] { } function parseWorkspaceIds(text: string): string[] { - // Match id:"wrk_..." or id: "wrk_..." patterns in JS-serialized output. - // Why: Workspace IDs follow a 'wrk_xxx' or 'wk_xxx' pattern. Using a - // more specific regex with word boundaries avoids picking up unrelated - // object properties that might match a generic ID pattern. + // Console uses org_/wrk_; wk_ remains accepted for older workspace responses. const ids: string[] = [] - const workspaceIdRegex = /\bid\s*:\s*["']((?:wrk|wk)_[a-zA-Z0-9]+)["']/g + const workspaceIdRegex = /\bid\s*:\s*["']((?:org|wrk|wk)_[a-zA-Z0-9]+)["']/g for (const match of text.matchAll(workspaceIdRegex)) { const id = match[1] if (id && !ids.includes(id)) { @@ -126,6 +130,35 @@ export async function fetchOpenCodeGoRateLimits( } } +function startOptionalBillingFetch(openCodeSession: Session, workspaceId: string) { + const abortController = new AbortController() + const promise = (async (): Promise => { + const timeout = setTimeout(() => abortController.abort(), OPTIONAL_BILLING_TIMEOUT_MS) + try { + const response = await openCodeSession.fetch(OPENCODE_BILLING_STATUS_URL, { + method: 'GET', + headers: { + Accept: 'application/json', + Origin: OPENCODE_BASE_URL, + Referer: `${OPENCODE_BASE_URL}/console/${workspaceId}/billing`, + 'x-org-id': workspaceId + }, + signal: abortController.signal + }) + if (!response.ok) { + return makeOpenCodeGoZenBalance(null, 'refresh-failed') + } + const balance = parseOpenCodeGoBillingStatusPayload(await response.text()) + return makeOpenCodeGoZenBalance(balance, balance === null ? 'billing-unavailable' : null) + } catch { + return makeOpenCodeGoZenBalance(null, 'refresh-failed') + } finally { + clearTimeout(timeout) + } + })() + return { promise, abort: () => abortController.abort() } +} + function makeOpenCodeError(error: unknown): ProviderRateLimits { return { provider: 'opencode-go', @@ -147,14 +180,14 @@ async function fetchOpenCodeGoRateLimitsWithSession( const override = workspaceIdOverride?.trim() if (override) { - if (!/^(wrk|wk)_[A-Za-z0-9]+$/.test(override)) { + if (!/^(org|wrk|wk)_[A-Za-z0-9]+$/.test(override)) { return { provider: 'opencode-go', session: null, weekly: null, monthly: null, updatedAt: Date.now(), - error: 'Invalid workspace ID format: must match ^(wrk|wk)_[A-Za-z0-9]+$', + error: 'Invalid workspace ID format: must match ^(org|wrk|wk)_[A-Za-z0-9]+$', status: 'error' } } @@ -221,9 +254,11 @@ async function fetchOpenCodeGoRateLimitsWithSession( // Why: /workspace//go now 302s to console login. Usage is JSON at // /console/api/go/status, scoped by x-org-id and authed by the console session. let lastError = '' + let balanceOnlyResult: ProviderRateLimits | null = null for (const candidateId of ids) { + let billingTask: ReturnType | null = null try { - const statusRes = await openCodeSession.fetch(OPENCODE_GO_STATUS_URL, { + const statusPromise = openCodeSession.fetch(OPENCODE_GO_STATUS_URL, { method: 'GET', headers: { Accept: 'application/json', @@ -233,8 +268,11 @@ async function fetchOpenCodeGoRateLimitsWithSession( }, signal: AbortSignal.timeout(API_TIMEOUT_MS) }) - + billingTask = startOptionalBillingFetch(openCodeSession, candidateId) + const statusRes = await statusPromise if (!statusRes.ok) { + billingTask.abort() + await billingTask.promise lastError = statusRes.status === 401 ? 'Usage fetch failed (401) — paste the full Cookie header including __Host-console_session (auth alone is not enough)' @@ -242,25 +280,56 @@ async function fetchOpenCodeGoRateLimitsWithSession( continue } - const parsed = parseOpenCodeGoStatusPayload(await statusRes.text()) + const statusText = await statusRes.text() + const parsed = parseOpenCodeGoStatusPayload(statusText) + const explicitNoAccess = isOpenCodeGoExplicitNoAccessPayload(statusText) + if (!parsed && !explicitNoAccess) { + billingTask.abort() + await billingTask.promise + lastError = 'Could not parse usage data' + continue + } + + const balance = await billingTask.promise if (parsed) { return { provider: 'opencode-go', session: parsed.session, weekly: parsed.weekly, monthly: parsed.monthly, + extraUsage: balance, updatedAt: Date.now(), error: null, status: 'ok' } } - lastError = 'Could not parse usage data' + if (balance.balance !== null) { + balanceOnlyResult ??= { + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + extraUsage: balance, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + } else { + lastError = 'Could not parse usage data' + } } catch (err) { + if (billingTask) { + billingTask.abort() + await billingTask.promise + } const message = err instanceof Error ? err.message : 'Unknown error' lastError = message } } + if (balanceOnlyResult) { + return balanceOnlyResult + } return { provider: 'opencode-go', session: null, diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts index 64b7ac622ca..44cc775afa4 100644 --- a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts +++ b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts @@ -66,6 +66,9 @@ describe('fetchOpenCodeGoUsage', () => { expect(result.status).toBe('ok') expect(result.session).toEqual(WINDOWS.session) expect(result.usageMetadata?.credentialSource).toBe('environment') + expect(result.extraUsage).toEqual( + expect.objectContaining({ balance: null, enabled: false, disabledReason: 'api-key-source' }) + ) }) it('passes the settings override down as the highest-precedence tier', async () => { diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.ts b/src/main/rate-limits/opencode-go-usage-source-selection.ts index c38fc1dc844..5973d8c1e91 100644 --- a/src/main/rate-limits/opencode-go-usage-source-selection.ts +++ b/src/main/rate-limits/opencode-go-usage-source-selection.ts @@ -4,7 +4,7 @@ import { resolveOpenCodeGoApiKey, type OpenCodeGoApiKeyResolution } from './opencode-go-api-key-source' -import type { OpenCodeGoUsageWindows } from './opencode-go-status-parsing' +import { makeOpenCodeGoZenBalance, type OpenCodeGoUsageWindows } from './opencode-go-status-parsing' import type { OpenCodeCredentialBackend } from '../opencode/opencode-credential-backend' import { fetchOpenCodeGoUsageWithApiKey, @@ -52,6 +52,7 @@ function usageResult( session: windows.session, weekly: windows.weekly, monthly: windows.monthly, + extraUsage: makeOpenCodeGoZenBalance(null, 'api-key-source'), updatedAt: Date.now(), error: null, status: 'ok', diff --git a/src/main/ripgrep/text-search-unicode-real.test.ts b/src/main/ripgrep/text-search-unicode-real.test.ts index b56c1794a19..b2cc22c1aeb 100644 --- a/src/main/ripgrep/text-search-unicode-real.test.ts +++ b/src/main/ripgrep/text-search-unicode-real.test.ts @@ -108,6 +108,17 @@ describe('ripgrep Unicode match coordinates', () => { expect(result.truncated).toBe(false) }) + it('keeps clipped emoji context intact around a real match', async () => { + const prefix = '😀'.repeat(300) + await writeFile(join(root, 'emoji.txt'), `${prefix}x${prefix}\n`) + const match = (await search('x')).files[0]?.matches[0] + expect(match?.column).toBe(601) + expect(match?.matchLength).toBe(1) + expect(match?.lineContent.isWellFormed()).toBe(true) + expect(match?.lineContent.length).toBeLessThanOrEqual(502) + expect(match?.lineContent.slice((match.displayColumn ?? 1) - 1, match.displayColumn)).toBe('x') + }) + it('ignores external rg config while retaining workspace ignore files', async () => { const config = join(root, 'config') await writeFile(config, '--invert-match\n') diff --git a/src/main/runtime/browser-screencast-driver-attribution.test.ts b/src/main/runtime/browser-screencast-driver-attribution.test.ts index 59a8b6b98ca..678cb2caa0c 100644 --- a/src/main/runtime/browser-screencast-driver-attribution.test.ts +++ b/src/main/runtime/browser-screencast-driver-attribution.test.ts @@ -1,3 +1,4 @@ +import './rpc/unused-default-rpc-methods.test-fixture' /** * `browser.screencast` is the one stream a phone, a paired desktop client, the web client and the * CLI all open against the same host page. Stamping every subscriber as the mobile driver put the diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index a12fc06c479..b75b4a06024 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -1,3 +1,4 @@ +import './rpc/unused-default-rpc-methods.test-fixture' import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/src/main/runtime/opencode-model-worktree-create.test.ts b/src/main/runtime/opencode-model-worktree-create.test.ts new file mode 100644 index 00000000000..078a5c5e5e3 --- /dev/null +++ b/src/main/runtime/opencode-model-worktree-create.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const resolveRepo = vi.hoisted(() => vi.fn()) +vi.mock('./orca-runtime-get-worktree-terminal-provisioning-host', () => ({ + OrcaRuntimeWithGetWorktreeTerminalProvisioningHost: class { + store = { getSettings: () => ({ disabledTuiAgents: [] }) } + resolveRepoSelector = resolveRepo + } +})) +vi.mock('../workspace-create-telemetry', () => ({ + trackRuntimeWorkspaceCreate: ( + _request: unknown, + execute: (events: { begin: ReturnType }) => Promise + ) => execute({ begin: vi.fn() }) +})) +vi.mock('electron', () => ({ + app: { getPath: () => '/private/test', isPackaged: false }, + BrowserWindow: { fromId: vi.fn() }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() } +})) + +import { OrcaRuntimeWithCreateManagedWorktree } from './orca-runtime-create-managed-worktree' + +describe('unverified OpenCode model worktree creation', () => { + beforeEach(() => { + resolveRepo.mockReset() + resolveRepo.mockRejectedValue(new Error('repo_read_before_model_refusal')) + }) + + it.each(['local-repo', 'folder-repo', 'ssh-repo'])( + 'refuses %s before resolving or creating its workspace', + async (repoSelector) => { + const runtime = new OrcaRuntimeWithCreateManagedWorktree() + await expect( + runtime.createManagedWorktree({ + repoSelector, + name: 'unverified-model', + startupAgent: 'opencode', + startupLaunchPreferences: { model: 'private-proof/model-b' } + }) + ).rejects.toMatchObject({ code: 'capability_unsupported' }) + expect(resolveRepo).not.toHaveBeenCalled() + } + ) + + it('preserves worktree creation without a model preference', async () => { + const runtime = new OrcaRuntimeWithCreateManagedWorktree() + await expect( + runtime.createManagedWorktree({ + repoSelector: 'local-repo', + name: 'ordinary', + startupAgent: 'opencode' + }) + ).rejects.toThrow('repo_read_before_model_refusal') + expect(resolveRepo).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 36e4d3f41a1..b2ba25b7d73 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -17,7 +17,7 @@ import { } from './orca-runtime-core' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' -import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' +import { buildExecutionHostAgentStartupPlan } from '../opencode/opencode-model-startup-plan' import type { RuntimeTerminalCreate } from '../../shared/runtime-types' import type { AgentSessionCreateOperation, @@ -159,14 +159,14 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe ...(request.agentArgs !== undefined ? { agentArgs: request.agentArgs } : {}), sessionOptions: this.toAgentSessionOptions(request.launchPreferences) }) - const startup = - request.promptDelivery === 'draft' - ? buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' }) - : buildAgentStartupPlan({ - ...startupArgs, - prompt: request.prompt ?? '', - allowEmptyPromptLaunch: true - }) + const startup = await buildExecutionHostAgentStartupPlan({ + inputs: startupArgs, + cwd: startupCwd ?? workspace.path, + prompt: request.prompt ?? '', + promptDelivery: request.promptDelivery, + hostIdentity: this.runtimeId, + signal: caller.signal + }) if (!startup) { throw new Error('agent_session_identity_required') } diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index 63e3a010772..5e8cf5e9d8a 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -13,15 +13,15 @@ import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup import { invalidateAuthorizedRootsCacheForRepo } from '../ipc/filesystem-auth' import { startRuntimeLocalWorktreeTerminals } from './runtime-local-worktree-terminal-startup' import { trackRuntimeWorkspaceCreate } from '../workspace-create-telemetry' +import { assertOpenCodeModelLaunchPreferencesAbsent } from '../opencode/opencode-model-startup-plan' +import { resolveWorktreeCreateAgentStartup } from './runtime-worktree-agent-startup' import type { RuntimeWorkspaceCreateEvents } from '../workspace-create-telemetry' export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWorktreeTerminalProvisioningHost { async createManagedWorktree( args: RuntimeManagedWorktreeCreateArgs ): Promise { - // Why a holder fired in `finally`: consuming a prepared checkout empties a pool slot, so a - // create that fails anywhere after that — include copy, push target, terminal startup — must - // still arm the replacement. On success it fires last, once the startup terminals are up. + // Re-arm a consumed checkout after terminal startup, including failed creates. const rearm: PreparationRearmHolder = { fire: () => {} } try { return await trackRuntimeWorkspaceCreate(args, (events) => @@ -41,6 +41,11 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork throw new Error('runtime_unavailable') } + assertOpenCodeModelLaunchPreferencesAbsent( + args.startupAgent ?? args.createdWithAgent, + args.startupLaunchPreferences + ) + const repo = await this.resolveRepoSelector(args.repoSelector) const createSettings = this.store.getSettings() const requestedAgent = args.startupAgent ?? args.createdWithAgent @@ -58,19 +63,9 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork ) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') } - const agentStartup = - !args.startup && args.startupAgent - ? this.buildStartupForAgent( - repo, - args.startupAgent, - args.startupPrompt, - args.startupLaunchPreferences, - { - ...(args.startupAgentArgs !== undefined ? { agentArgs: args.startupAgentArgs } : {}), - ...(args.startupLaunchSource ? { launchSource: args.startupLaunchSource } : {}) - } - ) - : null + const agentStartup = resolveWorktreeCreateAgentStartup(args, (...inputs) => + this.buildStartupForAgent(repo, ...inputs) + ) const draftStartup = !args.startup && !agentStartup && args.startupDraft ? await this.buildStartupForDraft( @@ -88,10 +83,7 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork draftStartup?.agent ?? (requestedAgentEnabled ? requestedAgent : undefined)) const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste - // Resolve the execution host once, shared with the `worktrees:create` IPC entry point so the - // two cannot answer differently for the same repo. Reading the raw `connectionId` field routes - // an `executionHostId: 'ssh:*'`-only repo down the local path, which runs `git worktree add` on - // the client against a remote path. + // Match IPC routing: executionHostId-only SSH repos must not create locally. const createRoute = resolveWorktreeCreateRoute(repo) if (isFolderRepo(repo)) { // A folder workspace is a registration, not a filesystem create, so it is host-agnostic. diff --git a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts index f1b8fa16af5..0799fd1b17a 100644 --- a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts @@ -9,6 +9,8 @@ import type { RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types' import { randomUUID } from 'node:crypto' +import { copySleepingAgentLaunchConfig } from './runtime-agent-launch-resolution' +import { deriveRemoteRuntimeTerminalCreateHandle } from './remote-runtime-terminal-create-identity' import { parsePaneKey } from '../../shared/stable-pane-id' import { placeCreatedSessionTab } from '../../shared/session-tab-placement' import { @@ -17,7 +19,28 @@ import { getHeadlessMobileSessionGroupId } from './mobile-session-layout-projection' +const OWNED_MOBILE_DISPATCH_RECIPE_TTL_MS = 15 * 60_000 + export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends OrcaRuntimeWithResolveMobileSessionTerminalCommand { + private readonly ownedMobileDispatchRecipes = new Map< + string, + { + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent + connectionId: string | null + recordedAt: number + } + >() + + private pruneOwnedMobileDispatchRecipes(): void { + const now = Date.now() + for (const [handle, recipe] of this.ownedMobileDispatchRecipes) { + if (now - recipe.recordedAt >= OWNED_MOBILE_DISPATCH_RECIPE_TTL_MS) { + this.ownedMobileDispatchRecipes.delete(handle) + } + } + } + protected async createRuntimeOwnedMobileSessionTerminal( worktreeId: string, activate: boolean, @@ -29,6 +52,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] identity?: { tabId: string; leafId: string; sessionId?: string } + createMutation?: { clientIdentity: string; id: string } launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' targetGroupId?: string @@ -37,41 +61,122 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca signal?: AbortSignal } = {} ): Promise { + this.pruneOwnedMobileDispatchRecipes() const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd) // Why: SshPtyProvider treats sessionId as a relay reattach; only synthesize local serve ids so SSH fresh terminals still call pty.spawn. const stableSessionId = opts.identity?.sessionId ?? (workspace.connectionId ? undefined : `serve-${randomUUID()}`) const isNewSession = stableSessionId !== undefined && opts.identity?.sessionId === undefined - const terminal = await this.createTerminal(`id:${worktreeId}`, { - focus: false, - command: opts.command, - cwd, - env: opts.env, - envToDelete: opts.envToDelete, - ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), - ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), - ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), - startupCommandDelivery: opts.startupCommandDelivery, - ...(opts.identity - ? { - tabId: opts.identity.tabId, - leafId: opts.identity.leafId, - ...(stableSessionId ? { sessionId: stableSessionId } : {}) - } - : stableSessionId - ? { sessionId: stableSessionId } + const mutationHandle = opts.createMutation + ? deriveRemoteRuntimeTerminalCreateHandle( + opts.createMutation.clientIdentity, + worktreeId, + opts.createMutation.id + ) + : undefined + const create = (preAllocatedHandle?: string) => + this.createTerminal(`id:${worktreeId}`, { + focus: false, + ...(mutationHandle + ? { + onPtySpawnDispatched: (launch) => { + if (!launch) { + return + } + if ( + !this.ownedMobileDispatchRecipes.has(mutationHandle) && + this.ownedMobileDispatchRecipes.size >= 4096 + ) { + throw new Error('runtime_unavailable') + } + // Retain original dispatch evidence across an ambiguous response; retry payloads cannot replace it. + this.ownedMobileDispatchRecipes.set(mutationHandle, { + connectionId: workspace.connectionId ?? null, + recordedAt: Date.now(), + ...(launch.launchConfig + ? { launchConfig: copySleepingAgentLaunchConfig(launch.launchConfig) } + : {}), + ...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}) + }) + } + } : {}), - ...(isNewSession ? { isNewSession: true } : {}), - persistHostSessionBinding: true, - // Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash. - deferMobileSessionPublish: true, - signal: opts.signal - }) + ...(preAllocatedHandle ? { preAllocatedHandle } : {}), + command: opts.command, + cwd, + env: opts.env, + envToDelete: opts.envToDelete, + ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), + ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), + startupCommandDelivery: opts.startupCommandDelivery, + ...(opts.identity + ? { + tabId: opts.identity.tabId, + leafId: opts.identity.leafId, + ...(stableSessionId ? { sessionId: stableSessionId } : {}) + } + : stableSessionId + ? { sessionId: stableSessionId } + : {}), + ...(isNewSession ? { isNewSession: true } : {}), + persistHostSessionBinding: true, + // Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash. + deferMobileSessionPublish: true, + signal: opts.signal + }) + const terminal = opts.createMutation + ? await this.dedupeTerminalCreate( + opts.createMutation.clientIdentity, + `id:${worktreeId}`, + opts.createMutation.id, + true, + (_selector, handle) => create(handle) + ) + : await create() const livePty = this.getLivePtyForHandle(terminal.handle) if (!livePty) { throw new Error('terminal_handle_stale') } + if (opts.createMutation) { + if ( + livePty.pty.launchAgent && + opts.launchAgent && + livePty.pty.launchAgent !== opts.launchAgent + ) { + throw new Error('terminal_create_identity_conflict') + } + const dispatchedRecipe = + mutationHandle === terminal.handle && this.ownedMobileDispatchRecipes.get(terminal.handle) + const recipe = + dispatchedRecipe && + dispatchedRecipe.connectionId === (workspace.connectionId ?? null) && + (!livePty.pty.launchAgent || livePty.pty.launchAgent === dispatchedRecipe.launchAgent) + ? dispatchedRecipe + : null + if (opts.launchConfig && !livePty.pty.launchConfig && !recipe?.launchConfig) { + // Missing original dispatch evidence cannot authorize a retry's captured recipe. + throw new Error('runtime_unavailable') + } + if (recipe) { + livePty.pty.launchAgent ??= recipe.launchAgent ?? null + if (!livePty.pty.launchConfig && recipe.launchConfig) { + livePty.pty.launchConfig = copySleepingAgentLaunchConfig(recipe.launchConfig) + } + } + livePty.pty.runtimeSessionOwned = true + if (mutationHandle) { + this.ownedMobileDispatchRecipes.delete(mutationHandle) + } + } + if (opts.createMutation && opts.identity && terminal.ptyId) { + this.registerPty(terminal.ptyId, worktreeId, workspace.connectionId ?? null, { + tabId: opts.identity.tabId, + leafId: opts.identity.leafId, + terminalHandle: terminal.handle + }) + } const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}` const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID() if (opts.viewMode) { diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 4b8ff9cc744..eba6d03a29a 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -121,7 +121,8 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate } let result: Awaited>> try { - launchOpts.onPtySpawnDispatched?.() + const { launchAgent } = launchOpts + launchOpts.onPtySpawnDispatched?.({ launchConfig: effectiveLaunchConfig, launchAgent }) result = await this.ptyController.spawn({ cols: 120, rows: 40, diff --git a/src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts b/src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts index 13e5bbb8682..edb546b0396 100644 --- a/src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts +++ b/src/main/runtime/orca-runtime-files-ssh-chunk-reads.test.ts @@ -1,11 +1,6 @@ import { createHash } from 'node:crypto' import { describe, expect, it, vi } from 'vitest' -import { - enoent, - openMock, - resolveAuthorizedPathMock, - statMock -} from './orca-runtime-files-mock-registry' +import { enoent, openMock, resolveAuthorizedPathMock } from './orca-runtime-files-mock-registry' import { createRuntimeFileCommands, useRuntimeFileCommandsLifecycle @@ -313,9 +308,9 @@ describe('RuntimeFileCommands.readFileExplorerChunk over SSH', () => { const contents = remoteFileBytes(2048) const { commands } = createRuntimeFileCommands() resolveAuthorizedPathMock.mockResolvedValue('/repo/local.bin') - statMock.mockResolvedValue({ isDirectory: () => false, size: contents.byteLength }) const close = vi.fn(async () => undefined) openMock.mockResolvedValue({ + stat: vi.fn(async () => ({ isFile: () => true, size: contents.byteLength })), read: vi.fn( async (buffer: Buffer, bufferOffset: number, length: number, position: number) => { const bytesRead = contents.copy(buffer, bufferOffset, position, position + length) diff --git a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts index 7f968a6ba7d..8b03eebd650 100644 --- a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts +++ b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts @@ -13,6 +13,7 @@ import { canonicalizeAgentSessionIdentity } from './agent-session-claim-identity import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' +import { OrchestrationError } from './orchestration/orchestration-error' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -93,6 +94,15 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim // Legacy renderer sleep records are migration evidence, not host authority. throw new Error('agent_session_resume_not_authorized') } + if ( + request.agent === 'opencode' && + Object.values(request.launchPreferences ?? {}).some((value) => value !== undefined) + ) { + throw new OrchestrationError( + 'capability_unsupported', + 'OpenCode resume preferences are not verified by this execution host.' + ) + } if (!this.store) { throw new Error('runtime_unavailable') } diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index 82969ecb1da..567cbe6896d 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -19,6 +19,11 @@ import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-termi import { resolveStartupShell, type AgentStartupShell } from '../../shared/tui-agent-startup-shell' import { isTuiAgent } from '../../shared/tui-agent-config' import { resolveConfiguredWorkerAgent } from './orchestration/configured-worker-agent-selector' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { resolveLocalProjectRuntimeForRepo } from '../project-runtime-git-options' + +import { prepareOpenCodeModelStartupInputs } from '../opencode/opencode-model-startup-plan' +import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAdoptTerminalOrphansFromInventory { async getTerminalInteractiveWait( @@ -223,6 +228,58 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd return this.resolveOrchestrationAgentLauncher(selector, platform, shell) } + async probeOrchestrationOpenCodeModelLaunchSupport(target: { + worktree?: string + model?: string + }): Promise { + if (!target.model || !target.worktree) { + return false + } + const workspace = await this.resolveTerminalWorkspaceLaunchScope(target.worktree) + const executionRepo = workspace?.repo + if ( + workspace?.connectionId || + (executionRepo?.executionHostId && executionRepo.executionHostId !== 'local') + ) { + return false + } + const store = this.requireStore() + const settings = store.getSettings() + const path = workspace?.path + const unc = path ? parseWslUncPath(path) : null + const projectRuntime = executionRepo + ? resolveLocalProjectRuntimeForRepo(store, executionRepo) + : null + if (projectRuntime?.status === 'repair-required') { + return false + } + const wsl = unc + ? { distro: unc.distro } + : projectRuntime?.runtime.kind === 'wsl' + ? { distro: projectRuntime.runtime.distro } + : undefined + if (!path) { + return false + } + try { + await prepareOpenCodeModelStartupInputs({ + inputs: resolveAgentStartupPlanInputs({ + agent: 'opencode', + settings, + platform: wsl ? 'linux' : process.platform, + isRemote: false, + sessionOptions: { model: target.model } + }), + cwd: path, + isWsl: Boolean(wsl), + hostIdentity: this.getRuntimeId() + }) + return true + } catch { + return false + } + } + validateOrchestrationAgentLauncher(agent: TuiAgent): void { const settings = this.store?.getSettings() if (!settings) { diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 71b919bbf1b..115a5be8f0d 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -20,14 +20,9 @@ import type { import type { RuntimeTerminalRename } from '../../shared/runtime-types' import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' import type { TerminalCreateOptions } from './runtime-terminal-contracts' -import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { terminalShellOverrideRefusal } from './terminal-shell-override-host-support' import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' -import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' -import { buildAgentStartupPlan } from '../../shared/tui-agent-startup' -import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' -import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../shared/execution-host' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { @@ -38,6 +33,8 @@ import { import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' import { retryFailedRemovalUnlessRegistered } from '../worktree-removal-table' import type { GitWorktreeInfo } from '../../shared/worktree/types' +import { resolveQoderTerminalCommandForWorkspace } from './qoder-terminal-command-resolution' +import { buildRuntimeAgentTerminalStartupOptions } from './runtime-agent-terminal-startup' export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWithRemoveManagedWorktree { protected async resolveWorktreeRemovalTarget( @@ -243,6 +240,19 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith protected async resolveAgentTerminalCreateOptions( workspace: TerminalWorkspaceLaunchScope, opts: TerminalCreateOptions + ): Promise { + const launch = await this.buildAgentTerminalCreateOptions(workspace, opts) + return resolveQoderTerminalCommandForWorkspace( + launch, + workspace, + this.store, + this.getAgentLaunchPlatformForWorkspace(workspace) + ) + } + + protected async buildAgentTerminalCreateOptions( + workspace: TerminalWorkspaceLaunchScope, + opts: TerminalCreateOptions ): Promise { // Before any early return: every create lane funnels through here, and a host that cannot // apply the requested shell must refuse rather than spawn its default one. @@ -287,63 +297,13 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith return opts } - const settings = store.getSettings() - const platform = this.getAgentLaunchPlatformForWorkspace(workspace) - // Why: `workspace.repo` is display metadata and may be a row from another host; the launch - // shape must match the PTY route this scope already resolved. - const isRemote = Boolean(workspace.connectionId) - if (opts.startupAgent && !isTuiAgentEnabled(opts.startupAgent, settings.disabledTuiAgents)) { - throw new Error(`Agent ${opts.startupAgent} is disabled. Choose an enabled agent.`) - } - const agent = - opts.startupAgent ?? - resolveBareAgentLaunchCommand({ - command: opts.command, - settings, - platform, - isRemote - }) - if (!agent) { - return opts - } - - const startupPlan = buildAgentStartupPlan({ - ...resolveAgentStartupPlanInputs({ - agent, - settings, - platform, - isRemote, - ...(opts.agentArgs !== undefined ? { agentArgs: opts.agentArgs } : {}), - // A requested shell is the one this PTY will actually be, so it owns the quoting family. - windowsShellOverride: opts.shellOverride, - sessionOptions: this.toAgentSessionOptions(opts.launchPreferences) - }), - prompt: opts.startupPrompt ?? '', - allowEmptyPromptLaunch: true - }) - if (!startupPlan) { - // Why: an explicit agent that yields no plan would otherwise spawn a bare - // shell that never reaches agent readiness. - if (opts.startupAgent) { - throw new Error(`Could not build launch command for ${opts.startupAgent}.`) - } - return opts - } - // A prompt this launch command cannot carry has nowhere to go from here — the create returns - // options, not a live PTY — so refuse rather than spawn the agent and drop the text. - if (opts.startupPrompt && startupPlan.followupPrompt) { - throw new Error(`Agent ${agent} does not take a startup prompt on its launch command.`) - } - - return { - ...opts, - command: startupPlan.launchCommand, - ...(startupPlan.env ? { env: startupPlan.env } : {}), - launchConfig: startupPlan.launchConfig, - launchAgent: agent, - startupCommandDelivery: startupPlan.startupCommandDelivery, - // A bare command the user typed stays out of launch accounting, as before. - ...(opts.startupAgent ? { telemetry: agentStartedTelemetry(agent, opts.launchSource) } : {}) - } + return buildRuntimeAgentTerminalStartupOptions( + workspace, + opts, + store.getSettings(), + this.getAgentLaunchPlatformForWorkspace(workspace), + this.toAgentSessionOptions(opts.launchPreferences), + this.runtimeId + ) } } diff --git a/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts index 193bf3cb12d..5e3c6458252 100644 --- a/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts @@ -13,6 +13,7 @@ import { isClientDisconnectedError } from './orca-runtime-core' import { rendererPublicationThrottle } from '../window/renderer-publication-throttle' +import { deterministicAgentSessionUuid } from './runtime-agent-launch-resolution' export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWithCreateMobileSessionTerminal { protected async runCreateMobileSessionTerminal( @@ -66,6 +67,29 @@ export class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWi if (opts.signal?.aborted) { throw new Error('client_disconnected') } + if (opts.clientMutationId && startupCommand.command && startupCommand.launchAgent === 'qoder') { + const clientIdentity = opts.clientNavigationId ?? 'local' + const seed = `${clientIdentity}\0${worktreeId}\0${opts.clientMutationId}` + // A command-bearing retry must reconcile the owning PTY before delivering the resume again. + return await this.createRuntimeOwnedMobileSessionTerminal( + worktreeId, + opts.activate !== false, + afterTabId, + { + ...startupCommand, + cwd, + identity: { + tabId: deterministicAgentSessionUuid(`mobile-qoder-tab\0${seed}`), + leafId: deterministicAgentSessionUuid(`mobile-qoder-leaf\0${seed}`) + }, + createMutation: { clientIdentity, id: opts.clientMutationId }, + viewMode: opts.viewMode, + targetGroupId: opts.targetGroupId, + supportsSplitGroupPlacement: opts.supportsSplitGroupPlacement, + signal: opts.signal + } + ) + } const win = this.getAvailableAuthoritativeWindow() if (!win) { return await this.createRuntimeOwnedMobileSessionTerminal( diff --git a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts index 59e13676e63..64041d6d8da 100644 --- a/src/main/runtime/orca-runtime-terminal-create-deduplication.ts +++ b/src/main/runtime/orca-runtime-terminal-create-deduplication.ts @@ -101,7 +101,11 @@ export class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRuntimeWithC this.adoptControllerTerminalHandle(session.id, terminalHandle) const pty = this.recordPtyWorktree(session.id, worktreeId, { connected: true, - title: session.title + title: session.title, + ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + ...(session.wslDistro !== undefined + ? { wslDistro: session.wslDistro, isWsl: session.wslDistro !== null } + : {}) }) const adoptedHandle = this.issuePtyHandle(pty) if (adoptedHandle !== terminalHandle) { diff --git a/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts b/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts index 30d6c61ff79..93ef7412f47 100644 --- a/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts +++ b/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts @@ -350,6 +350,7 @@ vi.mock('../../ssh/ssh-target-registry', () => ({ })) vi.mock('../../preflight/agent-detection', () => ({ + detectAgentCommandsOnHost: vi.fn(async () => new Set(['qodercli'])), detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgents: detectRemoteAgentsMock })) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts index 47d7236dd9d..de21af9f013 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { detectAgentCommandsOnHost } from '../../preflight/agent-detection' import { FLOATING_TERMINAL_WORKTREE_ID, OrcaRuntimeService, @@ -28,6 +29,29 @@ import { } from '../orca-runtime-test-fixtures.spec' describe('OrcaRuntimeService', () => { + it.each(['qoder', 'qodercli'] as const)( + 'spawns the available Qoder command for a captured same-session resume: %s', + async (command) => { + vi.mocked(detectAgentCommandsOnHost).mockResolvedValueOnce(new Set([command])) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-qoder-resume' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + launchAgent: 'qoder', + command: "qodercli --resume 'existing-qoder-session'", + launchConfig: { agentCommand: 'qodercli', agentArgs: '', agentEnv: {} } + }) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ command: `${command} --resume 'existing-qoder-session'` }) + ) + } + ) + it('does not use the local Windows shell setting for remote Windows bare agent creates', async () => { const remoteRepo = { id: TEST_REPO_ID, diff --git a/src/main/runtime/orchestration-mailbox-notification-consistency.test.ts b/src/main/runtime/orchestration-mailbox-notification-consistency.test.ts index 161ccbeb9b1..3475ae70eb0 100644 --- a/src/main/runtime/orchestration-mailbox-notification-consistency.test.ts +++ b/src/main/runtime/orchestration-mailbox-notification-consistency.test.ts @@ -1,3 +1,4 @@ +import './rpc/unused-default-rpc-methods.test-fixture' import { settledWriteStub } from '../providers/settled-pty-write-stub' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' diff --git a/src/main/runtime/orchestration-message-delivery-identity.test.ts b/src/main/runtime/orchestration-message-delivery-identity.test.ts index 21c8a64f56a..d5dd60788af 100644 --- a/src/main/runtime/orchestration-message-delivery-identity.test.ts +++ b/src/main/runtime/orchestration-message-delivery-identity.test.ts @@ -1,3 +1,4 @@ +import './rpc/unused-default-rpc-methods.test-fixture' import { settledWriteStub } from '../providers/settled-pty-write-stub' import { spawn } from 'node:child_process' import { existsSync, mkdtempSync, rmSync } from 'node:fs' diff --git a/src/main/runtime/orchestration-opencode-model-host.test.ts b/src/main/runtime/orchestration-opencode-model-host.test.ts new file mode 100644 index 00000000000..6d12025e232 --- /dev/null +++ b/src/main/runtime/orchestration-opencode-model-host.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { + probeOpenCodeModelAvailability, + resolveOpenCodeDirectModelExecutable +} from '../opencode/opencode-model-availability' +import { probeOpenCodeLaunchCapabilities } from '../opencode/opencode-launch-capabilities' +import { resolveLocalProjectRuntimeForRepo } from '../project-runtime-git-options' + +vi.mock('../opencode/opencode-launch-capabilities', () => ({ + probeOpenCodeLaunchCapabilities: vi.fn() +})) +vi.mock('../opencode/opencode-model-availability', () => ({ + probeOpenCodeModelAvailability: vi.fn(), + resolveOpenCodeDirectModelExecutable: vi.fn() +})) +vi.mock('../managed-data-accounts/launch-environment', () => ({ + applyManagedDataAccountEnvironment: vi.fn() +})) +vi.mock('../project-runtime-git-options', () => ({ resolveLocalProjectRuntimeForRepo: vi.fn() })) + +function host( + scope: { path: string; connectionId?: string; repo?: { executionHostId?: string } } = { + path: '/tmp/folder' + } +) { + return { + resolveTerminalWorkspaceLaunchScope: vi.fn(async () => scope), + resolveRepoSelector: vi.fn(async () => scope), + requireStore: () => ({ + getSettings: () => ({ + agentCmdOverrides: { opencode: '/tmp/private-opencode' }, + agentDefaultEnv: { opencode: { OPENCODE_CONFIG_DIR: '/tmp/private-config' } } + }) + }), + getRuntimeId: () => 'host-1' + } +} +function probe( + runtime: ReturnType, + target = { worktree: 'id:folder', model: 'opencode/fledge-alpha-free' } +) { + return OrcaRuntimeService.prototype.probeOrchestrationOpenCodeModelLaunchSupport.call( + runtime, + target + ) +} + +describe('OpenCode worker model execution host', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(probeOpenCodeModelAvailability).mockResolvedValue(true) + vi.mocked(resolveOpenCodeDirectModelExecutable).mockResolvedValue('/tmp/private-opencode') + vi.mocked(resolveLocalProjectRuntimeForRepo).mockReturnValue(undefined) + vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ + version: '1.18.30', + pluginApi: 'v1', + promptMode: 'submit' + }) + }) + it('probes a local folder with its configured command and environment', async () => { + expect(await probe(host())).toBe(true) + expect(probeOpenCodeLaunchCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ + command: '/tmp/private-opencode', + cwd: '/tmp/folder', + hostIdentity: 'host-1', + env: expect.objectContaining({ OPENCODE_CONFIG_DIR: '/tmp/private-config' }) + }) + ) + }) + it('rejects an unknown selector rather than claiming the fallback model', async () => { + vi.mocked(probeOpenCodeModelAvailability).mockResolvedValue(false) + expect(await probe(host())).toBe(false) + }) + it.each(['v2', 'unknown'] as const)('refuses %s CLI model selection', async (pluginApi) => { + vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ + version: null, + pluginApi, + promptMode: 'unknown' + }) + expect(await probe(host())).toBe(false) + }) + it('never probes the client executable for an SSH workspace', async () => { + expect(await probe(host({ path: '/remote/folder', connectionId: 'ssh-1' }))).toBe(false) + expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() + }) + it('never probes the client executable for a foreign execution host', async () => { + expect( + await probe(host({ path: '/remote/folder', repo: { executionHostId: 'remote-host' } })) + ).toBe(false) + expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() + }) + it('refuses WSL without probing a different profile', async () => { + expect(await probe(host({ path: '\\\\wsl.localhost\\Ubuntu\\home\\repo' }))).toBe(false) + expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/qoder-independent-spawn-review.test.ts b/src/main/runtime/qoder-independent-spawn-review.test.ts new file mode 100644 index 00000000000..bfc32c1f78c --- /dev/null +++ b/src/main/runtime/qoder-independent-spawn-review.test.ts @@ -0,0 +1,329 @@ +import './rpc/unused-default-rpc-methods.test-fixture' +import { beforeEach, expect, it, vi } from 'vitest' +import filesystem from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' +import { buildAiVaultResumeShellCommand } from '../../shared/ai-vault-resume-command' + +await import('./orca-runtime-test-mocks.spec') +await import('./orca-runtime-test-lifecycle.spec') +const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec') +const { + store, + TEST_WORKTREE_PATH, + TEST_FOLDER_WORKSPACE_KEY, + createFolderWorkspaceRuntimeStore, + makeFolderWorkspace, + makeFolderProjectGroup +} = await import('./orca-runtime-test-fixtures.spec') +const { detectAgentCommandsOnHost } = await import('../preflight/agent-detection') +beforeEach(() => + vi + .mocked(detectAgentCommandsOnHost) + .mockReset() + .mockResolvedValue(new Set(['qodercli'])) +) + +it.each([ + ['modern-only', ['qoder'], 'qoder'], + ['legacy-only', ['qodercli'], 'qodercli'], + ['both', ['qoder', 'qodercli'], 'qodercli'] +] as const)( + 'fresh managed start reaches the production spawn boundary: %s', + async (_, found, selected) => { + vi.mocked(detectAgentCommandsOnHost).mockResolvedValueOnce(new Set(found)) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-independent-start' }) + const runtime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ ...store.getSettings(), disabledTuiAgents: [], agentCmdOverrides: {} }) + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + startupAgent: 'qoder', + agentArgs: null, + launchSource: 'orchestration', + startupPrompt: 'qodercli remains prompt text' + }) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + launchAgent: 'qoder', + command: `${selected} --prompt-interactive 'qodercli remains prompt text'` + }) + ) + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn.mock.calls[0]?.[0].telemetry).toEqual({ + agent_kind: 'qoder', + launch_source: 'orchestration', + request_kind: 'new' + }) + } +) + +it('a modern-only repo-less folder start reaches the same production spawn boundary', async () => { + vi.mocked(detectAgentCommandsOnHost).mockResolvedValueOnce(new Set(['qoder'])) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-independent-folder' }) + const createTempDirectory = filesystem.mkdtemp + const guard = vi.spyOn(filesystem, 'mkdtemp').mockImplementation((prefix, options) => { + // CI has no .context parent; reject that prerequisite even on a developer checkout. + expect(String(prefix)).not.toContain('.context') + return createTempDirectory(prefix, options) + }) + let folderPath: string | undefined + try { + folderPath = await filesystem.mkdtemp(join(tmpdir(), 'qoder-independent-folder-')) + const folderStore = createFolderWorkspaceRuntimeStore( + makeFolderWorkspace({ folderPath }), + makeFolderProjectGroup({ parentPath: folderPath }) + ) + const runtime = new OrcaRuntimeService({ + ...folderStore, + getSettings: () => ({ ...store.getSettings(), disabledTuiAgents: [], agentCmdOverrides: {} }) + }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_FOLDER_WORKSPACE_KEY}`, { + startupAgent: 'qoder', + agentArgs: null + }) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ launchAgent: 'qoder', command: 'qoder' }) + ) + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn.mock.calls[0]?.[0].cwd).toBe(folderPath) + expect(spawn.mock.calls[0]?.[0].telemetry).toEqual({ + agent_kind: 'qoder', + launch_source: 'unknown', + request_kind: 'new' + }) + expect((await filesystem.stat(folderPath)).isDirectory()).toBe(true) + await expect(filesystem.stat(join(folderPath, '.git'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + } finally { + guard.mockRestore() + if (folderPath) { + await filesystem.rm(folderPath, { recursive: true, force: true }) + } + } +}) + +it('selects the installed command for a mobile Qoder history resume at creation', async () => { + vi.mocked(detectAgentCommandsOnHost).mockResolvedValueOnce(new Set(['qoder'])) + const spawn = vi.fn().mockResolvedValue({ id: 'pty-mobile-qoder-resume' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const plan = buildAgentResumeStartupPlan({ + agent: 'qoder', + providerSession: { key: 'session_id', id: 'same-qoder-session' }, + cmdOverrides: {}, + platform: 'darwin' + }) + if (!plan) { + throw new Error('Missing Qoder resume plan') + } + const launch = { + command: buildAiVaultResumeShellCommand({ + resumeCommand: plan.launchCommand, + cwd: TEST_WORKTREE_PATH, + platform: 'darwin' + }), + launchAgent: plan.agent, + launchConfig: plan.launchConfig + } + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: launch.command, + launchAgent: launch.launchAgent, + launchConfig: launch.launchConfig + }) + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: launch.command.replace(/qodercli(?=\s)/, 'qoder'), + launchAgent: 'qoder' + }) + ) +}) + +it('host discovery refusal prevents any production spawn', async () => { + vi.mocked(detectAgentCommandsOnHost).mockRejectedValueOnce( + new Error('execution host unavailable') + ) + const spawn = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await expect( + runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + launchAgent: 'qoder', + command: 'qodercli --resume original-id' + }) + ).rejects.toThrow('execution host unavailable') + expect(spawn).not.toHaveBeenCalled() +}) + +it('caller-owned explicit executable survives production resume without discovery', async () => { + const detect = vi.mocked(detectAgentCommandsOnHost) + detect.mockClear() + const spawn = vi.fn().mockResolvedValue({ id: 'pty-independent-explicit' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + launchAgent: 'qoder', + command: '/caller/qodercli --resume original-id' + }) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ command: '/caller/qodercli --resume original-id' }) + ) + expect(detect).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn.mock.calls[0]?.[0].telemetry).toBeUndefined() +}) + +it.each([ + ['bare', 'qodercli'], + ['resume', 'qodercli --resume original-id'] +] as const)('does not falsely attribute a %s command as a fresh start', async (_, command) => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-independent-unattributed' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command, + agentArgs: null, + launchSource: 'orchestration', + ...(command.includes('--resume') ? { launchAgent: 'qoder' as const } : {}) + }) + expect(spawn).toHaveBeenCalledTimes(1) + expect(spawn.mock.calls[0]?.[0].command).toBe(command) + expect(spawn.mock.calls[0]?.[0].telemetry).toBeUndefined() +}) + +it.each(['offline', 'legacy-identity'])( + 'refuses a Qoder command-bearing create with unverifiable inventory: %s', + async (state) => { + const { TEST_WORKTREE_ID } = await import('./orca-runtime-test-fixtures.spec') + const runtime = new OrcaRuntimeService(store) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const spawn = vi.fn() + const kill = vi.fn() + runtime.setPtyController({ + spawn, + kill, + write: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => { + if (state === 'offline') { + throw new Error('host offline') + } + return [ + { + id: 'older-live-pty', + cwd: TEST_WORKTREE_PATH, + title: 'shell', + worktreeId: TEST_WORKTREE_ID + } + ] + } + }) + await expect( + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + command: "qodercli '--resume' 'same-session'", + launchAgent: 'qoder', + clientNavigationId: 'paired-phone', + clientMutationId: 'same-resume', + select: false, + activate: false + }) + ).rejects.toThrow('runtime_unavailable') + expect(spawn).not.toHaveBeenCalled() + expect(kill).not.toHaveBeenCalled() + } +) + +it('reconciles after the reply-cache expires and isolates deliberate forks and paired callers', async () => { + const { TEST_WORKTREE_ID } = await import('./orca-runtime-test-fixtures.spec') + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const live: { + id: string + cwd: string + title: string + worktreeId: string + terminalHandle: string + }[] = [] + const spawn = vi.fn(async (args: { preAllocatedHandle?: string }) => { + const id = `qoder-${live.length + 1}` + live.push({ + id, + cwd: TEST_WORKTREE_PATH, + title: 'Qoder', + worktreeId: TEST_WORKTREE_ID, + terminalHandle: args.preAllocatedHandle ?? '' + }) + return { id } + }) + runtime.setPtyController({ + spawn, + listProcesses: async () => live, + kill: vi.fn(), + write: () => true, + getForegroundProcess: async () => null + }) + const resume = { + command: "qodercli '--resume' 'same-session'", + launchAgent: 'qoder' as const, + clientNavigationId: 'phone-a', + clientMutationId: 'resume-a', + select: false, + activate: false + } + const first = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, resume) + await vi.advanceTimersByTimeAsync(61_000) + const retry = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, resume) + expect(retry.tab.id).toBe(first.tab.id) + expect(retry.tab.ptyId).toBe(first.tab.ptyId) + expect(spawn).toHaveBeenCalledTimes(1) + const fork = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...resume, + clientMutationId: 'resume-b' + }) + const otherPhone = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...resume, + clientNavigationId: 'phone-b' + }) + expect(spawn).toHaveBeenCalledTimes(3) + expect(new Set([first.tab.id, fork.tab.id, otherPhone.tab.id]).size).toBe(3) + } finally { + vi.useRealTimers() + } +}) diff --git a/src/main/runtime/qoder-mobile-resume-adoption.test.ts b/src/main/runtime/qoder-mobile-resume-adoption.test.ts new file mode 100644 index 00000000000..2f244313a9b --- /dev/null +++ b/src/main/runtime/qoder-mobile-resume-adoption.test.ts @@ -0,0 +1,450 @@ +import './rpc/unused-default-rpc-methods.test-fixture' +import { expect, it, vi } from 'vitest' +import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' +import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' + +await import('./orca-runtime-test-mocks.spec') +await import('./orca-runtime-test-lifecycle.spec') +const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec') +const { store, TEST_WORKTREE_PATH } = await import('./orca-runtime-test-fixtures.spec') +const { detectAgentCommandsOnHost } = await import('../preflight/agent-detection') + +class QoderAdoptionRuntime extends OrcaRuntimeService { + retainedRecipe(ptyId: string, worktreeId: string) { + return this.recordPtyWorktree(ptyId, worktreeId) + } +} + +it.each([ + 'client_disconnected', + 'Terminal creation timed out', + 'quoted-agent-command', + 'wsl-inventory', + 'changed-replay', + 'inventory-outage', + 'surviving-recipe' +])('reconciles a paired Qoder resume after ambiguous creation: %s', async (scenario) => { + const failure = scenario === 'Terminal creation timed out' ? scenario : 'client_disconnected' + const { withPlatform } = await import('./orca-runtime-test-fixtures.spec') + await withPlatform(scenario === 'wsl-inventory' ? 'win32' : 'darwin', async () => { + vi.useFakeTimers() + try { + const { electronMocks } = await import('./orca-runtime-test-mocks.spec') + const { TEST_WORKTREE_ID } = await import('./orca-runtime-test-fixtures.spec') + vi.mocked(detectAgentCommandsOnHost).mockResolvedValue(new Set(['qoder'])) + const runtime = new QoderAdoptionRuntime(store) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + let revealedRecipe: SleepingAgentLaunchConfig | undefined + const revealTerminalSession = vi.fn( + (_workspace: string, reveal: { launchConfig?: SleepingAgentLaunchConfig }) => { + revealedRecipe = reveal.launchConfig + } + ) + runtime.setNotifier({ + focusTerminal: vi.fn(), + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + closeTerminal: vi.fn(), + closeSessionTab: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + const acceptedCommands: string[] = [] + const live: { + id: string + cwd: string + title: string + worktreeId: string + terminalHandle: string + wslDistro?: string + }[] = [] + let connection = new AbortController() + let inventoryUnavailable = false + const kill = vi.fn((id: string) => { + const index = live.findIndex((entry) => entry.id === id) + if (index !== -1) { + live.splice(index, 1) + } + return true + }) + const spawn = vi.fn(async (args: { command?: string; preAllocatedHandle?: string }) => { + if (args.command) { + acceptedCommands.push(args.command) + } + live.push({ + id: 'qoder-live-resume', + cwd: TEST_WORKTREE_PATH, + title: 'Qoder', + worktreeId: TEST_WORKTREE_ID, + terminalHandle: args.preAllocatedHandle ?? '', + ...(scenario === 'wsl-inventory' ? { wslDistro: 'Ubuntu-Orca' } : {}) + }) + if (scenario === 'surviving-recipe') { + const retained = runtime.retainedRecipe('qoder-live-resume', TEST_WORKTREE_ID) + retained.launchAgent = 'qoder' + retained.launchConfig = { + agentCommand: 'trusted-qoder', + agentArgs: 'trusted args', + agentEnv: { TRUSTED: 'retained' } + } + } + if (failure === 'client_disconnected') { + connection.abort() + } + throw new Error(failure) + }) + runtime.setPtyController({ + spawn, + listProcesses: async () => { + if (inventoryUnavailable) { + throw new Error('host offline') + } + return live + }, + write: () => true, + kill, + getForegroundProcess: async () => null + }) + const send = vi.fn((channel: string, payload: { command?: string }) => { + if (channel === 'terminal:requestTabCreate') { + if (payload.command) { + acceptedCommands.push(payload.command) + } + if (failure === 'client_disconnected') { + connection.abort() + } + } + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { isDestroyed: () => false, send, setBackgroundThrottling: vi.fn() } + }) + const plan = buildAgentResumeStartupPlan({ + agent: 'qoder', + providerSession: { key: 'session_id', id: 'same-qoder-session' }, + cmdOverrides: {}, + platform: 'darwin', + agentArgs: '--captured-option', + ...(scenario === 'quoted-agent-command' ? { agentCommand: "'qodercli'" } : {}) + }) + expect(plan).not.toBeNull() + if (!plan) { + throw new Error('missing resume plan') + } + const resume = { + command: plan.launchCommand, + launchAgent: 'qoder' as const, + launchConfig: { ...plan.launchConfig, agentEnv: { OWNED_RECIPE: 'original value' } }, + activate: false, + select: false, + navigation: 'caller' as const, + clientNavigationId: 'paired-phone', + clientMutationId: 'stable-resume-mutation' + } + const first = runtime + .createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...resume, + signal: connection.signal + }) + .then( + () => null, + (error: unknown) => error + ) + await vi.advanceTimersByTimeAsync(10_001) + expect(await first).toBeInstanceOf(Error) + expect(kill).not.toHaveBeenCalled() + connection = new AbortController() + if (scenario === 'inventory-outage') { + inventoryUnavailable = true + await expect( + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...resume, + signal: connection.signal + }) + ).rejects.toThrow('runtime_unavailable') + inventoryUnavailable = false + } + const retryPromise = runtime + .createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...resume, + ...(scenario === 'changed-replay' + ? { launchConfig: { ...resume.launchConfig, agentEnv: { CHANGED: 'retry' } } } + : {}), + signal: connection.signal + }) + .catch(() => null) + await vi.advanceTimersByTimeAsync(10_001) + const retry = await retryPromise + expect(acceptedCommands).toHaveLength(1) + expect(acceptedCommands[0]).toBe( + scenario === 'quoted-agent-command' + ? "qoder '--resume' 'same-qoder-session'" + : "qoder '--captured-option' '--resume' 'same-qoder-session'" + ) + expect(retry?.tab).toMatchObject({ ptyId: 'qoder-live-resume', launchAgent: 'qoder' }) + expect(spawn).toHaveBeenCalledTimes(1) + expect(send).not.toHaveBeenCalled() + if (!retry) { + throw new Error('missing adopted resume') + } + const pane = runtime.resolveTerminalPane(`${retry.tab.parentTabId}:${retry.tab.leafId}`) + await runtime.focusTerminal(pane.handle) + expect(revealTerminalSession).toHaveBeenCalledWith( + TEST_WORKTREE_ID, + expect.objectContaining({ + launchAgent: 'qoder', + launchConfig: + scenario === 'surviving-recipe' + ? { + agentCommand: 'trusted-qoder', + agentArgs: 'trusted args', + agentEnv: { TRUSTED: 'retained' } + } + : expect.objectContaining({ + agentCommand: + scenario === 'quoted-agent-command' + ? "'qodercli'" + : "qoder '--captured-option'", + agentArgs: plan.launchConfig.agentArgs, + agentEnv: { OWNED_RECIPE: 'original value' } + }) + }) + ) + if (!revealedRecipe) { + throw new Error('missing revealed recipe') + } + const relaunch = buildAgentResumeStartupPlan({ + agent: 'qoder', + providerSession: { key: 'session_id', id: 'same-qoder-session' }, + cmdOverrides: { qoder: 'changed-default-command' }, + platform: 'darwin', + agentCommand: revealedRecipe.agentCommand, + agentArgs: revealedRecipe.agentArgs, + agentEnv: revealedRecipe.agentEnv + }) + if (!relaunch) { + throw new Error('missing relaunch') + } + const restoredSpawn = vi.fn().mockResolvedValue({ id: 'intentional-restored-qoder' }) + runtime.setPtyController({ + spawn: restoredSpawn, + write: () => true, + kill, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + command: relaunch.launchCommand, + launchAgent: 'qoder', + launchConfig: relaunch.launchConfig, + env: relaunch.env, + focus: false + }) + expect(restoredSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: + scenario === 'surviving-recipe' + ? "trusted-qoder '--resume' 'same-qoder-session'" + : acceptedCommands[0], + env: expect.objectContaining( + scenario === 'surviving-recipe' + ? { TRUSTED: 'retained' } + : { OWNED_RECIPE: 'original value' } + ) + }) + ) + expect(kill).not.toHaveBeenCalled() + if (scenario === 'wsl-inventory' && retry) { + expect( + runtime.resolveTerminalPane(`${retry.tab.parentTabId}:${retry.tab.leafId}`).hostPlatform + ).toBe('linux') + } + } finally { + vi.useRealTimers() + } + }) +}) + +it.each(['missing-original', 'different-agent'])( + 'refuses unproven adopted launch recipe: %s', + async (state) => { + const { TEST_WORKTREE_ID } = await import('./orca-runtime-test-fixtures.spec') + const { deriveRemoteRuntimeTerminalCreateHandle } = + await import('./remote-runtime-terminal-create-identity') + const handle = deriveRemoteRuntimeTerminalCreateHandle( + 'owned-phone', + TEST_WORKTREE_ID, + 'unknown-operation' + ) + const runtime = new QoderAdoptionRuntime(store) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const tabId = 'original-owned-tab' + const leafId = '55555555-5555-4555-8555-555555555555' + runtime.registerPty('unknown-owned-pty', TEST_WORKTREE_ID, null, { + tabId, + leafId, + terminalHandle: handle, + incarnationId: 'owned-prior-incarnation' + }) + const retained = runtime.retainedRecipe('unknown-owned-pty', TEST_WORKTREE_ID) + const originalPaneKey = retained.paneKey + const originalOwnership = retained.runtimeSessionOwned + if (state === 'different-agent') { + retained.launchAgent = 'codex' + } + const spawn = vi.fn() + const kill = vi.fn() + runtime.setPtyController({ + spawn, + kill, + write: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'unknown-owned-pty', + worktreeId: TEST_WORKTREE_ID, + terminalHandle: handle, + cwd: TEST_WORKTREE_PATH, + title: 'Unknown' + } + ] + }) + await expect( + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + command: 'qodercli --resume original', + launchAgent: 'qoder', + launchConfig: { + agentCommand: "'qodercli'", + agentArgs: '--guessed-retry', + agentEnv: { GUESSED: 'not-authoritative' } + }, + clientNavigationId: 'owned-phone', + clientMutationId: 'unknown-operation', + select: false + }) + ).rejects.toThrow( + state === 'different-agent' ? 'terminal_create_identity_conflict' : 'runtime_unavailable' + ) + expect(spawn).not.toHaveBeenCalled() + expect(kill).not.toHaveBeenCalled() + expect(retained.launchConfig).toBeNull() + expect(retained.launchAgent).toBe(state === 'different-agent' ? 'codex' : null) + expect(retained.launchToken).toBeNull() + expect(retained.launchIncarnationId).toBeNull() + expect(retained.tabId).toBe(tabId) + expect(retained.paneKey).toBe(originalPaneKey) + expect(retained.runtimeSessionOwned).toBe(originalOwnership) + expect(runtime.resolveTerminalPane(`${tabId}:${leafId}`).handle).toBe(handle) + } +) + +it('releases abandoned dispatch capacity without guessing an expired live recipe', async () => { + vi.useFakeTimers() + try { + const { TEST_WORKTREE_ID } = await import('./orca-runtime-test-fixtures.spec') + const { deriveRemoteRuntimeTerminalCreateHandle } = + await import('./remote-runtime-terminal-create-identity') + vi.mocked(detectAgentCommandsOnHost) + .mockReset() + .mockResolvedValue(new Set(['qoder'])) + const runtime = new OrcaRuntimeService(store) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + let accept = false + const inventory: { + id: string + worktreeId: string + terminalHandle: string + cwd: string + title: string + }[] = [] + const spawn = vi.fn(async () => { + if (accept) { + return { id: 'fresh-after-expiry' } + } + throw new Error('abandoned-create') + }) + const kill = vi.fn() + runtime.setPtyController({ + spawn, + kill, + write: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => inventory + }) + const request = { + command: 'qodercli --resume original', + launchAgent: 'qoder' as const, + launchConfig: { + agentCommand: 'qodercli', + agentArgs: '--original', + agentEnv: { ORIGINAL: 'owned' } + }, + clientNavigationId: 'owned-phone', + select: false + } + for (let index = 0; index < 4096; index++) { + await runtime + .createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...request, + clientMutationId: `abandoned-${index}` + }) + .catch((error: unknown) => { + if (!(error instanceof Error) || error.message !== 'abandoned-create') { + throw error + } + }) + } + accept = true + await expect( + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...request, + clientMutationId: 'fresh-mutation' + }) + ).rejects.toThrow('runtime_unavailable') + expect(spawn).toHaveBeenCalledTimes(4096) + await vi.advanceTimersByTimeAsync(14 * 60_000) + await expect( + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...request, + clientMutationId: 'fresh-mutation' + }) + ).rejects.toThrow('runtime_unavailable') + expect(spawn).toHaveBeenCalledTimes(4096) + await vi.advanceTimersByTimeAsync(2 * 60_000) + const fresh = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...request, + clientMutationId: 'fresh-mutation' + }) + expect(fresh.tab.ptyId).toBe('fresh-after-expiry') + inventory.push({ + id: 'expired-live-pty', + worktreeId: TEST_WORKTREE_ID, + terminalHandle: deriveRemoteRuntimeTerminalCreateHandle( + 'owned-phone', + TEST_WORKTREE_ID, + 'abandoned-0' + ), + cwd: TEST_WORKTREE_PATH, + title: 'Qoder' + }) + await expect( + runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + ...request, + launchConfig: { ...request.launchConfig, agentEnv: { GUESSED: 'retry' } }, + clientMutationId: 'abandoned-0' + }) + ).rejects.toThrow('runtime_unavailable') + expect(spawn).toHaveBeenCalledTimes(4097) + expect(kill).not.toHaveBeenCalled() + expect(inventory).toHaveLength(1) + } finally { + vi.useRealTimers() + } +}, 30_000) diff --git a/src/main/runtime/qoder-terminal-command-resolution.test.ts b/src/main/runtime/qoder-terminal-command-resolution.test.ts new file mode 100644 index 00000000000..30c2e189bd0 --- /dev/null +++ b/src/main/runtime/qoder-terminal-command-resolution.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' +import { buildAiVaultResumeShellCommand } from '../../shared/ai-vault-resume-command' +import { resolveQoderTerminalCommand } from './qoder-terminal-command-resolution' + +vi.mock('../preflight/agent-detection', () => ({ detectAgentCommandsOnHost: vi.fn() })) + +describe('execution-host Qoder command selection', () => { + it.each(['darwin', 'linux', 'win32'] as const)( + 'starts and resumes modern-only and legacy-only installs on %s', + async (platform) => { + const startup = buildAgentStartupPlan({ + agent: 'qoder', + prompt: 'qodercli is a prompt word', + cmdOverrides: {}, + platform + }) + const resume = buildAgentResumeStartupPlan({ + agent: 'qoder', + providerSession: { key: 'session_id', id: 'existing-session' }, + cmdOverrides: {}, + platform + }) + if (!startup || !resume) { + throw new Error('Missing Qoder plans') + } + const shell = platform === 'win32' ? 'powershell' : 'posix' + for (const found of [ + new Set(['qoder']), + new Set(['qodercli']), + new Set(['qoder', 'qodercli']) + ]) { + const selected = found.has('qodercli') ? 'qodercli' : 'qoder' + const detect = vi.fn(async () => found) + for (const plan of [startup, resume]) { + const result = await resolveQoderTerminalCommand( + { launchAgent: 'qoder', command: plan.launchCommand, launchConfig: plan.launchConfig }, + { shell }, + detect + ) + expect(result.command).toBe(plan.launchCommand.replace(/^qodercli/, selected)) + expect(result.launchConfig?.agentCommand).toBe(selected) + } + const historyCommand = buildAiVaultResumeShellCommand({ + resumeCommand: resume.launchCommand, + cwd: '/folder workspace', + platform, + shell + }) + const history = await resolveQoderTerminalCommand( + { launchAgent: 'qoder', command: historyCommand, launchConfig: resume.launchConfig }, + { shell }, + detect + ) + expect(history.command).toBe(historyCommand.replace(/qodercli(?=\s)/, selected)) + } + } + ) + + it('queries each execution host without sharing a result or falling back after contact loss', async () => { + const options = { launchAgent: 'qoder' as const, command: 'qodercli --resume existing-session' } + const detect = vi + .fn() + .mockResolvedValueOnce(new Set(['qoder'])) + .mockResolvedValueOnce(new Set(['qodercli'])) + .mockRejectedValueOnce(new Error('Host disconnected')) + const wsl = { shell: 'posix' as const, context: { wslDistro: 'Ubuntu' } } + const ssh = { shell: 'posix' as const, connectionId: 'ssh-host' } + expect((await resolveQoderTerminalCommand(options, wsl, detect)).command).toBe( + 'qoder --resume existing-session' + ) + expect((await resolveQoderTerminalCommand(options, ssh, detect)).command).toBe(options.command) + await expect(resolveQoderTerminalCommand(options, ssh, detect)).rejects.toThrow( + 'Host disconnected' + ) + expect(detect.mock.calls).toEqual([ + [['qodercli', 'qoder'], wsl], + [['qodercli', 'qoder'], ssh], + [['qodercli', 'qoder'], ssh] + ]) + }) + + it.each([ + '/custom/qodercli --resume id', + 'qoder --prompt-interactive hello', + 'echo qodercli', + 'qodercli-other' + ])('preserves explicit paths, modern commands and unrelated commands: %s', async (command) => { + const detect = vi.fn() + expect( + ( + await resolveQoderTerminalCommand( + { launchAgent: 'qoder', command }, + { shell: 'posix' }, + detect + ) + ).command + ).toBe(command) + expect(detect).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/qoder-terminal-command-resolution.ts b/src/main/runtime/qoder-terminal-command-resolution.ts new file mode 100644 index 00000000000..7e872db933c --- /dev/null +++ b/src/main/runtime/qoder-terminal-command-resolution.ts @@ -0,0 +1,96 @@ +import type { PreflightRuntimeContext } from '../preflight/agent-detection' +import { detectAgentCommandsOnHost } from '../preflight/agent-detection' +import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { + resolveStartupShell, + tokenizeStartupCommand, + type AgentStartupShell +} from '../../shared/tui-agent-startup-shell' +import type { TerminalCreateOptions } from './runtime-terminal-contracts' +import type { RuntimeStore } from './runtime-store-contract' +import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' +import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' + +export async function resolveQoderTerminalCommandForWorkspace( + launch: TerminalCreateOptions, + workspace: TerminalWorkspaceLaunchScope, + store: RuntimeStore | null | undefined, + platform: NodeJS.Platform +): Promise { + if (launch.launchAgent !== 'qoder') { + return launch + } + return resolveQoderTerminalCommand(launch, { + connectionId: workspace.connectionId, + context: + !workspace.connectionId && store + ? { + projectRuntime: resolveLocalProjectRuntimeForWorktreeId(store, workspace.id), + wslDistro: parseWslUncPath(workspace.path)?.distro + } + : undefined, + shell: resolveStartupShell( + platform, + resolveLocalWindowsAgentStartupShell({ + platform, + isRemote: Boolean(workspace.connectionId), + terminalWindowsShell: launch.shellOverride ?? store?.getSettings().terminalWindowsShell + }) + ) + }) +} + +export async function resolveQoderTerminalCommand< + T extends Pick +>( + options: T, + host: { + connectionId?: string | null + context?: PreflightRuntimeContext + shell: AgentStartupShell + }, + detect = detectAgentCommandsOnHost +): Promise { + if (options.launchAgent !== 'qoder' || !options.command) { + return options + } + const parsed = tokenizeStartupCommand(options.command, host.shell) + // Explicit paths and configured recipes remain owned by the caller. + if (!parsed.ok) { + return options + } + const executableIndex = parsed.tokens.findIndex( + (token, index) => + token === TUI_AGENT_CONFIG.qoder.detectCmd && + (index === 0 || + parsed.tokens[index - 1] === '&&' || + (parsed.tokens[0] === 'Set-Location' && parsed.tokens[index - 1].endsWith(';'))) + ) + if (executableIndex === -1) { + return options + } + const candidates = getTuiAgentDetectCommands(TUI_AGENT_CONFIG.qoder) + const found = await detect(candidates, host) + const selected = candidates.find((candidate) => found.has(candidate)) + if (!selected || selected === parsed.tokens[executableIndex]) { + return options + } + const first = parsed.spans[executableIndex] + if (!first) { + return options + } + return { + ...options, + command: options.command.slice(0, first.start) + selected + options.command.slice(first.end), + ...(options.launchConfig + ? { + launchConfig: { + ...options.launchConfig, + agentCommand: options.launchConfig.agentCommand?.replace(/^qodercli(?=\s|$)/, selected) + } + } + : {}) + } +} diff --git a/src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts b/src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts index 959c383ccc3..35d8f5bb097 100644 --- a/src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts +++ b/src/main/runtime/rpc/dispatcher-browser-client-automation.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { beforeEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import type { OrcaRuntimeService } from '../orca-runtime' diff --git a/src/main/runtime/rpc/dispatcher-computer-errors.test.ts b/src/main/runtime/rpc/dispatcher-computer-errors.test.ts index bbc37e57ab1..9640352faf7 100644 --- a/src/main/runtime/rpc/dispatcher-computer-errors.test.ts +++ b/src/main/runtime/rpc/dispatcher-computer-errors.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { RpcDispatcher } from './dispatcher' diff --git a/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts index d0f1e6fbc39..c4c9433b939 100644 --- a/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts +++ b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' diff --git a/src/main/runtime/rpc/methods/agent-launch-failure-code.ts b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts index eac21c7c00e..bf3b4a2ceaa 100644 --- a/src/main/runtime/rpc/methods/agent-launch-failure-code.ts +++ b/src/main/runtime/rpc/methods/agent-launch-failure-code.ts @@ -1,3 +1,4 @@ +import { OrchestrationError } from '../../orchestration/orchestration-error' import type { AgentLaunchTarget } from '../../../../shared/agent-launch-intent' import { WorktreeCreateCollisionError, @@ -27,7 +28,8 @@ const LAUNCH_FAILURE_CODE_MAX_LENGTH = 128 * the entire store. */ export function agentLaunchFailureCode(error: unknown): string { - const code = error instanceof Error ? error.message : '' + const code = + error instanceof OrchestrationError ? error.code : error instanceof Error ? error.message : '' return code.length > 0 ? code.slice(0, LAUNCH_FAILURE_CODE_MAX_LENGTH) : 'agent_launch_failed' } diff --git a/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts b/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts index d1d419a4c56..3cc0806f680 100644 --- a/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' /** * The pane a caller reserves for the terminal `agent.launch` creates. * diff --git a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts index cfc14ec5504..15a8b3a8499 100644 --- a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' /** * A terminal launch that fails before its spawn is requested — no launch command, runtime * unavailable — created nothing, so a named operation settles as failed with its real cause. @@ -8,6 +9,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentLaunchFingerprint } from '../../../../shared/agent-launch-operation' import type { AgentSessionRecordStore } from '../../agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' @@ -32,7 +34,12 @@ const CREATE_LAUNCH = { target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } } } const NO_LAUNCH_COMMAND = 'Could not build launch command for claude.' -type Launch = typeof EXISTING_LAUNCH | typeof CREATE_LAUNCH +type Launch = { + agent: string + target: { kind: string; worktree?: string; create?: Readonly> } + sessionOptions?: Readonly> + reuseTerminal?: { handle: string } +} /** The create throws; `afterDispatch` says whether the spawn request had already left. */ function failingCreate(runtime: AgentLaunchRuntimeStub, error: Error, afterDispatch: boolean) { @@ -169,4 +176,75 @@ describe('a launch whose terminal fails', () => { error: { code: 'agent_session_operation_unknown' } }) }) + it.each([ + { ...CREATE_LAUNCH, agent: 'opencode', sessionOptions: { model: 'private-proof/model-b' } }, + { + ...EXISTING_LAUNCH, + agent: 'opencode', + reuseTerminal: { handle: 'term_existing' }, + sessionOptions: { model: 'private-proof/model-b' } + } + ])('records and replays a model refusal before adapter effects', async (launch) => { + const first = runtimeStub({ settings: {} }) + expect(await replay(first, launch)).toMatchObject({ + ok: false, + error: { code: 'capability_unsupported' } + }) + expect(outcomeOf(OPERATION_ID)).toMatchObject({ + status: 'failed', + code: 'capability_unsupported' + }) + expect(first.showRepo).not.toHaveBeenCalled() + expect(first.createManagedWorktree).not.toHaveBeenCalled() + expect(first.createTerminal).not.toHaveBeenCalled() + const retry = runtimeStub({ settings: {} }) + expect(await replay(retry, launch)).toMatchObject({ + ok: false, + error: { code: 'capability_unsupported' } + }) + expect(retry.showTerminalWorkspaceLaunchScope).not.toHaveBeenCalled() + expect(retry.createManagedWorktree).not.toHaveBeenCalled() + }) + + it('preserves a successful recorded model launch even when its placement is now refused', async () => { + const launch = AGENT_LAUNCH_REPLAY.params.parse({ + ...CREATE_LAUNCH, + agent: 'opencode', + sessionOptions: { model: 'private-proof/model-b' }, + operationId: OPERATION_ID + }) + const callerKey = 'trusted-local:runtime' + await store.admitOperation({ + callerKey, + operationId: OPERATION_ID, + fingerprint: computeAgentLaunchFingerprint(launch), + now: Date.now() + }) + await store.claimOperation({ callerKey, operationId: OPERATION_ID }) + await store.recordOperationOutcome({ + callerKey, + operationId: OPERATION_ID, + outcome: { + status: 'succeeded', + sessionId: '', + launch: { + outcome: { kind: 'terminal', handle: 'term_historical' }, + worktreeId: 'wt_historical', + receipt: { + mode: 'terminal', + preferred: 'terminal', + reason: 'user_default', + detail: 'Previously recorded launch' + } + } + } + }) + const runtime = runtimeStub({ settings: {} }) + expect(await replay(runtime, launch)).toMatchObject({ + ok: true, + result: { outcome: { handle: 'term_historical' }, worktreeId: 'wt_historical' } + }) + expect(runtime.showRepo).not.toHaveBeenCalled() + expect(runtime.createManagedWorktree).not.toHaveBeenCalled() + }) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index 24d0a80b3d1..9e725c7b517 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' /** * Replay safety for `agent.launch`, against the real durable ledger. * diff --git a/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts b/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts index 528490d1d0a..720f0819383 100644 --- a/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' /** * The chat session a caller reserves for the structured launch `agent.launch` creates. * diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 1c7448e5ca2..574dfd1a30f 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -31,6 +31,7 @@ import { WorktreeCreateCollisionError, WORKTREE_CREATE_COLLISION_CODE } from '../../../../shared/new-workspace/worktree-create-collision' +import { assertOpenCodeModelLaunchPreferencesAbsent } from '../../../opencode/opencode-model-startup-plan' import { executeAgentLaunch } from '../../../agent-launch/agent-launch-executor' import { trackTerminalSpawnDispatch, @@ -137,6 +138,9 @@ async function resolveUnlaunchedIntent( params: AgentLaunchParams, runtime: OrcaRuntimeService ): Promise { + if (params.reuseTerminal || params.target.kind === 'create-worktree') { + assertOpenCodeModelLaunchPreferencesAbsent(params.agent, params.sessionOptions) + } const intent = await agentLaunchIntent(params, runtime) await validateReusedTerminal(intent, runtime) return intent @@ -237,7 +241,7 @@ async function executeReplaySafeAgentLaunch( ): Promise { const admission = await admitAgentLaunchOperation(context, params, fingerprint) if (admission.decision === 'refuse') { - throw new Error(admission.refusal.code) + throw Object.assign(new Error(admission.refusal.code), { code: admission.refusal.code }) } if (admission.decision === 'replay') { return admission.result diff --git a/src/main/runtime/rpc/methods/agent-session.test.ts b/src/main/runtime/rpc/methods/agent-session.test.ts index f234dce1536..ed67179be57 100644 --- a/src/main/runtime/rpc/methods/agent-session.test.ts +++ b/src/main/runtime/rpc/methods/agent-session.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, diff --git a/src/main/runtime/rpc/methods/ai-vault-search.test.ts b/src/main/runtime/rpc/methods/ai-vault-search.test.ts index c247b0f31b1..7ed8ed4ffa4 100644 --- a/src/main/runtime/rpc/methods/ai-vault-search.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault-search.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it, vi } from 'vitest' import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' import { RpcDispatcher } from '../dispatcher' diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index 7848ddcd033..2a179b847e2 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts b/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts index 6af7ba467e9..7a3eacc7857 100644 --- a/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts +++ b/src/main/runtime/rpc/methods/automation-scoped-list-methods.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' /** * The runtime end of the list contract: an old client that sends no params must * keep receiving the authority's complete list through the legacy field while diff --git a/src/main/runtime/rpc/methods/automations.test.ts b/src/main/runtime/rpc/methods/automations.test.ts index ab768559749..be4d4ac58ca 100644 --- a/src/main/runtime/rpc/methods/automations.test.ts +++ b/src/main/runtime/rpc/methods/automations.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/browser-client-host-attach-adoption.test.ts b/src/main/runtime/rpc/methods/browser-client-host-attach-adoption.test.ts index 0d6bf3b80ef..21a5aa508a8 100644 --- a/src/main/runtime/rpc/methods/browser-client-host-attach-adoption.test.ts +++ b/src/main/runtime/rpc/methods/browser-client-host-attach-adoption.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { BrowserClientHostedPageInventory } from '../../../../shared/browser-client-host-protocol' diff --git a/src/main/runtime/rpc/methods/browser-client-host-reconciliation.test.ts b/src/main/runtime/rpc/methods/browser-client-host-reconciliation.test.ts index 5798ff1db42..8dbeedffb45 100644 --- a/src/main/runtime/rpc/methods/browser-client-host-reconciliation.test.ts +++ b/src/main/runtime/rpc/methods/browser-client-host-reconciliation.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { getBrowserHostLeaseRegistry } from '../../browser-host-lease-registry-instance' diff --git a/src/main/runtime/rpc/methods/browser-client-host.test.ts b/src/main/runtime/rpc/methods/browser-client-host.test.ts index cfd61e448a4..6226f9c6dd3 100644 --- a/src/main/runtime/rpc/methods/browser-client-host.test.ts +++ b/src/main/runtime/rpc/methods/browser-client-host.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { getBrowserHostLeaseRegistry } from '../../browser-host-lease-registry-instance' diff --git a/src/main/runtime/rpc/methods/browser-client-page-metadata.test.ts b/src/main/runtime/rpc/methods/browser-client-page-metadata.test.ts index 589a114aa94..254584ef9c6 100644 --- a/src/main/runtime/rpc/methods/browser-client-page-metadata.test.ts +++ b/src/main/runtime/rpc/methods/browser-client-page-metadata.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, diff --git a/src/main/runtime/rpc/methods/browser-identity-rpc.test.ts b/src/main/runtime/rpc/methods/browser-identity-rpc.test.ts index c10abb06bb4..f639522339c 100644 --- a/src/main/runtime/rpc/methods/browser-identity-rpc.test.ts +++ b/src/main/runtime/rpc/methods/browser-identity-rpc.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ diff --git a/src/main/runtime/rpc/methods/browser-network-tunnel.test.ts b/src/main/runtime/rpc/methods/browser-network-tunnel.test.ts index a34ea13602a..47779581881 100644 --- a/src/main/runtime/rpc/methods/browser-network-tunnel.test.ts +++ b/src/main/runtime/rpc/methods/browser-network-tunnel.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, diff --git a/src/main/runtime/rpc/methods/browser.test.ts b/src/main/runtime/rpc/methods/browser.test.ts index b5df3a6616b..962d2ea1a11 100644 --- a/src/main/runtime/rpc/methods/browser.test.ts +++ b/src/main/runtime/rpc/methods/browser.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/client-native-chat-settings.test.ts b/src/main/runtime/rpc/methods/client-native-chat-settings.test.ts index e26ed4acae3..dcd92f86cf6 100644 --- a/src/main/runtime/rpc/methods/client-native-chat-settings.test.ts +++ b/src/main/runtime/rpc/methods/client-native-chat-settings.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts index c6f4865dce7..5bcaca1fd0e 100644 --- a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts +++ b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { getDefaultUIState } from '../../../../shared/constants' import { PAIRING_LOCAL_UI_FIELDS } from '../../../../shared/pairing-local-ui-fields' diff --git a/src/main/runtime/rpc/methods/client-ui-task-resume-state.test.ts b/src/main/runtime/rpc/methods/client-ui-task-resume-state.test.ts index 909975923ae..22f3e3a5a7c 100644 --- a/src/main/runtime/rpc/methods/client-ui-task-resume-state.test.ts +++ b/src/main/runtime/rpc/methods/client-ui-task-resume-state.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { getDefaultUIState } from '../../../../shared/constants' import type { OrcaRuntimeService } from '../../orca-runtime' diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index e2cf1289636..cf47bc56454 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { getDefaultUIState } from '../../../../shared/constants' import { omitPairingLocalUiFields } from '../../../../shared/pairing-local-ui-fields' diff --git a/src/main/runtime/rpc/methods/clipboard.test.ts b/src/main/runtime/rpc/methods/clipboard.test.ts index c0d224b85c6..d68f06dbee0 100644 --- a/src/main/runtime/rpc/methods/clipboard.test.ts +++ b/src/main/runtime/rpc/methods/clipboard.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest, RpcResponse } from '../core' diff --git a/src/main/runtime/rpc/methods/diagnostics.test.ts b/src/main/runtime/rpc/methods/diagnostics.test.ts index 82fc1fa9b75..dab6b8fe85d 100644 --- a/src/main/runtime/rpc/methods/diagnostics.test.ts +++ b/src/main/runtime/rpc/methods/diagnostics.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/files-base64-padding.test.ts b/src/main/runtime/rpc/methods/files-base64-padding.test.ts index 03f3b73e786..5729c2df071 100644 --- a/src/main/runtime/rpc/methods/files-base64-padding.test.ts +++ b/src/main/runtime/rpc/methods/files-base64-padding.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' diff --git a/src/main/runtime/rpc/methods/files-doc-preview.test.ts b/src/main/runtime/rpc/methods/files-doc-preview.test.ts index e512856c04a..cd8616ba1a9 100644 --- a/src/main/runtime/rpc/methods/files-doc-preview.test.ts +++ b/src/main/runtime/rpc/methods/files-doc-preview.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/files-list-all-page-size.test.ts b/src/main/runtime/rpc/methods/files-list-all-page-size.test.ts index 826d0c0f3f0..bb6f362b2ce 100644 --- a/src/main/runtime/rpc/methods/files-list-all-page-size.test.ts +++ b/src/main/runtime/rpc/methods/files-list-all-page-size.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' /** * #12547: `files.listAll` did not declare `maxResults`, so "the client names its cap and a full page * means there is more" was wired only on the Electron IPC hop. Web and mobile were saved incidentally, diff --git a/src/main/runtime/rpc/methods/files-open-navigation.test.ts b/src/main/runtime/rpc/methods/files-open-navigation.test.ts index 0d3ee7b1c0d..d7d8809c512 100644 --- a/src/main/runtime/rpc/methods/files-open-navigation.test.ts +++ b/src/main/runtime/rpc/methods/files-open-navigation.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/files-path-search.test.ts b/src/main/runtime/rpc/methods/files-path-search.test.ts index 38938228b8c..f5a246ba54a 100644 --- a/src/main/runtime/rpc/methods/files-path-search.test.ts +++ b/src/main/runtime/rpc/methods/files-path-search.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/files-preview-transport-budget.test.ts b/src/main/runtime/rpc/methods/files-preview-transport-budget.test.ts index c36a34983a9..51d6ea3a268 100644 --- a/src/main/runtime/rpc/methods/files-preview-transport-budget.test.ts +++ b/src/main/runtime/rpc/methods/files-preview-transport-budget.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { remoteRpcContentBudget } from '../../../../shared/remote-rpc-content-budget' import type { OrcaRuntimeService } from '../../orca-runtime' diff --git a/src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts b/src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts index d589b74116c..0fccbddb180 100644 --- a/src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts +++ b/src/main/runtime/rpc/methods/files-terminal-path-resolution.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/files-watch-cancellation.test.ts b/src/main/runtime/rpc/methods/files-watch-cancellation.test.ts index 067c23aa83a..95d458c05a4 100644 --- a/src/main/runtime/rpc/methods/files-watch-cancellation.test.ts +++ b/src/main/runtime/rpc/methods/files-watch-cancellation.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { RpcRequest } from '../core' import { RpcDispatcher } from '../dispatcher' diff --git a/src/main/runtime/rpc/methods/files-watch-cleanup.test.ts b/src/main/runtime/rpc/methods/files-watch-cleanup.test.ts index 991dc23b732..a16fbacc44a 100644 --- a/src/main/runtime/rpc/methods/files-watch-cleanup.test.ts +++ b/src/main/runtime/rpc/methods/files-watch-cleanup.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { expect, it, vi } from 'vitest' import { WatcherProcessFailure } from '../../../ipc/parcel-watcher-process-failure' import type { OrcaRuntimeService } from '../../orca-runtime' diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index 2b29e9b65b6..8c95a7cfa33 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts b/src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts index 72ae885c9b7..13f740259c2 100644 --- a/src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts +++ b/src/main/runtime/rpc/methods/git-diff-transport-budget.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' // Why: git.diff, git.branchDiff and git.commitDiff all return a GitDiffResult, so capping only the // first would leave the other two able to kill a remote socket. import { describe, expect, it, vi } from 'vitest' diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index 34ec308bfb4..efb6a3fcb5c 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/github-pr-refresh-reason.test.ts b/src/main/runtime/rpc/methods/github-pr-refresh-reason.test.ts index 967bafa516e..2ae4fc9300f 100644 --- a/src/main/runtime/rpc/methods/github-pr-refresh-reason.test.ts +++ b/src/main/runtime/rpc/methods/github-pr-refresh-reason.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' // Split from github.test.ts to keep it under its line cap. import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index 1ed202d2f57..bd333234cd0 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/gitlab.test.ts b/src/main/runtime/rpc/methods/gitlab.test.ts index a5cda8dc144..ef55c1fc64d 100644 --- a/src/main/runtime/rpc/methods/gitlab.test.ts +++ b/src/main/runtime/rpc/methods/gitlab.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/host-capabilities.test.ts b/src/main/runtime/rpc/methods/host-capabilities.test.ts index 8e2f15cd25c..726aa5d90a7 100644 --- a/src/main/runtime/rpc/methods/host-capabilities.test.ts +++ b/src/main/runtime/rpc/methods/host-capabilities.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { beforeEach, describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/hosted-review.test.ts b/src/main/runtime/rpc/methods/hosted-review.test.ts index d4c7e6ac645..9d4ca356b0d 100644 --- a/src/main/runtime/rpc/methods/hosted-review.test.ts +++ b/src/main/runtime/rpc/methods/hosted-review.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/jira.test.ts b/src/main/runtime/rpc/methods/jira.test.ts index e5e6bfba634..1652819fec0 100644 --- a/src/main/runtime/rpc/methods/jira.test.ts +++ b/src/main/runtime/rpc/methods/jira.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/linear-agent-access.test.ts b/src/main/runtime/rpc/methods/linear-agent-access.test.ts index 511378aa141..e44fe0b38dc 100644 --- a/src/main/runtime/rpc/methods/linear-agent-access.test.ts +++ b/src/main/runtime/rpc/methods/linear-agent-access.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/linear.test.ts b/src/main/runtime/rpc/methods/linear.test.ts index 1e9b430fa8a..890dc18d43f 100644 --- a/src/main/runtime/rpc/methods/linear.test.ts +++ b/src/main/runtime/rpc/methods/linear.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/orchestration-dispatch-error-codes.test.ts b/src/main/runtime/rpc/methods/orchestration-dispatch-error-codes.test.ts index 918724e6aaf..659523822b6 100644 --- a/src/main/runtime/rpc/methods/orchestration-dispatch-error-codes.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-dispatch-error-codes.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it, vi } from 'vitest' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' import { diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federated-message-targeting.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federated-message-targeting.test.ts index 6d3f00abd6a..6d92c69e4b9 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federated-message-targeting.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federated-message-targeting.test.ts @@ -1,3 +1,4 @@ +import '../../../unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it, vi } from 'vitest' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../../../shared/protocol-version' import { OrcaRuntimeService } from '../../../../orca-runtime' diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-control-mail.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-control-mail.test.ts index c95ec9b5630..1579f0132d9 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-control-mail.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-control-mail.test.ts @@ -1,3 +1,4 @@ +import '../../../unused-default-rpc-methods.test-fixture' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../../../shared/protocol-version' import type { RuntimeRpcResponse } from '../../../../../../shared/runtime-rpc-envelope' diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts index 94341ac9c6f..33e860b74e6 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts @@ -1,3 +1,4 @@ +import '../../../unused-default-rpc-methods.test-fixture' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ORCHESTRATION_CONTRACT_VERSION, diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-output.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-output.test.ts index cb298ad873f..374a9b9be66 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-output.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-output.test.ts @@ -1,3 +1,4 @@ +import '../../../unused-default-rpc-methods.test-fixture' import { mkdtempSync, rmSync } from 'node:fs' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts index 450f9ff9a5f..a2c692dd0fb 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts @@ -1,3 +1,4 @@ +import '../../../unused-default-rpc-methods.test-fixture' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ORCHESTRATION_CONTRACT_VERSION, diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index e88004ff89e..0d6bde6e104 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -20,7 +20,7 @@ import { } from './federation-setup' import { FederationAttachStartParams } from './federation-start-schema' import { failFederatedAttachmentWithReceipt } from './federation-start-receipt' -import { prepareFederationConfiguredWorkerStart } from '../worker/worker-configured-agent-preflight' +import { prepareFederationWorkerLaunchOnHost } from '../worker/worker-opencode-model-preflight' import { isWorkerStartTimeoutWithinTimerLimit, resolveWorkerStartReadinessTimeoutMs @@ -54,7 +54,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ ) } const createsWorktree = params.worktree === 'new-top-level' - const { agent, launch } = await prepareFederationConfiguredWorkerStart({ + const { agent, launch } = await prepareFederationWorkerLaunchOnHost({ params, createsWorktree, runtime diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.test.ts index cf78bfa6636..5e45e97fd7a 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.test.ts @@ -1,3 +1,4 @@ +import '../../../unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it, vi } from 'vitest' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../../../shared/protocol-version' import type { RuntimeTerminalSummary } from '../../../../../../shared/runtime-types' diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-invalid-type.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-invalid-type.test.ts index 3ace8ba5058..d1527338b95 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-invalid-type.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-invalid-type.test.ts @@ -1,3 +1,4 @@ +import '../../../unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it } from 'vitest' import type { RpcRequest } from '../../../core' import { ORCHESTRATION_METHODS } from '../../orchestration' diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts index 6d4e1e1c19b..c3c8f2e1bdf 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts @@ -1,3 +1,4 @@ +import '../../../unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcContext, RpcRequest } from '../../../core' import { ORCHESTRATION_METHODS } from '../../orchestration' diff --git a/src/main/runtime/rpc/methods/orchestration/runs/migration-behavior.test.ts b/src/main/runtime/rpc/methods/orchestration/runs/migration-behavior.test.ts index 929dd5c1ee3..d384b2c7255 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/migration-behavior.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/migration-behavior.test.ts @@ -1,3 +1,4 @@ +import '../../../unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it, vi } from 'vitest' import type { RuntimeRpcResponse } from '../../../../../../shared/runtime-rpc-envelope' import { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index c6d7ef96f68..c964c6f1e17 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -1,3 +1,4 @@ +import { probeWorkerOpenCodeModelLaunchSupport } from './worker-opencode-model-preflight' import { resolveWorkerConfiguredAgentParams } from './worker-configured-agent-preflight' import { waitForWorkerAgentReady } from '../../../../launched-agent-composer-readiness' import type { OrcaRuntimeService } from '../../../../orca-runtime' @@ -71,10 +72,25 @@ export async function startLocalWorker(args: { worktree: requestedWorktree === 'current' ? `id:${callerWorkspaceId}` : requestedWorktree } }) + let openCodeModelLaunchSupported = false + if (!createsWorktree && launchParams.agent === 'opencode' && launchParams.model) { + const callerWorkspaceId = await resolveDispatchCallerWorktreeId( + runtime, + params.from, + callerSession + ) + openCodeModelLaunchSupported = await probeWorkerOpenCodeModelLaunchSupport( + runtime, + launchParams, + { worktree: requestedWorktree === 'current' ? `id:${callerWorkspaceId}` : requestedWorktree } + ) + } + const { agent, launch } = prepareLocalWorkerStart({ params: launchParams, createsWorktree, - runtime + runtime, + openCodeModelLaunchSupported }) const coordinatorWorktreeId = await resolveDispatchCallerWorktreeId( diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts index 899a63c0683..f79094d8d86 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts @@ -117,10 +117,48 @@ describe('orchestration worker launch preferences', () => { ).toThrow('does not support effort turbo') }) - it('refuses an opencode model because the opencode 2 TUI rejects --model', () => { + it('refuses an opencode model until the execution host verifies legacy TUI support', () => { expect(() => resolveWorkerLaunchPreferences({ agent: 'opencode', model: 'meta/muse-spark-1.3' }) - ).toThrow('does not support launch-time model selection') + ).toThrow('cannot verify launch-time model selection') + }) + + it('explains the existing-worktree requirement even if a new-worktree host is verified', () => { + expect(() => + resolveWorkerLaunchPreferences({ + agent: 'opencode', + model: 'opencode/fledge-alpha-free', + createsWorktree: true, + openCodeModelLaunchSupported: true + }) + ).toThrow('requires an existing worktree') + }) + + it('supports an opaque OpenCode model on a verified legacy host', () => { + expect( + resolveWorkerLaunchPreferences({ + agent: 'opencode', + model: 'zai-coding-plan/glm-5.3-flash', + openCodeModelLaunchSupported: true + }) + ).toEqual({ + preferences: { model: 'zai-coding-plan/glm-5.3-flash' }, + receipt: { + requested: { agent: 'opencode', model: 'zai-coding-plan/glm-5.3-flash', effort: null }, + effective: { agent: 'opencode', model: 'zai-coding-plan/glm-5.3-flash', effort: null } + } + }) + }) + + it('refuses an unverified OpenCode effort on a legacy host', () => { + expect(() => + resolveWorkerLaunchPreferences({ + agent: 'opencode', + model: 'opencode/fledge-alpha-free', + effort: 'high', + openCodeModelLaunchSupported: true + }) + ).toThrow('does not support effort high') }) it('does not invent an effort when only a model is requested', () => { @@ -157,23 +195,28 @@ describe('orchestration worker launch preferences', () => { }, { model: 'gpt-5.4', - accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'], - rejected: ['max', 'ultra', 'future-effort'] + accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + rejected: ['future-effort'] }, { model: 'gpt-5.4-mini', - accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'], - rejected: ['max', 'ultra', 'future-effort'] + accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + rejected: ['future-effort'] }, { model: 'gpt-5.3-codex-spark', - accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'], - rejected: ['max', 'ultra', 'future-effort'] + accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + rejected: ['future-effort'] + }, + { + model: 'gpt-6.1-sol', + accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + rejected: ['turbo', 'future-effort'] }, { model: 'future-codex-model', - accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'], - rejected: ['max', 'ultra', 'future-effort'] + accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + rejected: ['future-effort'] } ])('enforces the Codex effort ceiling for $model', ({ model, accepted, rejected }) => { const catalog = getAgentSessionOptionCatalog('codex')! diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts index d998d8e5e71..a5b12d7d46a 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts @@ -1,10 +1,12 @@ import type { AgentLaunchPreferences } from '../../../../../../shared/agent-session-host-authority' import { findCatalogModel, - findCatalogOption, - getAgentSessionOptionCatalog + findCatalogOption } from '../../../../../../shared/agent-session-option-catalog' -import { resolveAgentSessionOptionLaunch } from '../../../../../../shared/agent-session-option-launch' +import { + getAgentSessionOptionLaunchCatalog, + resolveAgentSessionOptionLaunch +} from '../../../../../../shared/agent-session-option-launch' import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version' import type { TuiAgent } from '../../../../../../shared/tui-agent' import { OrchestrationError } from '../../../../orchestration/orchestration-error' @@ -50,6 +52,8 @@ export function createPendingWorkerLaunchReceipt(args: { export function resolveWorkerLaunchPreferences(args: { agent: TuiAgent + openCodeModelLaunchSupported?: boolean + createsWorktree?: boolean model?: string effort?: string }): { @@ -66,7 +70,21 @@ export function resolveWorkerLaunchPreferences(args: { } } - const catalog = getAgentSessionOptionCatalog(args.agent) + if (args.agent === 'opencode' && args.createsWorktree) { + throw new OrchestrationError( + 'capability_unsupported', + 'OpenCode model selection requires an existing worktree. Use --worktree current or an existing worktree selector, or omit --model.' + ) + } + + if (args.agent === 'opencode' && args.openCodeModelLaunchSupported !== true) { + throw new OrchestrationError( + 'capability_unsupported', + 'This OpenCode TUI cannot verify launch-time model selection. Omit --model or use a supported OpenCode CLI.' + ) + } + + const catalog = getAgentSessionOptionLaunchCatalog(args.agent) if (!catalog?.supportsWorkerLaunchPreferences || !catalog.modelApply.launchArgs) { throw new OrchestrationError( 'invalid_argument', diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-opencode-model-preflight.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-opencode-model-preflight.ts new file mode 100644 index 00000000000..af0c4aa8db7 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-opencode-model-preflight.ts @@ -0,0 +1,36 @@ +import { + prepareFederationConfiguredWorkerStart, + resolveWorkerConfiguredAgentParams +} from './worker-configured-agent-preflight' +import type { prepareFederationAttachmentWorkerStart } from './worker-start-validation' +import type { OrcaRuntimeService } from '../../../../orca-runtime' + +export async function probeWorkerOpenCodeModelLaunchSupport( + runtime: OrcaRuntimeService, + params: { agent?: string; model?: string }, + target: { worktree?: string } +): Promise { + return Boolean( + params.model && + params.agent && + runtime.resolveOrchestrationAgentLauncher(params.agent) === 'opencode' && + (await runtime.probeOrchestrationOpenCodeModelLaunchSupport({ ...target, model: params.model })) + ) +} + +export async function prepareFederationWorkerLaunchOnHost( + args: Omit< + Parameters[0], + 'openCodeModelLaunchSupported' + > +) { + const params = await resolveWorkerConfiguredAgentParams(args.runtime, args.params, async () => + args.createsWorktree ? { repo: args.params.repo } : { worktree: args.params.worktree } + ) + const openCodeModelLaunchSupported = + !args.createsWorktree && + (await probeWorkerOpenCodeModelLaunchSupport(args.runtime, params, { + worktree: params.worktree + })) + return prepareFederationConfiguredWorkerStart({ ...args, params, openCodeModelLaunchSupported }) +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts index cba8baad45f..73fa180dc54 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts @@ -1,3 +1,4 @@ +import '../../../unused-default-rpc-methods.test-fixture' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts index 1f80eebcb34..b9e39abb859 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts @@ -52,6 +52,7 @@ export function prepareLocalWorkerStart(args: { params: WorkerStartInput createsWorktree: boolean runtime: OrcaRuntimeService + openCodeModelLaunchSupported?: boolean }): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } { const { params, createsWorktree, runtime } = args assertWorkerLaunchPreferencesCreateTerminal(params) @@ -78,6 +79,8 @@ export function prepareLocalWorkerStart(args: { } return resolveWorkerStartAgent({ runtime, + openCodeModelLaunchSupported: args.openCodeModelLaunchSupported, + createsWorktree, terminal: params.terminal, agent: params.agent, model: params.model, @@ -90,6 +93,7 @@ export function prepareFederationAttachmentWorkerStart(args: { params: FederationAttachStartInput createsWorktree: boolean runtime: OrcaRuntimeService + openCodeModelLaunchSupported?: boolean }): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } { const { params, createsWorktree, runtime } = args assertWorkerLaunchPreferencesCreateTerminal(params) @@ -122,6 +126,8 @@ export function prepareFederationAttachmentWorkerStart(args: { } return resolveWorkerStartAgent({ runtime, + openCodeModelLaunchSupported: args.openCodeModelLaunchSupported, + createsWorktree, terminal: params.terminal, agent: params.agent, model: params.model, @@ -133,6 +139,8 @@ export function prepareFederationAttachmentWorkerStart(args: { function resolveWorkerStartAgent(args: { runtime: OrcaRuntimeService + openCodeModelLaunchSupported?: boolean + createsWorktree: boolean terminal?: string agent?: string model?: string @@ -153,6 +161,8 @@ function resolveWorkerStartAgent(args: { agent, launch: resolveWorkerLaunchPreferences({ agent, + openCodeModelLaunchSupported: args.openCodeModelLaunchSupported, + createsWorktree: args.createsWorktree, model: args.model, effort: args.effort }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index fc7c1f3b26c..f52c50a1f1f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -1,3 +1,4 @@ +import '../../../unused-default-rpc-methods.test-fixture' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -8,6 +9,8 @@ import { OrchestrationDb } from '../../../../orchestration/db' import { RpcDispatcher } from '../../../dispatcher' import type { RpcRequest } from '../../../core' import { ORCHESTRATION_METHODS } from '../../orchestration' +import { prepareFederationWorkerLaunchOnHost } from './worker-opencode-model-preflight' +import { FederationAttachStartParams } from '../federation/federation-start-schema' describe('orchestration new-worktree workers', () => { type CreateWorktreeResult = Awaited> @@ -189,6 +192,67 @@ describe('orchestration new-worktree workers', () => { }) }) + it.each(['new-child', 'new-top-level'])( + 'refuses an OpenCode model for %s without probing', + async (worktree) => { + mockCreatedWorktree() + const probe = vi + .spyOn(runtime, 'probeOrchestrationOpenCodeModelLaunchSupport') + .mockResolvedValue(true) + await expect( + startWorker({ + worktree, + repo: 'repo', + agent: 'opencode', + model: 'opencode/fledge-alpha-free' + }) + ).rejects.toMatchObject({ + code: 'capability_unsupported', + message: expect.stringContaining('requires an existing worktree') + }) + expect(probe).not.toHaveBeenCalled() + expect(runtime.createManagedWorktree).not.toHaveBeenCalled() + expect(runtime.createTerminal).not.toHaveBeenCalled() + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + } + ) + + it('refuses a federated new-worktree model before probing its repository', async () => { + const probe = vi + .spyOn(runtime, 'probeOrchestrationOpenCodeModelLaunchSupport') + .mockResolvedValue(true) + const params = FederationAttachStartParams.parse({ + dispatchId: 'ctx_new', + taskId: 'task_new', + taskSpec: 'No effects', + protocolVersion: 3, + worktree: 'new-top-level', + name: 'new-worker', + repo: 'repo', + agent: 'opencode', + model: 'opencode/fledge-alpha-free' + }) + await expect( + prepareFederationWorkerLaunchOnHost({ runtime, params, createsWorktree: true }) + ).rejects.toMatchObject({ + code: 'capability_unsupported', + message: expect.stringContaining('requires an existing worktree') + }) + expect(probe).not.toHaveBeenCalled() + expect(runtime.createTerminal).not.toHaveBeenCalled() + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) + + it('rejects an unsupported OpenCode model before creating a worker', async () => { + const create = vi.spyOn(runtime, 'createManagedWorktree') + vi.spyOn(runtime, 'probeOrchestrationOpenCodeModelLaunchSupport').mockResolvedValue(false) + await expect( + startWorker({ agent: 'opencode', model: 'opencode/fledge-alpha-free' }) + ).rejects.toMatchObject({ code: 'capability_unsupported' }) + expect(create).not.toHaveBeenCalled() + expect(runtime.createTerminal).not.toHaveBeenCalled() + }) + it('rejects a new worktree for a folder project before creating effects', async () => { vi.mocked(runtime.showRepo).mockResolvedValue({ id: 'repo', diff --git a/src/main/runtime/rpc/methods/pairing.test.ts b/src/main/runtime/rpc/methods/pairing.test.ts index 58a12a1f058..05f896dec4c 100644 --- a/src/main/runtime/rpc/methods/pairing.test.ts +++ b/src/main/runtime/rpc/methods/pairing.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index 5b453f9ce40..88fa0bf2057 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/repo-badge-color.test.ts b/src/main/runtime/rpc/methods/repo-badge-color.test.ts index 32b50ab0552..f641a90b933 100644 --- a/src/main/runtime/rpc/methods/repo-badge-color.test.ts +++ b/src/main/runtime/rpc/methods/repo-badge-color.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 3de0e8feae0..e111d09c62f 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts b/src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts index c0fb2f250bd..d9a7f59adfa 100644 --- a/src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts +++ b/src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { OrcaRuntimeService } from '../../orca-runtime' diff --git a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts index 0a1076bd8f6..f0792e20a47 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, diff --git a/src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts index 4067836d88e..7a4d474806c 100644 --- a/src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { OrcaRuntimeService } from '../../orca-runtime' diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts b/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts index 4afdfd8e960..4d78c642795 100644 --- a/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts b/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts index 021b6f20622..00c62dc37ec 100644 --- a/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' import { SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' diff --git a/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts b/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts index 3b2f5a5ca1e..0bfc94f3663 100644 --- a/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-structured-restore.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/session-tabs-subscribe-admission.test.ts b/src/main/runtime/rpc/methods/session-tabs-subscribe-admission.test.ts index 2652cdf12fd..6f874b22633 100644 --- a/src/main/runtime/rpc/methods/session-tabs-subscribe-admission.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-subscribe-admission.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' diff --git a/src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts b/src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts index 021224bd649..f279dc3a9de 100644 --- a/src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-unsubscribe.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' import { RuntimeSubscriptionRegistry } from '../../runtime-subscription-registry' diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 44d0dd104eb..0fbab1bb9b0 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/speech.test.ts b/src/main/runtime/rpc/methods/speech.test.ts index dbc06ab8a96..2ef23c69762 100644 --- a/src/main/runtime/rpc/methods/speech.test.ts +++ b/src/main/runtime/rpc/methods/speech.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/ssh.test.ts b/src/main/runtime/rpc/methods/ssh.test.ts index 04293d3cca4..b71602fadfa 100644 --- a/src/main/runtime/rpc/methods/ssh.test.ts +++ b/src/main/runtime/rpc/methods/ssh.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 6d1ddf9f359..a68ffe4492e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index b846be96dd2..d66ef3d2fa1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../../shared/agent-session-journal-types' // A chat at rest, through the RPC surface a client actually calls: opening it starts nothing, what // it can answer without an agent it answers, and the first send is what starts one. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 769bd32f17b..5c9633a5690 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' // `agentSession.hold` / `release` are kept answering for clients that still send them, and do // nothing else: a view never starts or keeps an agent. // 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/methods/structured-agent-session-precommit-refusal.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts index 17aadf32a80..970374824d1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' // The create route's pre-commit boundary: a failure before `attach` must reach the client as a // refusal it can classify, and a failure at or after `attach` must not. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts index a31a8bc8d18..22d4d7c6174 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' // A transcript stream ends when its client says so: the host drops that subscriber, so nothing is // derived or sent for it any more, and a sibling stream of the same session keeps going. diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index c97672269eb..4c7fe2103d7 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' /** * A chat tab's pointer to the conversation it shows, driven end to end: a real record store on disk, * the real structured host, the real runtime, and the real RPC handlers. Only the provider is faked. diff --git a/src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts index 3f3630b0ffa..48acbbec665 100644 --- a/src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts +++ b/src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/terminal-navigation.test.ts b/src/main/runtime/rpc/methods/terminal-navigation.test.ts index b0aa29828c6..11212e53aac 100644 --- a/src/main/runtime/rpc/methods/terminal-navigation.test.ts +++ b/src/main/runtime/rpc/methods/terminal-navigation.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.test.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.test.ts index 9b0d5b4fc18..ca999ce50ef 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.test.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewer-colors-method.test.ts @@ -1,3 +1,4 @@ +import '../../unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it } from 'vitest' import type { OrcaRuntimeService } from '../../../orca-runtime' import { RpcDispatcher } from '../../dispatcher' diff --git a/src/main/runtime/rpc/methods/workspace-ports.test.ts b/src/main/runtime/rpc/methods/workspace-ports.test.ts index 78465ada16b..75ce15053c2 100644 --- a/src/main/runtime/rpc/methods/workspace-ports.test.ts +++ b/src/main/runtime/rpc/methods/workspace-ports.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts b/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts index 7bf9cd2734c..f1004b6abe7 100644 --- a/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts +++ b/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' diff --git a/src/main/runtime/rpc/methods/worktree-catalog-version-stamp.test.ts b/src/main/runtime/rpc/methods/worktree-catalog-version-stamp.test.ts index 1d7e1627c36..cbe4bd654b2 100644 --- a/src/main/runtime/rpc/methods/worktree-catalog-version-stamp.test.ts +++ b/src/main/runtime/rpc/methods/worktree-catalog-version-stamp.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/worktree-create-launch-source.test.ts b/src/main/runtime/rpc/methods/worktree-create-launch-source.test.ts index 2cfd6c3de92..8cb90745a0b 100644 --- a/src/main/runtime/rpc/methods/worktree-create-launch-source.test.ts +++ b/src/main/runtime/rpc/methods/worktree-create-launch-source.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { OrcaRuntimeService } from '../../orca-runtime' diff --git a/src/main/runtime/rpc/methods/worktree-create-navigation.test.ts b/src/main/runtime/rpc/methods/worktree-create-navigation.test.ts index 39f826986e6..b70d56eee9b 100644 --- a/src/main/runtime/rpc/methods/worktree-create-navigation.test.ts +++ b/src/main/runtime/rpc/methods/worktree-create-navigation.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/worktree-github-pr-suppression.test.ts b/src/main/runtime/rpc/methods/worktree-github-pr-suppression.test.ts index 4adaf6f5325..32a118ef00a 100644 --- a/src/main/runtime/rpc/methods/worktree-github-pr-suppression.test.ts +++ b/src/main/runtime/rpc/methods/worktree-github-pr-suppression.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts b/src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts index 9ea904af68d..1ae125ef814 100644 --- a/src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts +++ b/src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' diff --git a/src/main/runtime/rpc/methods/worktree-retired-names.test.ts b/src/main/runtime/rpc/methods/worktree-retired-names.test.ts index 3c4c6f3755d..98488fe1c57 100644 --- a/src/main/runtime/rpc/methods/worktree-retired-names.test.ts +++ b/src/main/runtime/rpc/methods/worktree-retired-names.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts b/src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts index e456e4cbf50..4d45504f77a 100644 --- a/src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts +++ b/src/main/runtime/rpc/methods/worktree-rm-background-reply.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, diff --git a/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts b/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts index d96fa04c521..4f9a4acdfec 100644 --- a/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts +++ b/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts b/src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts index 0c912db53be..35ba7b9f267 100644 --- a/src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts +++ b/src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 3b62cbfd7cd..d67ee99193f 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -1,3 +1,4 @@ +import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/orchestration-commit-notify-characterization.test.ts b/src/main/runtime/rpc/orchestration-commit-notify-characterization.test.ts index 4ebd49df17e..9c8679e3963 100644 --- a/src/main/runtime/rpc/orchestration-commit-notify-characterization.test.ts +++ b/src/main/runtime/rpc/orchestration-commit-notify-characterization.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/src/main/runtime/rpc/orchestration-contract-fence.test.ts b/src/main/runtime/rpc/orchestration-contract-fence.test.ts index 17aee4b0b95..10d4d13b1fe 100644 --- a/src/main/runtime/rpc/orchestration-contract-fence.test.ts +++ b/src/main/runtime/rpc/orchestration-contract-fence.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { z } from 'zod' import { afterEach, describe, expect, it, vi } from 'vitest' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' diff --git a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts index c57f7876402..55ef7ac2040 100644 --- a/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-coordinator-race.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { createHash } from 'node:crypto' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' diff --git a/src/main/runtime/rpc/orchestration-legacy-question-takeover.test.ts b/src/main/runtime/rpc/orchestration-legacy-question-takeover.test.ts index 82c59f39587..b044469cc3f 100644 --- a/src/main/runtime/rpc/orchestration-legacy-question-takeover.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-question-takeover.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { createHash } from 'node:crypto' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' diff --git a/src/main/runtime/rpc/orchestration-legacy-takeover-current-authority.test.ts b/src/main/runtime/rpc/orchestration-legacy-takeover-current-authority.test.ts index 4de71ce6f2a..3ae8dc8375c 100644 --- a/src/main/runtime/rpc/orchestration-legacy-takeover-current-authority.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-takeover-current-authority.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { createHash } from 'node:crypto' import { afterEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer } from '../../agent-hooks/server' diff --git a/src/main/runtime/rpc/orchestration-legacy-takeover-delivery.test.ts b/src/main/runtime/rpc/orchestration-legacy-takeover-delivery.test.ts index feb3017b538..4dde47e78df 100644 --- a/src/main/runtime/rpc/orchestration-legacy-takeover-delivery.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-takeover-delivery.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { createHash } from 'node:crypto' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' diff --git a/src/main/runtime/rpc/orchestration-legacy-takeover-dispatcher.test.ts b/src/main/runtime/rpc/orchestration-legacy-takeover-dispatcher.test.ts index e5a05fe7d26..73a27ddc04d 100644 --- a/src/main/runtime/rpc/orchestration-legacy-takeover-dispatcher.test.ts +++ b/src/main/runtime/rpc/orchestration-legacy-takeover-dispatcher.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { createHash } from 'node:crypto' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' diff --git a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts index 6909b4f3345..3a6b734c259 100644 --- a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts +++ b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { createHash } from 'node:crypto' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' diff --git a/src/main/runtime/rpc/orchestration-mutation-request-show.test.ts b/src/main/runtime/rpc/orchestration-mutation-request-show.test.ts index e64fb5b9640..68310db7224 100644 --- a/src/main/runtime/rpc/orchestration-mutation-request-show.test.ts +++ b/src/main/runtime/rpc/orchestration-mutation-request-show.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' // A lost mutation response must be answerable without mutating again: these cover // `orchestration.requestShow` reading the same durable receipt --retry-request replays. import { describe, expect, it, vi } from 'vitest' diff --git a/src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts b/src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts index db1da5c93d0..e9b46098bce 100644 --- a/src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts +++ b/src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { createHash } from 'node:crypto' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' diff --git a/src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts b/src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts index d09c47939f0..ed10d31f919 100644 --- a/src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts +++ b/src/main/runtime/rpc/orchestration-task-dispatch-invariant.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts b/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts index 098eeeb751a..13009905aad 100644 --- a/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts +++ b/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { _resetTracerForTests, setActiveSink, type TracerSink } from '../../observability/tracer' import type { OrcaRuntimeService } from '../orca-runtime' diff --git a/src/main/runtime/rpc/runtime-close-attribution.test.ts b/src/main/runtime/rpc/runtime-close-attribution.test.ts index dcf9caa658f..12290423a75 100644 --- a/src/main/runtime/rpc/runtime-close-attribution.test.ts +++ b/src/main/runtime/rpc/runtime-close-attribution.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { _resetTracerForTests, setActiveSink, type TracerSink } from '../../observability/tracer' import type { OrcaRuntimeService } from '../orca-runtime' diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts index a5a132ae0cd..48afad4f3ac 100644 --- a/src/main/runtime/rpc/streaming.test.ts +++ b/src/main/runtime/rpc/streaming.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { RpcDispatcher } from './dispatcher' diff --git a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts index 824513dd999..ba4cd3cb4f6 100644 --- a/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts +++ b/src/main/runtime/rpc/terminal-agent-prompt-send.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../orca-runtime' import type { RpcRequest } from './core' diff --git a/src/main/runtime/rpc/terminal-agent-send-guard.test.ts b/src/main/runtime/rpc/terminal-agent-send-guard.test.ts index 3128e0e2dc0..602bd3dbb86 100644 --- a/src/main/runtime/rpc/terminal-agent-send-guard.test.ts +++ b/src/main/runtime/rpc/terminal-agent-send-guard.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../orca-runtime' import type { RpcRequest } from './core' diff --git a/src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts b/src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts index c34ff54f848..15e269a965f 100644 --- a/src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts +++ b/src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import type { RpcRequest } from './core' diff --git a/src/main/runtime/rpc/terminal-lease-stale-handle-survival.test.ts b/src/main/runtime/rpc/terminal-lease-stale-handle-survival.test.ts index d7921f13135..d56d4bdc61a 100644 --- a/src/main/runtime/rpc/terminal-lease-stale-handle-survival.test.ts +++ b/src/main/runtime/rpc/terminal-lease-stale-handle-survival.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../orca-runtime' import type { RpcRequest } from './core' diff --git a/src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts b/src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts index fc4fab66147..6b370641897 100644 --- a/src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts +++ b/src/main/runtime/rpc/terminal-list-host-scope-transport.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { RuntimeTerminalListResult } from '../../../shared/runtime-types' import { RpcDispatcher } from './dispatcher' diff --git a/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts b/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts index f93b772994e..0efaa5b2fe7 100644 --- a/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-ack-output-budget.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import { TERMINAL_METHODS } from './methods/terminal' diff --git a/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts b/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts index 57abea846ae..3c46d16fbf3 100644 --- a/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-ack-overflow-recovery.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import { TERMINAL_METHODS } from './methods/terminal' diff --git a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts index 562181b2752..19e22540c1f 100644 --- a/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-desktop-resize-routing.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import { TERMINAL_METHODS } from './methods/terminal' diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts index 4b9afed46c6..38f5d9e5cd5 100644 --- a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import type { RpcRequest } from './core' diff --git a/src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts b/src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts index 38117a7c762..43ef18a7e87 100644 --- a/src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import type { RuntimeTerminalDataMeta } from '../orca-runtime' diff --git a/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts b/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts index ae2298a20dc..2b8472524bf 100644 --- a/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-input-write-rejection.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import type { OrcaRuntimeService } from '../orca-runtime' diff --git a/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts b/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts index eecd7b03859..7f11dbdfed7 100644 --- a/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-output-pause-and-viewport.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import type { OrcaRuntimeService, RuntimeTerminalDataMeta } from '../orca-runtime' diff --git a/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts b/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts index 9a8cc77af3e..bbd4c7eca5e 100644 --- a/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import { TERMINAL_METHODS } from './methods/terminal' diff --git a/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts b/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts index a39f63572f6..266630280f1 100644 --- a/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import type { RpcRequest } from './core' diff --git a/src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts b/src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts index 4ce604ba27d..a55a69dff08 100644 --- a/src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import { TERMINAL_METHODS } from './methods/terminal' diff --git a/src/main/runtime/rpc/terminal-multiplex-subscribe-slot-recovery.test.ts b/src/main/runtime/rpc/terminal-multiplex-subscribe-slot-recovery.test.ts index 7fa6f4a39d2..fa275c0c15e 100644 --- a/src/main/runtime/rpc/terminal-multiplex-subscribe-slot-recovery.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-subscribe-slot-recovery.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import { TERMINAL_METHODS } from './methods/terminal' diff --git a/src/main/runtime/rpc/terminal-opencode-send-guard.integration.test.ts b/src/main/runtime/rpc/terminal-opencode-send-guard.integration.test.ts index 2671449b606..f485e207efd 100644 --- a/src/main/runtime/rpc/terminal-opencode-send-guard.integration.test.ts +++ b/src/main/runtime/rpc/terminal-opencode-send-guard.integration.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../orca-runtime' import { RpcDispatcher } from './dispatcher' diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index 214a5a2814e..15dcf01ad44 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import type { RpcRequest } from './core' diff --git a/src/main/runtime/rpc/terminal-prompt-delivery-receipt.test.ts b/src/main/runtime/rpc/terminal-prompt-delivery-receipt.test.ts index e1bc03f18ab..7bb2cf8727c 100644 --- a/src/main/runtime/rpc/terminal-prompt-delivery-receipt.test.ts +++ b/src/main/runtime/rpc/terminal-prompt-delivery-receipt.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it, vi } from 'vitest' import type { TuiAgent } from '../../../shared/tui-agent' import { createAgentPromptSubmissionRuntime } from '../agent-prompt-submission-runtime-test-fixture' diff --git a/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts b/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts index 726141c536d..88113c84cc1 100644 --- a/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts +++ b/src/main/runtime/rpc/terminal-provider-snapshot-sequence.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import type { RpcRequest } from './core' diff --git a/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts b/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts index c5f05cb0456..dacf408900c 100644 --- a/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts +++ b/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import type { RpcRequest } from './core' diff --git a/src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts b/src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts index 9e5cf52384a..61f740ee85c 100644 --- a/src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts +++ b/src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../orca-runtime' import type { RpcRequest } from './core' diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 9aa55eb823e..a6b451b1488 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { afterEach, describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import type { RpcRequest } from './core' diff --git a/src/main/runtime/rpc/terminal-subscribe-admission.test.ts b/src/main/runtime/rpc/terminal-subscribe-admission.test.ts index 75df605d1c8..c0046637d27 100644 --- a/src/main/runtime/rpc/terminal-subscribe-admission.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-admission.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../orca-runtime' import type { RpcRequest } from './core' diff --git a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts index 3562f9cc3c4..c6bf99e4d44 100644 --- a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' /** STA-1840 regression: missing mobile terminal models request an exact renderer tab mount. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index a08458d8be5..601d1eaf936 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import type { RpcRequest } from './core' diff --git a/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts b/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts index 1ee273d1ca2..3a027ae516b 100644 --- a/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-lease-only.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../orca-runtime' import type { RpcRequest } from './core' diff --git a/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts index 6dd443f1402..7371c54472c 100644 --- a/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-mount-replay.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { RuntimeTerminalWait } from '../../../shared/runtime-types' import { diff --git a/src/main/runtime/rpc/terminal-subscribe-mounted-pane.test.ts b/src/main/runtime/rpc/terminal-subscribe-mounted-pane.test.ts index 56720ff5d48..466cf4b3901 100644 --- a/src/main/runtime/rpc/terminal-subscribe-mounted-pane.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-mounted-pane.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeTerminalWait } from '../../../shared/runtime-types' import { diff --git a/src/main/runtime/rpc/terminal-subscribe-ownership.test.ts b/src/main/runtime/rpc/terminal-subscribe-ownership.test.ts index fefe8efb28d..8c7c87ffeed 100644 --- a/src/main/runtime/rpc/terminal-subscribe-ownership.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-ownership.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../orca-runtime' import { RuntimeSubscriptionRegistry } from '../runtime-subscription-registry' diff --git a/src/main/runtime/rpc/terminal-subscribe-reconnect-rebind.test.ts b/src/main/runtime/rpc/terminal-subscribe-reconnect-rebind.test.ts index df3bd9bcece..fe7990b71c4 100644 --- a/src/main/runtime/rpc/terminal-subscribe-reconnect-rebind.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-reconnect-rebind.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { RuntimeTerminalWait } from '../../../shared/runtime-types' import type { OrcaRuntimeService } from '../orca-runtime' diff --git a/src/main/runtime/rpc/terminal-subscribe-relay-drop-lease-survival.test.ts b/src/main/runtime/rpc/terminal-subscribe-relay-drop-lease-survival.test.ts index 5cfab233011..f7c9554c191 100644 --- a/src/main/runtime/rpc/terminal-subscribe-relay-drop-lease-survival.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-relay-drop-lease-survival.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' // Why: the stale-handle survival suite doubles subscribeToPtyExit, so it pins the wiring but // not the predicate that decides whether a PTY counts as exited. This wires that one call to // a real OrcaRuntimeService, because a relay drop reaches the phone through the predicate. diff --git a/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts b/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts index 65701e2a80a..530eaf54171 100644 --- a/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-renderer-recovery-output.test.ts @@ -1,3 +1,4 @@ +import './unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { RuntimeTerminalWait } from '../../../shared/runtime-types' import { diff --git a/src/main/runtime/rpc/unused-default-rpc-methods.test-fixture.ts b/src/main/runtime/rpc/unused-default-rpc-methods.test-fixture.ts new file mode 100644 index 00000000000..6e2f54332d7 --- /dev/null +++ b/src/main/runtime/rpc/unused-default-rpc-methods.test-fixture.ts @@ -0,0 +1,22 @@ +import { afterEach, expect, vi } from 'vitest' +import type { RpcAnyMethodDeclaration } from './core' + +const unusedDefaultRpcMethods = vi.hoisted(() => { + const methods: RpcAnyMethodDeclaration[] = [] + const consumption = { count: 0 } + methods[Symbol.iterator] = (): ArrayIterator => { + consumption.count += 1 + throw new Error('Explicit-registry tests must not consume the default RPC manifest') + } + return { methods, consumption } +}) + +vi.mock('./methods', () => ({ ALL_RPC_METHODS: unusedDefaultRpcMethods.methods })) + +afterEach(() => { + try { + expect(unusedDefaultRpcMethods.consumption.count).toBe(0) + } finally { + unusedDefaultRpcMethods.consumption.count = 0 + } +}) diff --git a/src/main/runtime/runtime-agent-terminal-startup.ts b/src/main/runtime/runtime-agent-terminal-startup.ts new file mode 100644 index 00000000000..5cd0c01ae82 --- /dev/null +++ b/src/main/runtime/runtime-agent-terminal-startup.ts @@ -0,0 +1,77 @@ +import type { SessionOptionValue } from '../../shared/native-chat-session-options' +import type { RuntimeStore } from './runtime-store-contract' +import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' +import type { TerminalCreateOptions } from './runtime-terminal-contracts' +import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' +import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' +import { buildExecutionHostAgentStartupPlan } from '../opencode/opencode-model-startup-plan' +import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' +import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' + +export async function buildRuntimeAgentTerminalStartupOptions( + workspace: TerminalWorkspaceLaunchScope, + opts: TerminalCreateOptions, + settings: ReturnType, + platform: NodeJS.Platform, + sessionOptions: Record | undefined, + hostIdentity: string +): Promise { + // Why: `workspace.repo` is display metadata and may be a row from another host; the launch + // shape must match the PTY route this scope already resolved. + const isRemote = Boolean(workspace.connectionId) + if (opts.startupAgent && !isTuiAgentEnabled(opts.startupAgent, settings.disabledTuiAgents)) { + throw new Error(`Agent ${opts.startupAgent} is disabled. Choose an enabled agent.`) + } + const agent = + opts.startupAgent ?? + resolveBareAgentLaunchCommand({ + command: opts.command, + settings, + platform, + isRemote + }) + if (!agent) { + return opts + } + + const startupPlan = await buildExecutionHostAgentStartupPlan({ + inputs: resolveAgentStartupPlanInputs({ + agent, + settings, + platform, + isRemote, + ...(opts.agentArgs !== undefined ? { agentArgs: opts.agentArgs } : {}), + // A requested shell is the one this PTY will actually be, so it owns the quoting family. + windowsShellOverride: opts.shellOverride, + sessionOptions: sessionOptions + }), + prompt: opts.startupPrompt ?? '', + cwd: resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path, + hostIdentity + }) + if (!startupPlan) { + // Why: an explicit agent that yields no plan would otherwise spawn a bare + // shell that never reaches agent readiness. + if (opts.startupAgent) { + throw new Error(`Could not build launch command for ${opts.startupAgent}.`) + } + return opts + } + // A prompt this launch command cannot carry has nowhere to go from here — the create returns + // options, not a live PTY — so refuse rather than spawn the agent and drop the text. + if (opts.startupPrompt && 'followupPrompt' in startupPlan && startupPlan.followupPrompt) { + throw new Error(`Agent ${agent} does not take a startup prompt on its launch command.`) + } + + return { + ...opts, + command: startupPlan.launchCommand, + ...(startupPlan.env ? { env: startupPlan.env } : {}), + launchConfig: startupPlan.launchConfig, + launchAgent: agent, + startupCommandDelivery: startupPlan.startupCommandDelivery, + // A bare command the user typed stays out of launch accounting, as before. + ...(opts.startupAgent ? { telemetry: agentStartedTelemetry(agent, opts.launchSource) } : {}) + } +} diff --git a/src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts b/src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts index 394e6940112..25f7069e213 100644 --- a/src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts +++ b/src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts @@ -13,7 +13,7 @@ import { readPreviewFileWithinCap } from './runtime-file-commands-mobile-file-list-limit' import { requireRuntimeFileProvider } from './runtime-file-command-target' -import { open, stat } from 'node:fs/promises' +import { readLocalFileRange } from '../ipc/filesystem/local-file-range-read' import { resolveAuthorizedPath } from '../ipc/filesystem-auth' import { extname } from 'node:path' import { @@ -161,22 +161,6 @@ export class RuntimeFileCommandsWithReadFileExplorerPreview extends RuntimeFileC } const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - const fileStats = await stat(filePath) - if (fileStats.isDirectory()) { - throw new Error('Cannot download a directory') - } - const handle = await open(filePath, 'r') - try { - const buffer = Buffer.alloc(Math.min(length, Math.max(0, fileStats.size - offset))) - const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset) - const chunk = buffer.subarray(0, bytesRead) - return { - contentBase64: chunk.toString('base64'), - bytesRead, - eof: offset + bytesRead >= fileStats.size - } - } finally { - await handle.close() - } + return readLocalFileRange(filePath, offset, length) } } diff --git a/src/main/runtime/runtime-file-path-existence.test.ts b/src/main/runtime/runtime-file-path-existence.test.ts index d74c58bfdae..24b12e7d5a6 100644 --- a/src/main/runtime/runtime-file-path-existence.test.ts +++ b/src/main/runtime/runtime-file-path-existence.test.ts @@ -1,3 +1,4 @@ +import './rpc/unused-default-rpc-methods.test-fixture' import { afterEach, expect, it, vi } from 'vitest' import { mkdtemp, writeFile, rm } from 'node:fs/promises' import { join } from 'node:path' diff --git a/src/main/runtime/runtime-git-status-admission.test.ts b/src/main/runtime/runtime-git-status-admission.test.ts index b6c92fe7002..e5f50197a0d 100644 --- a/src/main/runtime/runtime-git-status-admission.test.ts +++ b/src/main/runtime/runtime-git-status-admission.test.ts @@ -1,3 +1,4 @@ +import './rpc/unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { GitAdmissionEvent } from '../../shared/git-admission-state' import { GitAdmissionScheduler } from '../git/command-runner/git-subprocess-admission' diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index d623a3f1fd1..ea0803eb4df 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -4,7 +4,10 @@ import type { AgentSessionExecutionClaim, RuntimeCreateAgentSessionResult } from '../../shared/agent-session-host-authority' -import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' import type { OrchestrationCompatibilityHostStamp } from '../../shared/orchestration-compatibility-evidence' import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import type { @@ -84,7 +87,10 @@ export type TerminalCreateOptions = { signal?: AbortSignal onPtySpawnCommitted?: () => void /** Called before the spawn request leaves this process; a throw before it proves nothing spawned. */ - onPtySpawnDispatched?: () => void + onPtySpawnDispatched?: (launch?: { + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent + }) => void deferMobileSessionPublish?: boolean } diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index a65f2527357..a57beb7efcd 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -14,6 +14,7 @@ import { detectRemoteAgents } from '../preflight/agent-detection' import type { RuntimeStore } from './runtime-store-contract' +import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' export type WorktreeStartupDraftPaste = { agent: TuiAgent; content: string } export type WorktreeStartupFollowup = { expectedProcess: string; prompt: string } @@ -173,3 +174,21 @@ export function buildWorktreeStartupForAgent( : {}) } } + +export function resolveWorktreeCreateAgentStartup( + args: RuntimeManagedWorktreeCreateArgs, + build: ( + agent: TuiAgent, + prompt: string | undefined, + preferences: AgentLaunchPreferences | undefined, + inputs: { agentArgs?: string | null; launchSource?: string } + ) => { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } +) { + if (args.startup || !args.startupAgent) { + return null + } + return build(args.startupAgent, args.startupPrompt, args.startupLaunchPreferences, { + ...(args.startupAgentArgs !== undefined ? { agentArgs: args.startupAgentArgs } : {}), + ...(args.startupLaunchSource ? { launchSource: args.startupLaunchSource } : {}) + }) +} diff --git a/src/main/runtime/structured-agent-session-held-signal.test.ts b/src/main/runtime/structured-agent-session-held-signal.test.ts index 2bf1a423fe1..0bf61aa7843 100644 --- a/src/main/runtime/structured-agent-session-held-signal.test.ts +++ b/src/main/runtime/structured-agent-session-held-signal.test.ts @@ -1,3 +1,4 @@ +import './rpc/unused-default-rpc-methods.test-fixture' /** * "This machine holds a structured chat" against a real host and record store. Session history, * resume preparation and replay-safe phone launches all build the host for a user who never had a diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 3d9e72f7404..571e903dde3 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -1,3 +1,4 @@ +import './rpc/unused-default-rpc-methods.test-fixture' // One structured Codex session driven end to end over `agentSession.*`. // // Nothing here is stubbed except the Codex child itself: the RPC dispatcher, the diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 49c694b5adb..0ec0418184d 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -1,3 +1,4 @@ +import './rpc/unused-default-rpc-methods.test-fixture' // A worker's result reaching the structured chat that coordinates it, end to end in one process. // // Real: the structured agent-session host, its record store, journal, lease and Codex adapter; the @@ -26,7 +27,6 @@ import { OrchestrationDb } from './orchestration/db' import { localOrchestrationCliCommand } from './orchestration/cli-command' import { formatMessagePointer } from './orchestration/formatter' import { currentRunCoordinatorOrcaSessionId } from './orchestration/db/runs/run-coordinator-orca-session' -import type { RpcRequest } from './rpc/core' import { RpcDispatcher } from './rpc/dispatcher' import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' import { idOf, isRecord, resultOf } from './rpc/orchestration-session-caller-test-fixture' @@ -63,7 +63,7 @@ function request( method: string, params: Record, options: { sessionId?: string } = {} -): RpcRequest { +): Parameters[0] { requests += 1 return { id: `rpc-${requests}`, diff --git a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts index 9710ba438be..0b2a2e888ee 100644 --- a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts +++ b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts @@ -1,3 +1,4 @@ +import './rpc/unused-default-rpc-methods.test-fixture' /** * Subscriber-driven daemon attach (never-activated tab ingestion). * diff --git a/src/main/ssh/ssh-relay-incumbent-process.test.ts b/src/main/ssh/ssh-relay-incumbent-process.test.ts index 425e3f5f279..f77cd335174 100644 --- a/src/main/ssh/ssh-relay-incumbent-process.test.ts +++ b/src/main/ssh/ssh-relay-incumbent-process.test.ts @@ -65,7 +65,8 @@ async function probe(script: string, listening = false) { rmSync(dir, { recursive: true, force: true }) } } -describe.skipIf(process.platform === 'win32')('real generated incumbent probe', () => { +// Each probe owns its socket, shim and process group; overlap the real lsof deadlines. +describe.skipIf(process.platform === 'win32').concurrent('real generated incumbent probe', () => { it('bounds hung lsof and preserves live connect evidence', async () => { const p = await probe('echo $$ > "$FIXTURE_PID"\nexec sleep 60\n', true) expect(p.result.timedOut).toBe(false) diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index 5c1e99eb80d..d48fa1c1e80 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -417,10 +417,13 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { feed([ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', - { reject: 'Command "node -e ..." timed out after 30s' } // health probe never answered + { reject: 'Command "node -e ..." timed out after 30s' }, // health probe never answered + '', // launch namespace marker + 'DEAD', + 'READY' ]) - await deployAndLaunchRelay(conn).catch(() => {}) + const outcome = await deployAndLaunchRelay(conn).catch((error: Error) => error) const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) expect(execCalls.some((c) => c.includes('npm install'))).toBe(false) @@ -428,9 +431,19 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) expect(warnMessages.some((m) => m.includes('Repairing missing native deps'))).toBe(false) - // Why no log assertion: the behavioural claim above is the real one. Asserting on warn text - // pinned wording that main's landed probe verdict does not use, and #18000 adds its own. + // Probe diagnostic wording changes independently of the repair contract. expect(execCalls.some((c) => c.includes("rm -rf 'node_modules/node-pty'"))).toBe(false) + expect(execCalls.filter((command) => command.includes('ORCA-NATIVE-DEPS-OK'))).toHaveLength(1) + const healthProbeIndex = execCalls.findIndex((command) => + command.includes('ORCA-NATIVE-DEPS-OK') + ) + await expect(vi.mocked(execCommand).mock.results[healthProbeIndex].value).rejects.toThrow( + 'Command "node -e ..." timed out after 30s' + ) + expect( + outcome, + 'an unanswered health probe must still allow an intact relay to launch' + ).not.toBeInstanceOf(Error) }) it('lets a probe SSH-channel failure bubble up rather than silently mapping to MISSING', async () => { diff --git a/src/main/ssh/ssh-remote-linear-relation-write.test.ts b/src/main/ssh/ssh-remote-linear-relation-write.test.ts index 262ec566232..9042e63deca 100644 --- a/src/main/ssh/ssh-remote-linear-relation-write.test.ts +++ b/src/main/ssh/ssh-remote-linear-relation-write.test.ts @@ -1,3 +1,4 @@ +import '../runtime/rpc/unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { RpcDispatcher } from '../runtime/rpc/dispatcher' diff --git a/src/main/startup/codex-account-restart-resume.test.ts b/src/main/startup/codex-account-restart-resume.test.ts new file mode 100644 index 00000000000..197c9281edb --- /dev/null +++ b/src/main/startup/codex-account-restart-resume.test.ts @@ -0,0 +1,117 @@ +import { dirname, join } from 'node:path' +import { linkSync, readFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const homes = vi.hoisted(() => ({ original: '', selected: '', system: '' })) +vi.mock('electron', () => ({ app: { getPath: () => homes.system } })) +vi.mock('./main-process-state', () => ({ + mainProcessState: { + codexRuntimeHome: { + isHostSystemDefaultRealHome: () => false, + getHostCodexHomePathsForSessionDiscovery: () => [homes.original, homes.selected], + resolveSelectedHostAccountCodexHomePathForResume: () => homes.selected || null + }, + store: { getSettings: () => ({}) } + } +})) +vi.mock('../codex/hook-service', () => ({ + codexHookService: { + installForLaunchPrep: vi.fn(), + refreshRuntimeUserHooksForLaunchPrep: vi.fn() + } +})) +vi.mock('../codex/codex-real-home-hook-install', () => ({ + ensureRealHomeCodexHookState: vi.fn(), + awaitRealHomeCodexHookTrust: vi.fn() +})) +vi.mock('../codex/codex-home-paths', () => ({ + getCodexSessionBackfillStateDirPath: () => join(homes.system, 'backfill'), + getSystemCodexHomePath: () => homes.system, + getOrcaManagedCodexHomePath: () => join(homes.system, 'legacy') +})) +vi.mock('../codex/codex-config-mirror', () => ({ ensureCodexDaemonSocketGuard: vi.fn() })) + +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' + +let root: string +let transcriptPath: string +const sessionId = 'abcdef00-1234-4321-9999-cafecafecafe' +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-account-restart-')) + homes.original = join(root, 'codex-accounts', 'old', 'home') + homes.selected = join(root, 'codex-accounts', 'new', 'home') + homes.system = join(root, 'system') + const relativePath = join('sessions', '2026', '10', '05', `rollout-${sessionId}.jsonl`) + transcriptPath = join(homes.original, relativePath) + const bridged = join(homes.selected, relativePath) + mkdirSync(dirname(transcriptPath), { recursive: true }) + mkdirSync(dirname(bridged), { recursive: true }) + writeFileSync(transcriptPath, '{"type":"session_meta"}\n') + linkSync(transcriptPath, bridged) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +it('resumes an explicit account restart in the selected home using the bridged transcript', async () => { + const result = await prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'host' }, + useSelectedAccount: true + }) + expect(result).toMatchObject({ outcome: 'resume', codexHomePath: homes.selected }) +}) + +it('keeps ordinary automatic restores pinned to their original account', async () => { + const result = await prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'host' } + }) + expect(result).toMatchObject({ outcome: 'resume', codexHomePath: homes.original }) +}) + +it('materializes a rollout when the account bridge has not caught up', async () => { + rmSync(join(homes.selected, 'sessions'), { recursive: true }) + const result = await prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'host' }, + useSelectedAccount: true + }) + expect(result).toMatchObject({ codexHomePath: homes.selected }) + expect(readFileSync(transcriptPath.replace(homes.original, homes.selected), 'utf8')).toBe( + readFileSync(transcriptPath, 'utf8') + ) +}) + +it('refuses a conflicting target rollout instead of restarting the old account', async () => { + const target = transcriptPath.replace(homes.original, homes.selected) + rmSync(target) + writeFileSync(target, 'another session') + await expect( + prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'host' }, + useSelectedAccount: true + }) + ).rejects.toThrow('different rollout') + expect(readFileSync(target, 'utf8')).toBe('another session') +}) + +it('uses the system home when an explicit restart follows deselection', async () => { + homes.selected = '' + const result = await prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'host' }, + useSelectedAccount: true + }) + expect(result).toMatchObject({ codexHomePath: homes.system }) +}) + +it('leaves WSL preparation on the execution host', async () => { + expect( + await prepareCodexSessionResumeForLaunch({ + providerSession: { key: 'session_id', id: sessionId, transcriptPath }, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + useSelectedAccount: true + }) + ).toBeNull() +}) diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts index f01d17610d7..5feb61df417 100644 --- a/src/main/startup/codex-session-resume-launch.ts +++ b/src/main/startup/codex-session-resume-launch.ts @@ -3,7 +3,10 @@ import type { AgentProviderSessionMetadata } from '../../shared/agent-session-re import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home' import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation' -import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-session-resume' +import { + prepareCodexAccountRestartResume, + prepareLegacySharedCodexSessionResume +} from '../codex/codex-legacy-session-resume' import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' import { codexHookService } from '../codex/hook-service' import { @@ -20,6 +23,7 @@ export async function prepareCodexSessionResumeForLaunch(args: { providerSession: AgentProviderSessionMetadata target: CodexAccountSelectionTarget launchEnv?: NodeJS.ProcessEnv + useSelectedAccount?: boolean }): Promise { const runtimeHome = state.codexRuntimeHome const store = state.store @@ -66,12 +70,7 @@ export async function prepareCodexSessionResumeForLaunch(args: { } ) } catch (error) { - // Why: this launch path pins CODEX_HOME to the account that OWNS the - // rollout and deliberately refuses to repin onto whichever account is - // selected now (#10793), so it does not wire - // getSelectedHostAccountCodexHomePath and this branch cannot fire today. - // It stays as a contract guard: the blanket catch below must never - // silently swallow a typed refusal if that ever changes. + // A credential-read refusal must never fall back to the old account. if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { throw error } @@ -81,7 +80,16 @@ export async function prepareCodexSessionResumeForLaunch(args: { error ) } - const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath + const resumeHome = args.useSelectedAccount + ? await prepareCodexAccountRestartResume({ + sourceHome: sessionSource.homePath, + transcriptPath: sessionSource.transcriptPath, + targetHome: selectedAccountCodexHome ?? systemHomePath, + systemCodexHomePath: systemHomePath + }) + : migrated.useRealCodexHome + ? systemHomePath + : sessionSource.homePath const isSystemHome = normalizeRuntimePathForComparison(resumeHome) === normalizeRuntimePathForComparison(systemHomePath) diff --git a/src/main/startup/os-opened-document-resolution.test.ts b/src/main/startup/os-opened-document-resolution.test.ts index 1267d51e5db..6c0f5990128 100644 --- a/src/main/startup/os-opened-document-resolution.test.ts +++ b/src/main/startup/os-opened-document-resolution.test.ts @@ -4,14 +4,10 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { resolveOsOpenedDocuments } from './os-opened-documents' -vi.mock('../ipc/filesystem-auth', () => ({ - authorizeExternalPath: vi.fn() -})) vi.mock('../ipc/floating-workspace-directory', () => ({ ensureDefaultFloatingWorkspacePath: vi.fn() })) -const { authorizeExternalPath } = await import('../ipc/filesystem-auth') const { ensureDefaultFloatingWorkspacePath } = await import('../ipc/floating-workspace-directory') describe('resolveOsOpenedDocuments', () => { @@ -19,7 +15,6 @@ describe('resolveOsOpenedDocuments', () => { let fileRoot: string beforeEach(async () => { - vi.mocked(authorizeExternalPath).mockClear() vi.mocked(ensureDefaultFloatingWorkspacePath).mockClear() floatingRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-root-')) fileRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-files-')) @@ -48,17 +43,15 @@ describe('resolveOsOpenedDocuments', () => { name: 'design notes' } ]) - expect(authorizeExternalPath).toHaveBeenCalledWith(filePath) } ) - it('never authorizes unsupported or relative files even when they exist', async () => { + it('drops unsupported or relative files even when they exist', async () => { const filePath = join(fileRoot, 'private.txt') await writeFile(filePath, 'private') expect(await resolveOsOpenedDocuments([filePath, 'relative.csv', 'file:///%zz.tsv'])).toEqual( [] ) - expect(authorizeExternalPath).not.toHaveBeenCalled() expect(ensureDefaultFloatingWorkspacePath).not.toHaveBeenCalled() }) @@ -71,21 +64,16 @@ describe('resolveOsOpenedDocuments', () => { const documents = await resolveOsOpenedDocuments([bundlePath, filePath]) expect(documents.map((document) => document.filePath)).toEqual([filePath]) - // Security contract: a path we never validated must never be authorized for renderer reads. - expect(authorizeExternalPath).toHaveBeenCalledTimes(1) - expect(authorizeExternalPath).toHaveBeenCalledWith(filePath) }) - it('drops a path that no longer exists without authorizing it', async () => { + it('drops a path that no longer exists', async () => { const missingPath = join(fileRoot, 'gone.csv') expect(await resolveOsOpenedDocuments([missingPath])).toEqual([]) - expect(authorizeExternalPath).not.toHaveBeenCalled() }) it('returns nothing for an empty input without touching the filesystem', async () => { expect(await resolveOsOpenedDocuments([])).toEqual([]) expect(ensureDefaultFloatingWorkspacePath).not.toHaveBeenCalled() - expect(authorizeExternalPath).not.toHaveBeenCalled() }) }) diff --git a/src/main/startup/os-opened-documents.ts b/src/main/startup/os-opened-documents.ts index ec2b9c20e76..cdc78803ef3 100644 --- a/src/main/startup/os-opened-documents.ts +++ b/src/main/startup/os-opened-documents.ts @@ -2,7 +2,6 @@ import { stat } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import type { FileDocument } from '../../shared/filesystem-entry-types' -import { authorizeExternalPath } from '../ipc/filesystem-auth' import { ensureDefaultFloatingWorkspacePath } from '../ipc/floating-workspace-directory' import { fileDocumentFromFilePath, isMarkdownDocumentName } from '../ipc/markdown-documents' @@ -135,7 +134,7 @@ export class OsOpenedDocumentState { /** * Turns OS-handed paths into the same document shape the floating workspace's own - * file picker produces, authorizing each one for the renderer's later read. + * file picker produces; the floating tab then reads each one as a user-named file. */ export async function resolveOsOpenedDocuments( filePaths: readonly string[] @@ -152,14 +151,13 @@ export async function resolveOsOpenedDocuments( for (const filePath of supportedPaths) { try { // Why: the shell can hand over a directory named like a document, or a path already - // deleted by the time we resolve. Authorize only something that is really a file. + // deleted by the time we resolve. Open only something that is really a file. if (!(await stat(filePath)).isFile()) { continue } } catch { continue } - authorizeExternalPath(filePath) documents.push( fileDocumentFromFilePath(floatingRoot, filePath, { outsideRootRelativePath: 'basename' diff --git a/src/main/window/clipboard-image-temp-file.test.ts b/src/main/window/clipboard-image-temp-file.test.ts index 71ca0804c0e..e6fdd9f900b 100644 --- a/src/main/window/clipboard-image-temp-file.test.ts +++ b/src/main/window/clipboard-image-temp-file.test.ts @@ -1,13 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { authorizeExternalPathMock, writeFileMock, getPathMock, writeFileBase64Mock } = vi.hoisted( - () => ({ - authorizeExternalPathMock: vi.fn(), - writeFileMock: vi.fn(), - getPathMock: vi.fn(() => '/var/folders/ab/T'), - writeFileBase64Mock: vi.fn() - }) -) +const { writeFileMock, getPathMock, writeFileBase64Mock } = vi.hoisted(() => ({ + writeFileMock: vi.fn(), + getPathMock: vi.fn(() => '/var/folders/ab/T'), + writeFileBase64Mock: vi.fn() +})) vi.mock('node:fs/promises', () => ({ default: { writeFile: writeFileMock } })) vi.mock('node:crypto', () => ({ randomUUID: () => 'uuid-1' })) @@ -20,7 +17,6 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ writeFileBase64: writeFileBase64Mock }) })) -vi.mock('../ipc/filesystem-auth', () => ({ authorizeExternalPath: authorizeExternalPathMock })) import { saveClipboardImageBufferAsTempFile } from './clipboard-image-temp-file' @@ -29,22 +25,20 @@ beforeEach(() => { }) describe('saveClipboardImageBufferAsTempFile', () => { - it('authorizes the local temp file so the composer can preview what it just wrote', async () => { + it('writes the pasted image to the local temp folder', async () => { const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1, 2, 3])) + expect(savedPath.startsWith('/var/folders/ab/T')).toBe(true) expect(writeFileMock).toHaveBeenCalledWith(savedPath, Buffer.from([1, 2, 3])) - // The OS temp dir is outside every allowed root, so an unauthorized path - // makes fs:readFile deny the preview read of Orca's own file. - expect(authorizeExternalPathMock).toHaveBeenCalledWith(savedPath) }) - it('does not authorize a local path for an SSH save', async () => { + it('writes an SSH paste to the remote temp folder', async () => { const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1]), { connectionId: 'conn-1' }) expect(savedPath.startsWith('/remote/tmp/')).toBe(true) expect(writeFileBase64Mock).toHaveBeenCalled() - expect(authorizeExternalPathMock).not.toHaveBeenCalled() + expect(writeFileMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts index 0024c4b6d1f..cadbd677650 100644 --- a/src/main/window/clipboard-image-temp-file.ts +++ b/src/main/window/clipboard-image-temp-file.ts @@ -6,7 +6,6 @@ import { getAppEnvironment } from '../../shared/app-environment' import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image' -import { authorizeExternalPath } from '../ipc/filesystem-auth' export type SaveClipboardImageAsTempFileArgs = { connectionId?: string | null @@ -42,8 +41,5 @@ export async function saveClipboardImageBufferAsTempFile( const tempPath = path.join(getAppEnvironment().getPath('temp'), fileName) await fs.writeFile(tempPath, buffer) - // Why: the OS temp dir is outside every allowed root, so without this the - // composer's own thumbnail/preview read of the file it just wrote is denied. - authorizeExternalPath(tempPath) return tempPath } diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 1bcf1749c30..79f9eabc066 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -13,7 +13,6 @@ const { spawnMock, childStdinEndMock, resolveAuthorizedPathMock, - authorizeExternalPathMock, fsAccessMock, fsLstatMock, fsMkdirMock, @@ -50,7 +49,6 @@ const { return child }), resolveAuthorizedPathMock: vi.fn(), - authorizeExternalPathMock: vi.fn(), fsAccessMock: vi.fn(), fsLstatMock: vi.fn(), fsMkdirMock: vi.fn(), @@ -94,8 +92,7 @@ vi.mock('node:fs/promises', () => ({ vi.mock('../ipc/filesystem-auth', () => ({ PATH_ACCESS_DENIED_MESSAGE: 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.', - resolveAuthorizedPath: resolveAuthorizedPathMock, - authorizeExternalPath: authorizeExternalPathMock + resolveAuthorizedPath: resolveAuthorizedPathMock })) vi.mock('node:crypto', () => ({ @@ -321,7 +318,7 @@ describe('registerClipboardHandlers', () => { ).resolves.toEqual({ ok: true }) expect(fsStatMock).toHaveBeenCalledWith('/tmp/copied-file.txt') - expect(resolveAuthorizedPathMock).toHaveBeenCalledWith('/tmp/copied-file.txt', {}) + expect(resolveAuthorizedPathMock.mock.calls[0]?.[0]).toBe('/tmp/copied-file.txt') if (process.platform === 'darwin') { expect(clipboardWriteBufferMock).toHaveBeenCalledWith( 'public.file-url', diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index e81140d734a..7873316ef3d 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -9,7 +9,8 @@ import { import { spawn } from 'node:child_process' import { open, stat } from 'node:fs/promises' import type { Store } from '../persistence' -import { PATH_ACCESS_DENIED_MESSAGE, resolveAuthorizedPath } from '../ipc/filesystem-auth' +import { PATH_ACCESS_DENIED_MESSAGE } from '../ipc/filesystem-auth' +import { resolveDesktopAuthorizedPath } from '../ipc/local-file-access-resolution' import { isENOENT } from '../ipc/filesystem-path-containment' import { assertClipboardTextWriteWithinLimitWithYield, @@ -170,7 +171,7 @@ export function registerClipboardHandlers(store: Store): void { } const deps = makeClipboardFileDeps(async (path) => { try { - const authorizedPath = await resolveAuthorizedPath(path, store) + const authorizedPath = await resolveDesktopAuthorizedPath(path, store) await stat(authorizedPath) return { ok: true, path: authorizedPath } } catch (error) { diff --git a/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts b/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts index 6e09eb5fd3e..979c5b7d413 100644 --- a/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts +++ b/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts @@ -43,8 +43,7 @@ vi.mock('node:fs/promises', () => ({ })) vi.mock('../ipc/filesystem-auth', () => ({ PATH_ACCESS_DENIED_MESSAGE: 'denied', - resolveAuthorizedPath: vi.fn(), - authorizeExternalPath: vi.fn() + resolveAuthorizedPath: vi.fn() })) vi.mock('../ipc/runtime-environment-transport-routing', () => ({ callRuntimeEnvironment: callRuntimeEnvironmentMock 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/worktree-root-preparation.test.ts b/src/main/worktree-root-preparation.test.ts index c5f25eca502..eada5ebc261 100644 --- a/src/main/worktree-root-preparation.test.ts +++ b/src/main/worktree-root-preparation.test.ts @@ -2,23 +2,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as WslModule from './wsl' import type { Repo } from '../shared/repo-types' -const { mkdirMock, authorizeExternalPathMock, getWslHomeMock, getWslHomeAsyncMock } = vi.hoisted( - () => ({ - mkdirMock: vi.fn(), - authorizeExternalPathMock: vi.fn(), - getWslHomeMock: vi.fn(), - getWslHomeAsyncMock: vi.fn() - }) -) +const { mkdirMock, getWslHomeMock, getWslHomeAsyncMock } = vi.hoisted(() => ({ + mkdirMock: vi.fn(), + getWslHomeMock: vi.fn(), + getWslHomeAsyncMock: vi.fn() +})) vi.mock('fs/promises', () => ({ mkdir: mkdirMock })) -vi.mock('./ipc/filesystem-auth', () => ({ - authorizeExternalPath: authorizeExternalPathMock -})) - vi.mock('./wsl', async (importOriginal) => ({ ...(await importOriginal()), getWslHome: getWslHomeMock, @@ -43,7 +36,6 @@ const store = { describe('prepareLocalWorktreeRootForRepo', () => { beforeEach(() => { mkdirMock.mockReset().mockResolvedValue(undefined) - authorizeExternalPathMock.mockReset() getWslHomeMock.mockReset().mockImplementation(() => { throw new Error('synchronous wsl.exe home probe must not run on the main thread') }) @@ -114,13 +106,11 @@ describe('prepareLocalWorktreeRootForRepo', () => { await prepareLocalWorktreeRootForRepo(store as never, { ...repo, kind: 'folder' }) expect(mkdirMock).not.toHaveBeenCalled() - expect(authorizeExternalPathMock).not.toHaveBeenCalled() }) it('does not fail repo setup when root preparation fails', async () => { mkdirMock.mockRejectedValueOnce(new Error('permission denied')) await expect(prepareLocalWorktreeRootForRepo(store as never, repo)).resolves.toBeUndefined() - expect(authorizeExternalPathMock).not.toHaveBeenCalled() }) }) diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index d8b74cd9304..07230ae04ff 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -1,4 +1,5 @@ import type { PathExistenceResult } from '../../shared/path-existence-batch' +import type { RuntimeFileReadChunkResult } from '../../shared/runtime-types' import type { SearchOptions, SearchResult } from '../../shared/code-search-types' import type { DirEntry, @@ -17,6 +18,7 @@ import type { LocalLogTailWatchArgs } from '../../shared/local-log-tail-types' import type { SshMutationExpectation } from '../../shared/ssh-types' +import type { LocalFileAccess } from '../../shared/local-file-access' import type { CreateVenvResult, KernelFrameEvent, @@ -37,11 +39,19 @@ export type ExportApi = { export type FilesystemApi = { fs: { + readFileChunk: (args: { + filePath: string + connectionId?: string + access?: LocalFileAccess + offset: number + length: number + }) => Promise readDir: (args: { dirPath: string; connectionId?: string }) => Promise readFile: (args: { filePath: string connectionId?: string includeLocalLogMetadata?: boolean + access?: LocalFileAccess }) => Promise<{ content: string isBinary: boolean @@ -88,6 +98,7 @@ export type FilesystemApi = { filePath: string content: string connectionId?: string + access?: LocalFileAccess } & SshMutationExpectation ) => Promise createFile: ( @@ -104,6 +115,7 @@ export type FilesystemApi = { oldPath: string newPath: string connectionId?: string + access?: LocalFileAccess } & SshMutationExpectation ) => Promise copy: ( @@ -120,16 +132,20 @@ export type FilesystemApi = { recursive?: boolean } & SshMutationExpectation ) => Promise - authorizeExternalPath: (args: { targetPath: string }) => Promise stat: (args: { filePath: string connectionId?: string + access?: LocalFileAccess }) => Promise<{ size: number; isDirectory: boolean; mtime: number }> pathsExist?: (args: { filePaths: string[] connectionId?: string }) => Promise - pathExists: (args: { filePath: string; connectionId?: string }) => Promise + pathExists: (args: { + filePath: string + connectionId?: string + access?: LocalFileAccess + }) => Promise listFiles: (args: { rootPath: string connectionId?: string @@ -147,6 +163,7 @@ export type FilesystemApi = { destDir: string connectionId?: string ensureDir?: boolean + access?: LocalFileAccess } & SshMutationExpectation ) => Promise<{ results: ImportItemResult[] }> stageExternalPathsForRuntimeUpload: (args: { diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index d58dd17ff8f..aeea9b07fd0 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -1,6 +1,7 @@ import type { PathExistenceResult } from '../../shared/path-existence-batch' import { ipcRenderer } from 'electron' import type { SshMutationExpectation } from '../../shared/ssh-types' +import type { LocalFileAccess } from '../../shared/local-file-access' import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' import type { SearchResult } from '../../shared/code-search-types' import type { FsChangedPayload } from '../../shared/filesystem-entry-types' @@ -18,6 +19,13 @@ import type { import type { PreloadApi } from '../api-types' export const fsApi = { + readFileChunk: (args: { + filePath: string + connectionId?: string + access?: LocalFileAccess + offset: number + length: number + }) => ipcRenderer.invoke('fs:readFileChunk', args), readDir: (args: { dirPath: string connectionId?: string @@ -27,6 +35,7 @@ export const fsApi = { filePath: string connectionId?: string includeLocalLogMetadata?: boolean + access?: LocalFileAccess }): Promise<{ content: string isBinary: boolean @@ -91,6 +100,7 @@ export const fsApi = { filePath: string content: string connectionId?: string + access?: LocalFileAccess } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:writeFile', args), createFile: ( @@ -100,7 +110,12 @@ export const fsApi = { args: { dirPath: string; connectionId?: string } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:createDir', args), rename: ( - args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation + args: { + oldPath: string + newPath: string + connectionId?: string + access?: LocalFileAccess + } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:rename', args), copy: ( args: { @@ -116,19 +131,21 @@ export const fsApi = { recursive?: boolean } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:deletePath', args), - authorizeExternalPath: (args: { targetPath: string }): Promise => - ipcRenderer.invoke('fs:authorizeExternalPath', args), stat: (args: { filePath: string connectionId?: string + access?: LocalFileAccess }): Promise<{ size: number; isDirectory: boolean; mtime: number }> => ipcRenderer.invoke('fs:stat', args), pathsExist: (args: { filePaths: string[] connectionId?: string }): Promise => ipcRenderer.invoke('fs:pathsExist', args), - pathExists: (args: { filePath: string; connectionId?: string }): Promise => - ipcRenderer.invoke('fs:pathExists', args), + pathExists: (args: { + filePath: string + connectionId?: string + access?: LocalFileAccess + }): Promise => ipcRenderer.invoke('fs:pathExists', args), listFiles: (args: { rootPath: string connectionId?: string @@ -157,6 +174,7 @@ export const fsApi = { destDir: string connectionId?: string ensureDir?: boolean + access?: LocalFileAccess } & SshMutationExpectation ): Promise<{ results: ImportItemResult[] }> => ipcRenderer.invoke('fs:importExternalPaths', args), stageExternalPathsForRuntimeUpload: (args: { diff --git a/src/preload/api/jira-api.ts b/src/preload/api/jira-api.ts index 06b1e2a8ac8..7498fc819a2 100644 --- a/src/preload/api/jira-api.ts +++ b/src/preload/api/jira-api.ts @@ -76,6 +76,11 @@ export type JiraApi = { query?: string siteId?: string }) => Promise + listAssignableUsersForProject: (args: { + projectIdOrKey: string + query?: string + siteId?: string + }) => Promise searchUsers: (args?: { query?: string; siteId?: string }) => Promise listTransitions: (args: { key: string; siteId?: string }) => Promise getProjectStatusOrder: (args: { diff --git a/src/preload/api/jira-bridge.ts b/src/preload/api/jira-bridge.ts index 4b6b991095d..0aaf03521ff 100644 --- a/src/preload/api/jira-bridge.ts +++ b/src/preload/api/jira-bridge.ts @@ -46,6 +46,7 @@ export const jiraApi = { title: string description?: string customFields?: Record + userFieldKeys?: string[] }): Promise<{ ok: true; id: string; key: string; url: string } | { ok: false; error: string }> => ipcRenderer.invoke('jira:createIssue', args), @@ -78,6 +79,11 @@ export const jiraApi = { listAssignableUsers: (args: { key: string; query?: string; siteId?: string }) => ipcRenderer.invoke('jira:listAssignableUsers', args), + listAssignableUsersForProject: (args: { + projectIdOrKey: string + query?: string + siteId?: string + }) => ipcRenderer.invoke('jira:listAssignableUsersForProject', args), searchUsers: (args?: { query?: string; siteId?: string }) => ipcRenderer.invoke('jira:searchUsers', args), diff --git a/src/relay/fs-handler-list-files-cancel.test.ts b/src/relay/fs-handler-list-files-cancel.test.ts index b6829921cb4..ca3bfed18e9 100644 --- a/src/relay/fs-handler-list-files-cancel.test.ts +++ b/src/relay/fs-handler-list-files-cancel.test.ts @@ -73,6 +73,28 @@ describe('relay list-files cancellation', () => { expect(spawnMock).not.toHaveBeenCalled() }) + it('listFilesWithRg does not start the ignored pass after cancellation between passes', async () => { + vi.useFakeTimers() + const primaryProc = createMockProcess() + spawnMock.mockReturnValue(primaryProc) + const controller = new AbortController() + const promise = listFilesWithRg('/remote/root', [], { + signal: controller.signal, + maxResults: 10 + }) + const rejected = expect(promise).rejects.toSatisfy(isFileListingCancellation) + + primaryProc.stdout?.emit('data', 'src/index.ts\0') + primaryProc.emit('close', 0, null) + // Abort before the completed pass's promise continuation admits the broader scan. + controller.abort() + + await rejected + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(primaryProc.kill).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + it('listFilesWithRg still resolves normally when a signal is provided but never aborted', async () => { const ignoredProc = createMockProcess() spawnMock.mockReturnValue(ignoredProc) diff --git a/src/relay/fs-handler-list-files-diagnosis-cancel.test.ts b/src/relay/fs-handler-list-files-diagnosis-cancel.test.ts new file mode 100644 index 00000000000..d5e515bfd12 --- /dev/null +++ b/src/relay/fs-handler-list-files-diagnosis-cancel.test.ts @@ -0,0 +1,142 @@ +import { ChildProcess } from 'node:child_process' +import type * as ChildProcessModule from 'node:child_process' +import type * as FsPromises from 'node:fs/promises' +import { getEventListeners } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const { spawnMock, statMock, retryMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + statMock: vi.fn(), + retryMock: vi.fn() +})) +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + spawn: spawnMock +})) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: statMock, + access: vi.fn(async () => undefined) +})) +vi.mock('./relay-bundled-ripgrep', () => ({ + resolveRelayRipgrepCommand: () => '/tools/rg', + pathRipgrepCommand: () => '/fallback/rg', + retryRipgrepOnPathAfterLaunchFailure: retryMock +})) +import { listFilesWithRg } from './fs-handler-list-files' + +function child(spawned: boolean) { + const result = new ChildProcess() + result.stdout = new PassThrough() + result.stderr = new PassThrough() + Object.defineProperty(result, 'pid', { value: spawned ? 4321 : undefined }) + result.kill = vi.fn(() => true) + return result +} + +async function drainDiagnosis(): Promise { + for (let tick = 0; tick < 20; tick += 1) { + await Promise.resolve() + } +} + +beforeEach(() => { + vi.useFakeTimers() + spawnMock.mockReset() + statMock.mockReset().mockResolvedValue({ isDirectory: () => false }) + retryMock.mockReset().mockResolvedValue(false) +}) +afterEach(() => vi.useRealTimers()) + +it('settles an unexpected diagnosis rejection without leaving a listing pending', async () => { + retryMock.mockRejectedValue('diagnosis failed') + const failed = child(false) + spawnMock.mockReturnValue(failed) + const controller = new AbortController() + const result = listFilesWithRg('/missing/root', [], { signal: controller.signal }) + const rejected = expect(result).rejects.toThrow('diagnosis failed') + failed.emit('error', new Error('spawn ENOENT')) + await rejected + expect(spawnMock).toHaveBeenCalledOnce() + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + expect(vi.getTimerCount()).toBe(0) +}) + +it('skips diagnosis when a canceled listing resumes its retry check', async () => { + let completeRetry: (value: boolean) => void = () => undefined + retryMock.mockReturnValue( + new Promise((resolve) => { + completeRetry = resolve + }) + ) + const failed = child(false) + const probe = child(true) + spawnMock.mockReturnValueOnce(failed).mockReturnValue(probe) + const controller = new AbortController() + const result = listFilesWithRg('/missing/root', [], { signal: controller.signal }) + const canceled = expect(result).rejects.toMatchObject({ name: 'FileListingCancelledError' }) + failed.emit('error', new Error('spawn ENOENT')) + expect(retryMock).toHaveBeenCalledOnce() + controller.abort() + await canceled + completeRetry(false) + await drainDiagnosis() + const statCalls = statMock.mock.calls.length + probe.emit('close', 0) + await drainDiagnosis() + expect(statCalls).toBe(0) + expect(spawnMock).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) +}) + +it('skips version probes when cancellation wins a pending cwd check', async () => { + let completeStat: (value: { isDirectory: () => boolean }) => void = () => undefined + statMock.mockReturnValue( + new Promise((resolve) => { + completeStat = resolve + }) + ) + const failed = child(false) + const probe = child(true) + spawnMock.mockReturnValueOnce(failed).mockReturnValue(probe) + const controller = new AbortController() + const result = listFilesWithRg('/missing/root', [], { signal: controller.signal }) + const canceled = expect(result).rejects.toMatchObject({ name: 'FileListingCancelledError' }) + failed.emit('error', new Error('spawn ENOENT')) + await drainDiagnosis() + expect(statMock).toHaveBeenCalledOnce() + controller.abort() + await canceled + completeStat({ isDirectory: () => false }) + await drainDiagnosis() + const spawnCalls = spawnMock.mock.calls.length + probe.emit('close', 0) + await drainDiagnosis() + expect(spawnCalls).toBe(1) + expect(vi.getTimerCount()).toBe(0) +}) + +it('terminates a running diagnosis probe when its listing is canceled', async () => { + const failed = child(false) + const probe = child(true) + spawnMock.mockReturnValueOnce(failed).mockReturnValue(probe) + const controller = new AbortController() + const result = listFilesWithRg('/missing/root', [], { signal: controller.signal }) + const canceled = expect(result).rejects.toMatchObject({ name: 'FileListingCancelledError' }) + failed.emit('error', new Error('spawn ENOENT')) + await drainDiagnosis() + expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock.mock.calls[1][1]).toEqual(['--version']) + controller.abort() + await canceled + const killCalls = vi.mocked(probe.kill).mock.calls.length + const closeListeners = probe.listenerCount('close') + probe.emit('close', 0) + await drainDiagnosis() + expect(killCalls).toBe(1) + expect(closeListeners).toBe(0) + expect(spawnMock).toHaveBeenCalledTimes(2) + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0) + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index ca25cd85e9e..93c325c301a 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -77,10 +77,8 @@ export function listFilesWithRg( } // Why: correctness backstop. The rg globs prune most blocklisted dirs, // but a glob edge case could still surface e.g. a .git/ or .npm/ hit. - if (!shouldIncludeQuickOpenPath(relPath)) { - return true - } - if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) { + const excluded = shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes) + if (!shouldIncludeQuickOpenPath(relPath) || excluded) { return true } if (attemptRanker) { @@ -96,6 +94,10 @@ export function listFilesWithRg( const runPassOnce = (args: string[]): Promise => new Promise((passResolve, passReject) => { + // A completed pass may queue its continuation before cancellation settles the request. + if (done || signal?.aborted) { + return passResolve() + } const attemptRanker = searchQuery === undefined ? null : new QuickOpenPathRanker(searchQuery, maxResults ?? 16) const filenameDecoder = new RipgrepFilenameDecoder((error) => { @@ -121,7 +123,7 @@ export function listFilesWithRg( child = spawn(command, ['--no-messages', ...args], { cwd: rootPath, env, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }) } catch (error) { @@ -132,11 +134,8 @@ export function listFilesWithRg( : error } const cleanup = (): void => { - if (timer) { - clearTimeout(timer) - } + clearTimeout(timer) child.stdout?.off('data', handleStdoutData) - child.stderr?.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) absorbPendingRipgrepSpawnError(child, { @@ -144,14 +143,14 @@ export function listFilesWithRg( unavailableExitObserved }) } - const rejectPass = (error: Error): void => { + const rejectPass = (error: unknown): void => { if (passDone) { return } passDone = true passBuf = '' cleanup() - passReject(error) + passReject(error instanceof Error ? error : new Error(String(error))) } const resolvePass = (): void => { if (passDone) { @@ -168,8 +167,11 @@ export function listFilesWithRg( if (launchFailureCheck) { return } - launchFailureCheck = retryRipgrepOnPathAfterLaunchFailure(command, rootPath, error).then( - async (retryOnPath) => { + launchFailureCheck = retryRipgrepOnPathAfterLaunchFailure(command, rootPath, error) + .then(async (retryOnPath) => { + if (passDone || done) { + return + } if (retryOnPath) { // Why: runPass retries a launch failure once, and the retry resolves to PATH rg. rejectPass(new RipgrepLaunchFailureError('bundled rg failed to start')) @@ -181,13 +183,14 @@ export function listFilesWithRg( (await classifyRipgrepLaunchFailure( rootPath, [command, pathRipgrepCommand()], - env + env, + signal )) === 'cwd-unreachable' ? ripgrepMissingCwdError(rootPath) : new RipgrepUnavailableError() ) - } - ) + }) + .catch(rejectPass) } children.push({ child, isDone: () => passDone, reject: rejectPass }) @@ -218,9 +221,6 @@ export function listFilesWithRg( } passBuf = start < passBuf.length ? passBuf.substring(start) : '' } - function handleStderrData(): void { - /* drain to prevent backpressure stalls */ - } function handleError(err: NodeJS.ErrnoException): void { processErrorObserved = true if (isTransientRipgrepSpawnError(err)) { @@ -277,7 +277,6 @@ export function listFilesWithRg( } child.stdout?.on('data', handleStdoutData) - child.stderr?.on('data', handleStderrData) child.once('error', handleError) child.once('close', handleClose) }) diff --git a/src/relay/opencode-hook-selection.test.ts b/src/relay/opencode-hook-selection.test.ts index 6e47c28bc36..0650662762c 100644 --- a/src/relay/opencode-hook-selection.test.ts +++ b/src/relay/opencode-hook-selection.test.ts @@ -27,6 +27,9 @@ vi.mock('node-pty', () => ({ spawn: mocks.mockPtySpawn })) vi.mock('../main/shell-prompt-readiness-probe', () => ({ createShellPromptReadinessProbe: mocks.mockCreateShellPromptReadinessProbe })) +vi.mock('../main/opencode/opencode-launch-capabilities', () => ({ + probeOpenCodeLaunchCapabilities: async () => null +})) vi.mock('../main/pty/posix-pty-process-groups', () => ({ forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) })) @@ -94,6 +97,38 @@ async function spawn(params: Record = {}): Promise { + it.each(['home', 'xdg', 'shell'] as const)( + 'consumer config root follows the remote %s environment', + async (kind) => { + await install('// remote plugin', '') + const home = join(root, 'remote-home') + const xdg = join(root, 'remote-xdg') + const consumer = kind === 'home' ? join(home, '.config', 'opencode') : join(xdg, 'opencode') + mkdirSync(home, { recursive: true }) + mkdirSync(consumer, { recursive: true }) + writeFileSync(join(consumer, 'opencode.json'), '{"model":"remote-model"}') + if (kind === 'shell') { + writeFileSync(join(home, '.zshrc'), `export XDG_CONFIG_HOME='${xdg}'\n`) + } + const env = await spawn({ + cwd: home, + launchAgent: 'opencode', + env: { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: kind === 'xdg' ? xdg : '', + SHELL: kind === 'shell' ? '/bin/zsh' : '/bin/sh' + } + }) + expect(readFileSync(plugin(consumer, 'opencode'), 'utf8')).toBe('// remote plugin') + expect(existsSync(plugin(join(root, 'xdg', 'opencode'), 'opencode'))).toBe(false) + expect(readFileSync(join(consumer, 'opencode.json'), 'utf8')).toBe('{"model":"remote-model"}') + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.HOME).toBe(home) + expect(env.XDG_CONFIG_HOME).toBe(kind === 'xdg' ? xdg : '') + } + ) + it('leaves a standalone relay without supplied sources unconfigured', async () => { const env = await spawn() expect(env.ORCA_OPENCODE_AGENT).toBeUndefined() diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index 4ee9113919a..c991b3fde71 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -25,6 +25,7 @@ import { selectOpenCodeHookAgent } from '../shared/opencode-launch-command' import { relayLogLine } from './relay-diagnostic-log' import { restoreOrStripOverlayEnv } from '../shared/agent-overlay-env' import { registerManagedHookInstaller } from './managed-hook-installer' +import { readSessionShellStartupEnvVar } from '../main/pty/shell-startup-env' export class RelayAgentHookRuntime { private readonly hookServer: RelayAgentHookServer @@ -122,7 +123,12 @@ export class RelayAgentHookRuntime { env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = sourceDir } } else { - this.pluginOverlay.installOpenCodePlugin(opencodeAgent, context.env) + this.pluginOverlay.installOpenCodePlugin(opencodeAgent, { + ...context.env, + XDG_CONFIG_HOME: + readSessionShellStartupEnvVar('XDG_CONFIG_HOME', context.env, context.shell) ?? + context.env.XDG_CONFIG_HOME + }) } } const explicitKind = isPiCompatibleAgentType(context.launchAgent) diff --git a/src/renderer/src/components/LinearIssueTextEditor.tsx b/src/renderer/src/components/LinearIssueTextEditor.tsx index 4301174ceb5..66806bf70f9 100644 --- a/src/renderer/src/components/LinearIssueTextEditor.tsx +++ b/src/renderer/src/components/LinearIssueTextEditor.tsx @@ -1,3 +1,4 @@ +import { ImeTextarea } from '@/lib/ime-text-field' import React, { useCallback, useRef, useState } from 'react' import { LoaderCircle } from 'lucide-react' import { toast } from 'sonner' @@ -190,7 +191,7 @@ export function LinearIssueTextEditor({

{fields !== 'description' ? (
-