diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 6f67dd25ee4..6b07312e158 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -27,6 +27,12 @@ on: required: false type: boolean default: true + # Why: artifact names are unique per workflow run, and pr.yml calls this workflow twice. + artifact_suffix: + description: Appended to the build and trace artifact names so two calls in one run do not collide + required: false + type: string + default: '' workflow_dispatch: inputs: ref: @@ -91,7 +97,7 @@ jobs: - name: Upload E2E build output uses: actions/upload-artifact@v7 with: - name: e2e-build-out + name: e2e-build-out${{ inputs.artifact_suffix }} path: out/ # Why: build-relay.mjs writes each relay's marker as `out/relay//.version`, # and upload-artifact drops dotfiles by default — consumers then fail SSH specs with @@ -201,7 +207,7 @@ jobs: - name: Download E2E build output uses: actions/download-artifact@v8 with: - name: e2e-build-out + name: e2e-build-out${{ inputs.artifact_suffix }} path: out/ # Why: the Electron suite is wall-clock constrained on OSS runners, but @@ -291,7 +297,7 @@ jobs: - name: Download E2E build output uses: actions/download-artifact@v8 with: - name: e2e-build-out + name: e2e-build-out${{ inputs.artifact_suffix }} path: out/ - name: Run changed E2E specs @@ -345,7 +351,7 @@ jobs: if: failure() || cancelled() uses: actions/upload-artifact@v7 with: - name: playwright-traces-changed + name: playwright-traces-changed${{ inputs.artifact_suffix }} path: test-results/ retention-days: 7 if-no-files-found: ignore @@ -393,7 +399,7 @@ jobs: native-runtime: electron - uses: actions/download-artifact@v8 with: - name: e2e-build-out + name: e2e-build-out${{ inputs.artifact_suffix }} path: out/ # The packaged orcad slot the T6-11 launcher runs: its own node-pty under the pinned Node. # The template is what `orca serve`'s default selection materializes that slot from. @@ -423,7 +429,7 @@ jobs: - uses: actions/upload-artifact@v7 if: failure() with: - name: orcad-serve-mode-switch-traces + name: orcad-serve-mode-switch-traces${{ inputs.artifact_suffix }} path: test-results/ retention-days: 7 if-no-files-found: ignore @@ -451,7 +457,7 @@ jobs: native-runtime: electron - uses: actions/download-artifact@v8 with: - name: e2e-build-out + name: e2e-build-out${{ inputs.artifact_suffix }} path: out/ # Built to a path the app does not look at by default, so the relay phase has no template. - name: Build the linux-x64-glibc orcad template @@ -474,7 +480,7 @@ jobs: - uses: actions/upload-artifact@v7 if: failure() with: - name: orcad-auto-convert-docker-traces + name: orcad-auto-convert-docker-traces${{ inputs.artifact_suffix }} path: test-results/ retention-days: 7 if-no-files-found: ignore @@ -501,7 +507,7 @@ jobs: native-runtime: electron - uses: actions/download-artifact@v8 with: - name: e2e-build-out + name: e2e-build-out${{ inputs.artifact_suffix }} path: out/ - name: Build the linux-x64-glibc orcad template run: | @@ -522,7 +528,7 @@ jobs: - uses: actions/upload-artifact@v7 if: failure() with: - name: orcad-journeys-docker-traces + name: orcad-journeys-docker-traces${{ inputs.artifact_suffix }} path: test-results/ retention-days: 7 if-no-files-found: ignore @@ -580,7 +586,7 @@ jobs: - uses: actions/upload-artifact@v7 if: failure() with: - name: orcad-serve-mode-switch-windows-traces + name: orcad-serve-mode-switch-windows-traces${{ inputs.artifact_suffix }} path: test-results/ retention-days: 7 if-no-files-found: ignore diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml index 64f527377b5..975b0618c7f 100644 --- a/.github/workflows/mobile-ios-release.yml +++ b/.github/workflows/mobile-ios-release.yml @@ -104,8 +104,6 @@ jobs: - name: Expo prebuild env: ORCA_IOS_APS_ENVIRONMENT: production - # Set the repo variable to 1 only after the App ID has Associated Domains enabled. - ORCA_IOS_ASSOCIATED_DOMAINS: ${{ vars.ORCA_IOS_ASSOCIATED_DOMAINS }} run: npx expo prebuild --platform ios --no-install - name: Install CocoaPods diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 381b3a24232..30dc63186e1 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -36,6 +36,7 @@ on: - 'config/scripts/mobile-release-check-scope*' - 'config/scripts/mobile-test-change-scope*' - 'config/scripts/pr-code-change-scope.mjs' + - 'config/scripts/pr-layout-oracle-route.mjs' # Why main too: two pull requests can each pass against their own base and disagree once both # land, and `verify` never runs on main. The same source paths as above, less the CI inputs only # `verify` reads. diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 994123c0e50..087a6759ebd 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -205,6 +205,12 @@ jobs: env: ORCA_BUN_ORCAD_SLOT: ${{ steps.bun-orcad.outputs.slot }} BUN_EXECUTABLE: ${{ steps.bun-orcad.outputs.executable }} + - name: Build the host-target desktop server template + if: runner.os == 'Linux' + run: node config/scripts/build-orcad-template.mjs --targets linux-${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }}-glibc + - name: Verify a structured chat from the desktop server template + if: runner.os == 'Linux' + run: pnpm test:node-server --artifact src/main/orcad/orcad-packaged-structured-chat.integration.test.ts --maxWorkers=2 # Only a Windows runner compiles it; arm64 cross-compiles here. # Before the Node 18 check below: the build script imports TypeScript, which Node 18 cannot load. # orcad's instance lock and stop proof read a PID's creation time through it; the x64 binary this diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8f8766d53bf..ba11ca658fd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -50,6 +50,7 @@ jobs: managed_hook_node18: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.managed_hook_node18 }} package: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package }} package_windows: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package_windows }} + layout_oracle: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.layout_oracle }} e2e_should_run: ${{ steps.e2e_filter.outputs.should_run }} test_files: ${{ steps.e2e_filter.outputs.test_files }} e2e_run_changed: ${{ steps.e2e_filter.outputs.e2e_run_changed }} @@ -75,6 +76,7 @@ jobs: /.github/scripts/check-root-directory-entries.mjs /config/scripts/check-readme-local-links.mjs /config/scripts/pr-code-change-scope.mjs + /config/scripts/pr-layout-oracle-route.mjs /config/scripts/pr-e2e-source-routing.mjs /config/scripts/ci-e2e-job-selection.mjs /config/scripts/pr-ready-check-reuse.mjs @@ -118,7 +120,7 @@ jobs: printf '%s\n' "$CHANGED" MOBILE_CI_ARGS=() if git diff --quiet --no-renames --diff-filter=ACDRTUXB "$DIFF_BASE" HEAD; then - MOBILE_CI_ARGS=(--mobile-ci-tools) + MOBILE_CI_ARGS=(--mobile-ci-tools --daemon-init-test-support) fi printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs "${MOBILE_CI_ARGS[@]}" | tee -a "$GITHUB_OUTPUT" @@ -131,9 +133,13 @@ jobs: BASE="${{ github.event.pull_request.base.sha }}" DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE")" CHANGED="$(git diff --name-only --diff-filter=AMCR "$DIFF_BASE" HEAD)" + E2E_SCOPE_ARGS=() + if git diff --quiet --no-renames --diff-filter=ACDRTUXB "$DIFF_BASE" HEAD; then + E2E_SCOPE_ARGS=(--daemon-init-test-support) + fi # Source routes are executable contracts so a test can prove exact # authorities, exclusions, and sentinels without evaluating workflow shell. - TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)" + TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs "${E2E_SCOPE_ARGS[@]}")" echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT" printf '%s\n' "$TEST_FILES_JSON" | node config/scripts/ci-e2e-job-selection.mjs --job-outputs >> "$GITHUB_OUTPUT" # Why its own signal: the real-IME lane is a whole ibus session, not a spec, so it must @@ -144,7 +150,7 @@ jobs: WSL_SOURCE_CHANGED="$(printf '%s\n' "$WSL_CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --wsl-source)" echo "wsl_source_changed=$WSL_SOURCE_CHANGED" >> "$GITHUB_OUTPUT" echo "Native IME source changed: $NATIVE_IME_SOURCE_CHANGED" - SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --reusable-workflow)" + SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --reusable-workflow "${E2E_SCOPE_ARGS[@]}")" if [ "$SHOULD_RUN" = true ]; then echo "should_run=true" >> "$GITHUB_OUTPUT" echo "Changed E2E specs: $TEST_FILES_JSON" @@ -1373,6 +1379,29 @@ jobs: run_changed_e2e: ${{ needs.code_paths.outputs.e2e_run_changed != 'false' }} needs_build: ${{ needs.code_paths.outputs.e2e_needs_build != 'false' }} + # Why its own required job, apart from `e2e`: the rest of the suite is red on main, but the + # layout oracle is green there, and its known-on-main list absorbs today's findings. Only the two + # oracle specs run, through the same reusable workflow: the window spec in the changed-spec lane, + # the headless spec in the orcad lane on both serve hosts. + layout_oracle: + name: layout oracle + needs: [code_paths, preflight] + if: >- + !cancelled() && + needs.code_paths.result == 'success' && + needs.code_paths.outputs.layout_oracle == 'true' && + needs.preflight.result == 'success' + permissions: + contents: read + uses: ./.github/workflows/e2e.yml + with: + # Why the merge commit, unlike `e2e`: a gate must test what lands on main, not a stale head. + ref: ${{ github.sha }} + test_files: '["tests/e2e/workspace-layout-oracle-headless.spec.ts","tests/e2e/workspace-layout-oracle.spec.ts"]' + run_changed_e2e: true + needs_build: true + artifact_suffix: -layout-oracle + # Why this is not in verify's needs: it is the first PR-gate run of a harness whose reliability # is only known from nightly main runs (20/20 green, 2026-08-09..2026-08-29, p50 3m25s). It # reports a red X on the PR without blocking, exactly like `e2e` above. Deliberately no @@ -1413,6 +1442,7 @@ jobs: - managed_hook_node18 - package - package_windows + - layout_oracle # Evaluating job results only needs the lightweight container runner. runs-on: ubuntu-slim timeout-minutes: 5 @@ -1452,6 +1482,8 @@ jobs: PACKAGE_SHOULD_RUN: ${{ needs.code_paths.outputs.package }} PACKAGE_WINDOWS: ${{ needs.package_windows.result }} PACKAGE_WINDOWS_SHOULD_RUN: ${{ needs.code_paths.outputs.package_windows }} + LAYOUT_ORACLE: ${{ needs.layout_oracle.result }} + LAYOUT_ORACLE_SHOULD_RUN: ${{ needs.code_paths.outputs.layout_oracle }} run: | if [ "$CODE_PATHS" != "success" ]; then exit 1 @@ -1486,4 +1518,5 @@ jobs: check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN" check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN" check_job package_windows "$PACKAGE_WINDOWS" "$PACKAGE_WINDOWS_SHOULD_RUN" + check_job layout_oracle "$LAYOUT_ORACLE" "$LAYOUT_ORACLE_SHOULD_RUN" exit "$failed" diff --git a/cloud/infra/terraform/environments/production.tfvars b/cloud/infra/terraform/environments/production.tfvars index 6b9f1d408ce..102666a6dde 100644 --- a/cloud/infra/terraform/environments/production.tfvars +++ b/cloud/infra/terraform/environments/production.tfvars @@ -21,6 +21,9 @@ relay_base_url = "https://relay.onorca.dev" # concurrency x instances. Scaling to 2 instances took placement failures 35% -> 70%. relay_min_instances = 5 relay_max_instances = 5 +# Proven on staging (2026-10-11, image F) before production turns them on. +relay_director_cloud_sql_proxy_sidecar = true +relay_event_loop_stall_alert = true # Production cells run only on fixed-one GCE MIGs; Cloud Run remains the director. relay_cells = {} manage_relay_domain_mapping = true diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 3e72c682343..6f39a744c56 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -277,12 +277,9 @@ 2 src/renderer/src/components/use-worktree-jump-palette-store-state.ts 1 src/renderer/src/components/use-worktree-jump-palette-task-url.ts 1 src/renderer/src/components/worktree-jump-palette-create-worktree.ts -1 src/renderer/src/hooks/composer-state/attachment-drop-state.ts -1 src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts 1 src/renderer/src/hooks/composer-state/composer-navigation-actions.ts -2 src/renderer/src/hooks/composer-state/composer-source-state.ts -1 src/renderer/src/hooks/composer-state/composer-target-state.ts -1 src/renderer/src/hooks/composer-state/workspace-identity-state.ts +1 src/renderer/src/hooks/composer-state/composer-source-state.ts +1 src/renderer/src/hooks/composer-state/runtime-target-selection.ts 1 src/renderer/src/hooks/editor-external-watch-target-rows.ts 3 src/renderer/src/hooks/editor-external-watch-targets.ts 1 src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts @@ -345,7 +342,7 @@ 2 src/renderer/src/lib/provider-runtime-context.ts 2 src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts 1 src/renderer/src/lib/remote-workspace-drop-test-fixtures.ts -3 src/renderer/src/lib/repo-runtime-owner.ts +2 src/renderer/src/lib/repo-runtime-owner.ts 2 src/renderer/src/lib/resume-sleeping-agent-session.ts 1 src/renderer/src/lib/runtime-session-mirror-owners.ts 1 src/renderer/src/lib/runtime-session-mirror-targets.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index c06100173c6..3b735730964 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -4263,28 +4263,25 @@ "terminal input availability", "worktree discovery", "Source Control status refresh", - "direct SSH detected-worktree scheduling", - "direct SSH reconnect telemetry", - "direct SSH host catalog authority", "direct SSH generation-scope rollover" ], "platforms": ["macos", "linux", "windows"], "providers": ["local", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "ssh"], - "coverageNotes": "Local deterministic evidence covers git-common classification, desktop watcher debounce counts, non-overlapping poller semantics, macOS native-watch fallback, preload cleanup, Source Control active-visible repo filtering, paired-runtime publication to two independent clients with reconnect and stale-cache checks, the direct SSH five-slot fair scheduler, timeout barrier, aggregate privacy schema, coordinator-to-renderer telemetry wiring, host-catalog provenance rejection, and process generation-scope rollover across sibling targets. A headed macOS Electron host and separate paired Electron client both rendered a worktree created through the real Git binary and watcher entry path without reconnect. A macOS Electron client also completed a direct SSH disconnect/reconnect against an ephemeral Linux Docker target with exact host/authority hydration and remote proof-file verification. Linux/Windows desktop clients, multi-target live fanout, and WSL runs remain gaps.", + "coverageNotes": "Local deterministic evidence covers git-common classification, desktop watcher debounce counts, non-overlapping poller semantics, macOS native-watch fallback, preload cleanup, Source Control active-visible repo filtering, paired-runtime publication to two independent clients with reconnect and stale-cache checks, and process generation-scope rollover across sibling targets. A headed macOS Electron host and separate paired Electron client both rendered a worktree created through the real Git binary and watcher entry path without reconnect. Linux/Windows desktop clients, multi-target live fanout, and WSL runs remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/7086", "https://github.com/stablyai/orca/issues/16477" ], - "invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control. When the authoritative paired host accepts an external structural change for a uniquely identified server-local repo, each live client for that host must receive one existing host-scoped worktreesChanged invalidation and converge to the same repo/worktree identity without reconnect; duplicate watcher events must coalesce, stale cached or in-flight catalogs must not survive invalidation, and event-ignoring clients must remain compatible. Nested SSH watchers and colliding local/SSH repo IDs must not publish the host-blind event until existing semantics can carry owner identity. External head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout. Direct SSH reconnect discovery must stay host- and authority-qualified, reject contradictory main-catalog provenance without returning rows, admit at most five locally unsettled provider calls, retain a retrying timeout barrier, and emit one identifier-free aggregate product event per target operation. A process generation-scope rollover revokes every direct SSH target and old-scope provider request, not only the target whose counter exhausted.", - "oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters. Join the real runtime RPC server to two independently authenticated E2EE clients plus an event-ignoring client, advance the host Git inventory, trigger duplicate structural watcher callbacks, and require one host renderer notification plus one runtime event and a refreshed catalog per subscribed client; require the event-ignoring client to refresh safely on explicit read. Hold a scan after it captures old inventory, join it from a second paired client, publish the watcher invalidation, advance Git independently, and require both overlapping RPCs to retry or join one fresh scan before returning. Disconnect one client, advance inventory again, and require reconnect to read current state without stale event replay. Give a watcher a nested SSH connection id or a local repo ID colliding with an SSH owner and require the host renderer refresh while the unqualified runtime event stays absent. Force the real path in isolated Electron apps with git worktree add, a host Git inventory assertion, store-subscription barriers, matching host/client worktree identity, and visible rows in both renderers. For direct SSH, reject catalog rows whose explicit and legacy host provenance contradict, roll one exhausted target into a fresh process generation scope while invalidating sibling target tokens, count locally unsettled attempts and round-robin admissions, keep lineage blocked through the first timeout retry, distinguish timeout/rejection/cancel/stale results, and reject telemetry properties carrying target, repo, host, path, label, user, request, lease, terminal, or raw-error data.", + "invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control. When the authoritative paired host accepts an external structural change for a uniquely identified server-local repo, each live client for that host must receive one existing host-scoped worktreesChanged invalidation and converge to the same repo/worktree identity without reconnect; duplicate watcher events must coalesce, stale cached or in-flight catalogs must not survive invalidation, and event-ignoring clients must remain compatible. Nested SSH watchers and colliding local/SSH repo IDs must not publish the host-blind event until existing semantics can carry owner identity. External head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout. A process generation-scope rollover revokes every direct SSH target and old-scope provider request, not only the target whose counter exhausted.", + "oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters. Join the real runtime RPC server to two independently authenticated E2EE clients plus an event-ignoring client, advance the host Git inventory, trigger duplicate structural watcher callbacks, and require one host renderer notification plus one runtime event and a refreshed catalog per subscribed client; require the event-ignoring client to refresh safely on explicit read. Hold a scan after it captures old inventory, join it from a second paired client, publish the watcher invalidation, advance Git independently, and require both overlapping RPCs to retry or join one fresh scan before returning. Disconnect one client, advance inventory again, and require reconnect to read current state without stale event replay. Give a watcher a nested SSH connection id or a local repo ID colliding with an SSH owner and require the host renderer refresh while the unqualified runtime event stays absent. Force the real path in isolated Electron apps with git worktree add, a host Git inventory assertion, store-subscription barriers, matching host/client worktree identity, and visible rows in both renderers. For direct SSH, roll one exhausted target into a fresh process generation scope while invalidating sibling target tokens, and fill a partial pushed authority before clearing that target's PTY bindings once.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktree-base-directory-event-filter.test.ts src/main/ipc/worktree-base-directory-watcher.test.ts src/main/ipc/worktree-base-directory-poller.test.ts src/main/ipc/worktree-head-identity-reader.test.ts src/renderer/src/hooks/worktree-head-identity-apply.test.ts src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/watched-worktree-catalog-notification.test.ts src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts src/main/window/attach-main-window-services.test.ts", "ORCA_E2E_FORCE_HEADFUL=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-external-worktree-discovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/shared/direct-ssh-reconnect-telemetry-schema.test.ts src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot" + "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot" ], "testFiles": [ "src/main/ipc/worktree-base-directory-event-filter.test.ts", @@ -4299,13 +4296,8 @@ "src/main/window/attach-main-window-services.test.ts", "tests/e2e/paired-external-worktree-discovery.spec.ts", "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts", - "src/main/ipc/repos-remote.test.ts", "src/main/ssh/ssh-connection-generation.test.ts", - "src/main/ssh/ssh-provider-authority.test.ts", - "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts", - "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts", - "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts", - "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts" + "src/main/ssh/ssh-provider-authority.test.ts" ], "assertionRefs": [ { @@ -4367,7 +4359,7 @@ { "file": "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts", "assertions": [ - "direct SSH coordinator telemetry is wired through the fail-soft product adapter" + "a filled partial SSH authority clears the target's PTY bindings once and a conflicting reply is rejected" ] }, { @@ -4403,13 +4395,6 @@ "the paired client's main-process shared-control transport keeps one connection epoch while converging and compares Windows paths case-insensitively" ] }, - { - "file": "src/main/ipc/repos-remote.test.ts", - "assertions": [ - "a host-qualified catalog rejects contradictory executionHostId and connectionId provenance without returning rows", - "local, sibling SSH, and runtime rows remain excluded from the exact direct SSH catalog" - ] - }, { "file": "src/main/ssh/ssh-connection-generation.test.ts", "assertions": [ @@ -4423,36 +4408,6 @@ "generation-scope rollover invalidates every target authority before abort callbacks run", "every registered old-scope provider request aborts exactly once" ] - }, - { - "file": "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts", - "assertions": [ - "coordinator-owned locally unsettled provider work never exceeds five and target lanes round-robin", - "the first timeout remains retrying and reports queue wait separately from provider execution", - "cancel debt admits at most two replacements and terminally distinguishes budget exhaustion" - ] - }, - { - "file": "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", - "assertions": [ - "lineage and token creation remain blocked until a timed-out repo retry settles", - "exact overlapping preparation emits one aggregate with a join count", - "telemetry callback failure cannot affect reconnect completion" - ] - }, - { - "file": "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts", - "assertions": [ - "timeout, rejection, cancellation, and stale outcomes have independent fields", - "target, repo, host, path, label, user, request, lease, terminal, and raw-error fields are rejected" - ] - }, - { - "file": "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts", - "assertions": [ - "one coordinator aggregate maps to one typed product event with queue and provider percentiles", - "adapter failure is swallowed before it can reach recovery" - ] } ], "evidenceRuns": [ @@ -4469,19 +4424,19 @@ "date": "2026-07-27", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot", "result": "passed", "durationSeconds": 1.61, - "summary": "Three main-process catalog and authority files passed with 118 tests, including contradictory catalog provenance rejection and all-target generation-scope revocation." + "summary": "Three main-process catalog and authority files passed with 118 tests, including contradictory catalog provenance rejection and all-target generation-scope revocation. The third file, src/main/ipc/repos-remote.test.ts, was later deleted with the SSH repo IPC handlers." }, { - "date": "2026-07-27", + "date": "2026-10-11", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/direct-ssh-reconnect-telemetry-schema.test.ts src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 3.45, - "summary": "Five focused direct SSH scheduler, coordinator, telemetry, and hook-wiring files passed with 130 tests." + "durationSeconds": 1.21, + "summary": "The direct SSH state-bridge wiring file passed with 6 tests after the reconnect coordinator, scheduler and telemetry were deleted." }, { "date": "2026-08-26", @@ -4512,11 +4467,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "The watcher count assertions fail against the old single-signal classifier because linked index events call notifyWorktreesChanged. The direct SSH tests encode failures for unbounded admission, early lineage release, merged timeout/rejection/cancel/stale results, identifier-bearing telemetry, duplicate joined events, and telemetry exceptions, but no intentional-break artifact was run or claimed; saved red/green artifacts are still needed before blocking promotion." + "evidence": "The watcher count assertions fail against the old single-signal classifier because linked index events call notifyWorktreesChanged. No intentional-break artifact was run or claimed; saved red/green artifacts are still needed before blocking promotion." }, "performanceBudget": { "required": true, - "evidence": "Index-only bursts produce zero structural notifications, so renderer fetchWorktrees and detected-worktree cache invalidation are not reached. The non-darwin poller stays bounded and non-overlapping. Direct SSH coordinator-owned detected-worktree work is capped at five locally unsettled calls with a two-call late-work allowance; terminal finalization precedes provider discovery, and queue wait and provider execution are reported separately. A 30-second live Electron run with 2,000 external linked-status calls delivered 50 ordered input chunks and recorded zero Orca-owned git worktree spawns across six diagnostic windows; no equivalent live direct SSH fanout benchmark is claimed." + "evidence": "Index-only bursts produce zero structural notifications, so renderer fetchWorktrees and detected-worktree cache invalidation are not reached. The non-darwin poller stays bounded and non-overlapping. A 30-second live Electron run with 2,000 external linked-status calls delivered 50 ordered input chunks and recorded zero Orca-owned git worktree spawns across six diagnostic windows." }, "promotionCriteria": [ "Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.", @@ -12958,8 +12913,8 @@ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts src/main/ipc/worktrees-lineage-hydration.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts src/main/ipc/worktrees-lineage-hydration.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", "pnpm run build:web-from-renderer", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", @@ -13001,11 +12956,8 @@ "src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts", "src/renderer/src/store/slices/terminals-hydration.test.ts", "src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts", - "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", - "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts", "src/renderer/src/hooks/direct-ssh-state-routing.test.ts", "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts", - "src/main/ipc/repos-remote.test.ts", "src/main/ipc/ssh.test.ts", "src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts", "src/main/ipc/worktrees-lineage-hydration.test.ts", @@ -13172,13 +13124,6 @@ "a detached null-PTY leaf remains marked for pending activation before ownership transfer" ] }, - { - "file": "src/main/ipc/repos-remote.test.ts", - "assertions": [ - "host-qualified repo catalogs require one consistent execution host and complete current SSH authority", - "contradictory, partial, mismatched, stale, and runtime-owned catalog requests fail closed" - ] - }, { "file": "src/main/ipc/ssh.test.ts", "assertions": [ @@ -13270,20 +13215,6 @@ "a provider result becomes stale when same-ID repo ownership turns malformed or contradictory during the await" ] }, - { - "file": "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", - "assertions": [ - "terminal invalidation and retry run synchronously before provider preparation", - "hydrated terminal finalization and same-authority correction are current-authority fenced", - "rapid authority rotation keeps immediate terminal checks while damping full preparation" - ] - }, - { - "file": "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts", - "assertions": [ - "exact-host catalog and lineage hydration preserves sibling SSH, local, runtime, ambiguous, and contradictory rows" - ] - }, { "file": "src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts", "assertions": [ @@ -13317,7 +13248,7 @@ "date": "2026-08-29", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "result": "passed", "durationSeconds": 10.7, "summary": "Fourteen direct-SSH files and 155 tests passed, including 32-arrival placement and readiness bursts with one listener/timer maximum, immediate supersession and stop cleanup, latest-only hydration, stale-push fencing, pending-capture and in-flight-write upload exclusion, and existing retry, authority, hydration, split-pane, and detach contracts." @@ -13353,7 +13284,7 @@ "date": "2026-07-28", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "result": "passed", "durationSeconds": 15.8, "summary": "Twelve direct SSH files and 647 tests passed, including exact lease revalidation after asynchronous SSH preparation, primary-exit continuation gaps, pending-only and live null-PTY two-sided split-detach authority, delayed post-success sibling admission, stale-authority provider retirement, late ownership-provenance rejection, and deleted-tab ledger pruning." diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index d548e5c65d1..351f71b51d0 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -9,6 +9,7 @@ import { externalNativeAddons, ORCAD_EXTERNAL_MODULES, ORCAD_CHILD_ENTRY_POINTS, + ORCAD_COMMONJS_MODULE_OPTIONS, orcadChildOutputFilename } from './orcad-entry-build.mjs' import { createRequire } from 'node:module' @@ -52,6 +53,8 @@ import { computeOrcadFullVersion } from './orcad-artifact-version.mjs' import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' import { findSlotProblems, readManifest } from './orcad-prebuild-slot-contents.mjs' import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts' +import { runProcessSync } from '@orca/process-host' +import { describeProcessFailure } from './process-failure-message.mjs' const ROOT = join(import.meta.dirname, '..', '..') const OUT_DIR = process.env.ORCAD_OUT_DIR @@ -99,6 +102,7 @@ async function stageParcelWatcher(target) { const wrapperSource = requireFromWatcher.resolve('@parcel/watcher/wrapper.js') mkdirSync(WATCHER_MODULE_DIR, { recursive: true }) await build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, stdin: { contents: `const {createWrapper}=require(${JSON.stringify(wrapperSource)});` + @@ -197,6 +201,7 @@ cpSync(join(ROOT, 'resources', 'native-chat-visuals'), join(OUT_DIR, 'native-cha * their runtime resolvers look for them. */ function buildForkedChild(entryPoint, outfile) { return build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, entryPoints: [entryPoint], bundle: true, platform: 'node', @@ -209,6 +214,7 @@ function buildForkedChild(entryPoint, outfile) { minify: true, sourcemap: false, define: { + ...ORCAD_COMMONJS_MODULE_OPTIONS.define, 'process.env.NODE_ENV': '"production"' }, logLevel: 'error' @@ -312,6 +318,17 @@ if (graphErrors.length > 0) { ) process.exitCode = 1 } + const providerSmoke = runProcessSync({ + program: process.execPath, + args: [SERVER_OUT_FILE, '--orcad-structured-provider-load-check'], + timeoutMs: 60_000 + }) + if (providerSmoke.code !== 0 || providerSmoke.timedOut || providerSmoke.outputTruncated) { + console.error( + `[build-orcad] the packaged structured provider failed to load.\n${describeProcessFailure(providerSmoke)}` + ) + process.exitCode = 1 + } // Why require + parseArgs and not a real daemon: requiring the bundle evaluates every // top-level import, and calling its exported argv parser proves the entry's own code is // there rather than a graph that merely resolved. Booting one would need a socket, a diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 6d3e961d2fe..a40748fb10c 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -2672,10 +2672,8 @@ "src/main/ipc/repos-remote-base-ref-queries.test.ts": 1223, "src/main/ipc/repos-remote-client-events.test.ts": 1788, "src/main/ipc/repos-remote-git-username.test.ts": 1104, - "src/main/ipc/repos-remote.test.ts": 1533, "src/main/ipc/repos-sparse-presets.test.ts": 1196, "src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts": 1525, - "src/main/ipc/repos/remote-repo-registration.test.ts": 282, "src/main/ipc/repos/repo-creation-git-availability.test.ts": 310, "src/main/ipc/runtime-agent-launch-capability.test.ts": 3143, "src/main/ipc/runtime-environment-browser-client-host-handler.test.ts": 1143, @@ -2841,7 +2839,6 @@ "src/main/ipc/worktrees/listing/detected-worktree-scan-listing-distro.test.ts": 559, "src/main/ipc/worktrees/listing/detected-worktree-scan-superseded.test.ts": 68, "src/main/ipc/worktrees/listing/register-sparse-checkout-cache-invalidation.test.ts": 197, - "src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts": 80, "src/main/ipc/zcode-plan-credentials.test.ts": 58, "src/main/issue-command-ignore.test.ts": 620, "src/main/jcode/daemon-prewarm.test.ts": 54, @@ -5118,7 +5115,6 @@ "src/main/runtime/windows-drive-listing.test.ts": 54, "src/main/runtime/windows-firewall-remote-scope.test.ts": 81, "src/main/runtime/windows-mobile-firewall.test.ts": 99, - "src/main/runtime/workspace-layout-round-trip-watch.test.ts": 254, "src/main/runtime/workspace-layout-stream.test.ts": 257, "src/main/runtime/workspace-session-membership-scaling.test.ts": 99, "src/main/runtime/workspace-session-worktree-id.test.ts": 254, @@ -7737,7 +7733,7 @@ "src/renderer/src/components/settings/source-control-ai-settings-persistence.test.tsx": 3192, "src/renderer/src/components/settings/sparse-preset-date.test.ts": 66, "src/renderer/src/components/settings/sparse-preset-operation-error.test.ts": 53, - "src/renderer/src/components/settings/ssh-host-server-status-copy.test.ts": 107, + "src/renderer/src/ssh/ssh-host-server-status-copy.test.ts": 107, "src/renderer/src/components/settings/ssh-target-action-state.test.ts": 47, "src/renderer/src/components/settings/ssh-target-draft.test.ts": 66, "src/renderer/src/components/settings/ssh-target-save-payload.test.ts": 114, @@ -9019,14 +9015,8 @@ "src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts": 1678, "src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts": 1661, "src/renderer/src/hooks/composer-state/quick-creation-request.test.ts": 110, - "src/renderer/src/hooks/direct-ssh-host-catalog-noop.test.ts": 1364, - "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts": 79, - "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts": 130, - "src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts": 49, - "src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts": 41, "src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx": 1605, "src/renderer/src/hooks/direct-ssh-state-routing.test.ts": 183, - "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts": 72, "src/renderer/src/hooks/editor-external-watch-fallback-explorer.test.ts": 355, "src/renderer/src/hooks/editor-external-watch-selected-host.test.ts": 324, "src/renderer/src/hooks/editor-runtime-file-watch.test.ts": 55, @@ -9298,7 +9288,6 @@ "src/renderer/src/lib/desktop-window-chrome.test.ts": 56, "src/renderer/src/lib/diff-comment-compat.test.ts": 50, "src/renderer/src/lib/diff-comments-format.test.ts": 47, - "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts": 56, "src/renderer/src/lib/direct-ssh-target-scope.test.ts": 70, "src/renderer/src/lib/doc-preview-grants.test.ts": 370, "src/renderer/src/lib/document-theme.test.ts": 55, @@ -10835,7 +10824,6 @@ "src/shared/cross-platform-path.test.ts": 55, "src/shared/currency-format.test.ts": 47, "src/shared/daemon-adoption-telemetry.test.ts": 349, - "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts": 279, "src/shared/doc-preview-file-access.test.ts": 93, "src/shared/doc-preview-scheme.test.ts": 44, "src/shared/draft-paste-ready-scanner-grok-trace-replay.test.ts": 75, diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index b7ec782b42b..b1195ea2064 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -4403,15 +4403,6 @@ "auto.components.sidebar.add.repo.local.start.actions.runtimeBrowseDescription": { "ko": "이 호스트의 기존 Git 리포지토리 또는 폴더" }, - "auto.components.sidebar.add.repo.local.start.actions.sshBrowseDescription": { - "ko": "이 SSH 호스트의 기존 Git 리포지토리 또는 폴더" - }, - "auto.components.sidebar.add.repo.local.start.actions.sshBrowseTitle": { - "ko": "SSH 호스트에서 프로젝트 열기" - }, - "auto.components.sidebar.add.repo.local.start.actions.sshCreateUnavailable": { - "ko": "아직 SSH 호스트에서는 사용할 수 없습니다." - }, "auto.components.sidebar.index.b826a98b6f": { "ko": "사용 중" }, diff --git a/config/scripts/mobile-release-check-scope.test.mjs b/config/scripts/mobile-release-check-scope.test.mjs index 2283827ad0a..7a07b19af2f 100644 --- a/config/scripts/mobile-release-check-scope.test.mjs +++ b/config/scripts/mobile-release-check-scope.test.mjs @@ -137,6 +137,7 @@ function fixture(detectorStep = detector) { for (const file of [ 'package.json', 'config/scripts/pr-code-change-scope.mjs', + 'config/scripts/pr-layout-oracle-route.mjs', 'config/scripts/mobile-release-check-scope.mjs', 'config/scripts/mobile-test-change-scope.mjs' ]) { diff --git a/config/scripts/mobile-test-change-scope.mjs b/config/scripts/mobile-test-change-scope.mjs index cdd04d6edf5..f773fb8fa02 100644 --- a/config/scripts/mobile-test-change-scope.mjs +++ b/config/scripts/mobile-test-change-scope.mjs @@ -9,6 +9,7 @@ const NON_TEST_FILES = new Set([ '.github/workflows/mobile-ios-release.yml', 'config/scripts/pr-code-change-scope.mjs', 'config/scripts/pr-code-change-scope.test.mjs', + 'config/scripts/pr-layout-oracle-route.mjs', 'config/scripts/mobile-release-check-scope.mjs', 'config/scripts/mobile-release-check-scope.test.mjs' ]) diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index feb4a950378..26389df0541 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -52,6 +52,8 @@ const ALWAYS_PREFIXES = [ 'src/main/persistence/', 'src/main/sqlite/', 'src/main/orcad/', + 'src/main/runtime/', + 'src/main/claude/', 'src/main/daemon/pty-subprocess/', 'src/main/providers/', 'config/patches/', @@ -61,7 +63,7 @@ const ALWAYS_PREFIXES = [ ] export function discoverNodeServerTests(root = ROOT) { - const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true }) + const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true, template: true }) return globSync( ['src/**/*.test.{ts,tsx}', 'config/scripts/**/*.test.{ts,mjs}', 'tests/e2e/**/*.unit.test.ts'], { cwd: root } @@ -150,11 +152,12 @@ export async function classifyNodeServerChanges( if (changedFiles.length === 0) { return { shouldRun: true, reason: 'No complete changed-file evidence' } } - const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true }) + const selectors = nodeServerTestPaths({ artifact: true, crossRuntime: true, template: true }) const forced = changedFiles.find( (file) => ALWAYS_FILES.has(file) || (ALWAYS_PREFIXES.some((prefix) => file.startsWith(prefix)) && !isSourceUnitTest(file)) || + /^config\/scripts\/[^/]*orcad[^/]*$/.test(file) || selectors.some((selector) => file.includes(selector)) ) if (forced) { diff --git a/config/scripts/node-server-packaged-chat-selection.test.mjs b/config/scripts/node-server-packaged-chat-selection.test.mjs new file mode 100644 index 00000000000..d8198c8454a --- /dev/null +++ b/config/scripts/node-server-packaged-chat-selection.test.mjs @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs' +import { expect, it } from 'vitest' +import { parse } from 'yaml' +import { classifyNodeServerChanges } from './node-server-change-scope.mjs' +import { nodeServerTestPaths } from './node-server-test-paths.mjs' + +it.each([ + 'src/main/orcad/orcad-packaged-structured-chat.integration.test.ts', + 'src/main/runtime/new-launch-path.ts', + 'src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs', + 'config/scripts/orcad-entry-build.mjs', + 'config/scripts/future-orcad-package.mjs' +])('runs the packaged structured chat when its inputs change: %s', async (file) => { + expect((await classifyNodeServerChanges([file], async () => new Set())).shouldRun).toBe(true) +}) + +it('runs a real structured chat on Linux from the built desktop template', () => { + const workflow = parse(readFileSync('.github/workflows/node-server-tests.yml', 'utf8')) + const steps = workflow.jobs.persistence.steps + const chat = steps.find((step) => + step.run?.includes('orcad-packaged-structured-chat.integration.test.ts') + ) + expect(chat.if).toBe("runner.os == 'Linux'") + expect(chat.run).toBe( + 'pnpm test:node-server --artifact src/main/orcad/orcad-packaged-structured-chat.integration.test.ts --maxWorkers=2' + ) + const template = steps.find((step) => step.run?.includes('build-orcad-template.mjs --targets')) + expect(template.if).toBe("runner.os == 'Linux'") + expect(template.run).toBe( + "node config/scripts/build-orcad-template.mjs --targets linux-${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }}-glibc" + ) + expect(steps.indexOf(template)).toBeGreaterThan( + steps.findIndex((step) => step.run === 'pnpm build:orcad') + ) + expect(steps.indexOf(chat)).toBeGreaterThan(steps.indexOf(template)) + expect(template['continue-on-error']).toBeUndefined() + expect(chat['continue-on-error']).toBeUndefined() + // Default --artifact runs have no template, so a required-input failure would follow. + expect(nodeServerTestPaths({ artifact: true, crossRuntime: true })).not.toContain( + 'src/main/orcad/orcad-packaged-structured-chat.integration.test.ts' + ) +}) diff --git a/config/scripts/node-server-test-paths.mjs b/config/scripts/node-server-test-paths.mjs index 9e51b23af9c..c9fb5c9a503 100644 --- a/config/scripts/node-server-test-paths.mjs +++ b/config/scripts/node-server-test-paths.mjs @@ -7,7 +7,16 @@ export const CROSS_RUNTIME_TEST_PATHS = [ 'src/main/persistence/profile-state/profile-state-cross-runtime.integration.test.ts' ] -export function nodeServerTestPaths({ artifact = false, crossRuntime = false } = {}) { +// Need the built desktop server template; CI runs them by path after build-orcad-template.mjs. +export const TEMPLATE_TEST_PATHS = [ + 'src/main/orcad/orcad-packaged-structured-chat.integration.test.ts' +] + +export function nodeServerTestPaths({ + artifact = false, + crossRuntime = false, + template = false +} = {}) { return [ 'src/main/persistence/profile-state', 'src/main/persistence/loading-store/profile-state', @@ -38,6 +47,7 @@ export function nodeServerTestPaths({ artifact = false, crossRuntime = false } = 'config/scripts/zip-extractor-command.test.mjs' ] : []), - ...(crossRuntime ? CROSS_RUNTIME_TEST_PATHS : []) + ...(crossRuntime ? CROSS_RUNTIME_TEST_PATHS : []), + ...(template ? TEMPLATE_TEST_PATHS : []) ] } diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index 1f0de9a11b2..7b636937b3e 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -25,6 +25,15 @@ export function orcadChildOutputFilename(entryPoint) { export const ORCAD_EXTERNAL_MODULES = ['electron', 'node-pty', '@parcel/watcher', 'fsevents'] +// Bundled ESM resolves from the deployed bundle, like CommonJS __filename. +export const ORCAD_COMMONJS_MODULE_OPTIONS = { + banner: { + js: 'var __orcadModuleUrl = require("node:url").pathToFileURL(__filename).href;' + }, + define: { 'import.meta.url': '__orcadModuleUrl' }, + logOverride: { 'empty-import-meta': 'error' } +} + // Native binaries are staged separately from every JavaScript entry. export const externalNativeAddons = { name: 'external-native-addons', @@ -45,6 +54,7 @@ const jsoncParserEsm = { export function buildOrcadEntry(outfile) { return build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, entryPoints: [join(root, ORCAD_ENTRY_POINT)], bundle: true, platform: 'node', @@ -56,7 +66,10 @@ export function buildOrcadEntry(outfile) { metafile: true, minify: true, sourcemap: false, - define: { 'process.env.NODE_ENV': '"production"' }, + define: { + ...ORCAD_COMMONJS_MODULE_OPTIONS.define, + 'process.env.NODE_ENV': '"production"' + }, logLevel: 'error' }) } @@ -81,6 +94,7 @@ export function buildOrcadLauncher(outfile) { .update(readFileSync(join(dirname(outfile), ORCAD_SERVER_ENTRY_FILENAME))) .digest('hex') return build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, entryPoints: [join(root, ORCAD_LAUNCHER_ENTRY_POINT)], bundle: true, platform: 'node', @@ -90,7 +104,10 @@ export function buildOrcadLauncher(outfile) { metafile: true, minify: true, sourcemap: false, - define: { ORCAD_SERVER_SHA256: JSON.stringify(serverSha256) }, + define: { + ...ORCAD_COMMONJS_MODULE_OPTIONS.define, + ORCAD_SERVER_SHA256: JSON.stringify(serverSha256) + }, logLevel: 'error' }) } diff --git a/config/scripts/orcad-entry-build.test.mjs b/config/scripts/orcad-entry-build.test.mjs new file mode 100644 index 00000000000..b367aba66ad --- /dev/null +++ b/config/scripts/orcad-entry-build.test.mjs @@ -0,0 +1,79 @@ +import { build } from 'esbuild' +import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, expect, it } from 'vitest' +import { buildOrcadEntry, ORCAD_COMMONJS_MODULE_OPTIONS } from './orcad-entry-build.mjs' +import { runProcessSync } from '@orca/process-host' + +const directory = mkdtempSync(join(tmpdir(), 'orca-structured-provider-build-')) +const bundle = join(directory, 'orcad-server.js') + +beforeAll(async () => { + await buildOrcadEntry(bundle) +}, 60_000) + +afterAll(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +it('loads the structured Claude SDK through the packaged server under plain Node', () => { + const loaded = runProcessSync({ + program: process.execPath, + args: [bundle, '--orcad-structured-provider-load-check'], + cwd: directory, + env: { ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 30_000 + }) + expect(loaded.code, loaded.stderr.slice(-2_000)).toBe(0) +}, 40_000) + +it('resolves bundle-relative requires after deployment to a path with URL-special characters', async () => { + const buildDirectory = join(directory, 'build') + mkdirSync(buildDirectory) + const outfile = join(buildDirectory, 'entry.cjs') + await build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, + stdin: { + contents: `import { createRequire } from 'node:module'; + console.log(createRequire(import.meta.url)('./asset.cjs'));`, + resolveDir: directory + }, + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile, + minify: true, + logLevel: 'error' + }) + writeFileSync(join(buildDirectory, 'asset.cjs'), 'module.exports = "deployed asset"') + const deployedDirectory = join(directory, 'deployed # 100%') + renameSync(buildDirectory, deployedDirectory) + const loaded = runProcessSync({ + program: process.execPath, + args: [join(deployedDirectory, 'entry.cjs')], + cwd: directory, + env: { ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 30_000 + }) + expect(loaded.code, loaded.stderr).toBe(0) + expect(loaded.stdout.trim()).toBe('deployed asset') +}) + +it.each(['import.meta', 'import.meta.dirname', 'import.meta.filename', 'import.meta.resolve'])( + 'rejects unsupported %s instead of silently replacing it with an empty object', + async (expression) => { + await expect( + build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, + stdin: { contents: `console.log(${expression})`, resolveDir: directory }, + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + logLevel: 'silent' + }) + ).rejects.toThrow('"import.meta" is not available') + } +) diff --git a/config/scripts/pr-auxiliary-preflight-admission.test.mjs b/config/scripts/pr-auxiliary-preflight-admission.test.mjs index bd2b9e9a095..d0c41ffc7c7 100644 --- a/config/scripts/pr-auxiliary-preflight-admission.test.mjs +++ b/config/scripts/pr-auxiliary-preflight-admission.test.mjs @@ -290,14 +290,21 @@ afterEach(() => { } }) -function createMobileCiScopeRepository(withTool = true) { +function createMobileCiScopeRepository( + withTool = true, + toolPath = 'config/scripts/run-mobile-ci-tests.mjs', + companionPaths = [] +) { const directory = mkdtempSync(join(tmpdir(), 'orca-mobile-ci-scope-')) mobileCiScopeDirectories.push(directory) const root = resolve(import.meta.dirname, '../..') for (const file of [ 'package.json', 'config/scripts/pr-code-change-scope.mjs', - 'config/scripts/git-pull-request-diff-base.mjs' + 'config/scripts/pr-layout-oracle-route.mjs', + 'config/scripts/git-pull-request-diff-base.mjs', + 'config/scripts/pr-e2e-source-routing.mjs', + 'config/scripts/ci-e2e-job-selection.mjs' ]) { mkdirSync(dirname(join(directory, file)), { recursive: true }) copyFileSync(join(root, file), join(directory, file)) @@ -323,17 +330,29 @@ function createMobileCiScopeRepository(withTool = true) { git('init', '--quiet') git('config', 'user.name', 'Scope control') git('config', 'user.email', 'scope@example.invalid') - const tool = join(directory, 'config/scripts/run-mobile-ci-tests.mjs') + const tool = join(directory, toolPath) + mkdirSync(dirname(tool), { recursive: true }) if (withTool) { writeFileSync(tool, 'export const revision = 1\n') } + for (const file of companionPaths) { + mkdirSync(dirname(join(directory, file)), { recursive: true }) + writeFileSync(join(directory, file), 'export const revision = 1\n') + } const base = commit() - const detect = () => { - const output = join(directory, 'github-output') - const step = workflow.jobs.code_paths.steps.find((candidate) => candidate.id === 'filter') + const detect = (stepId = 'filter') => { + const output = join( + directory, + stepId === 'filter' ? 'github-output' : `github-output-${stepId}` + ) + const step = workflow.jobs.code_paths.steps.find((candidate) => candidate.id === stepId) const result = runProcessSync({ program: 'bash', - args: ['-e', '-c', step.run], + args: [ + '-e', + '-c', + step.run.replace('${{ github.event.pull_request.base.sha }}', '$BASE_SHA') + ], cwd: directory, env: { ...process.env, @@ -397,3 +416,67 @@ describe.skipIf(process.platform === 'win32')('Mobile CI complete Git change evi expect(result.stderr).toContain('not a git repository') }) }) + +describe.skipIf(process.platform === 'win32')('Daemon mock complete Git change evidence', () => { + const helper = 'src/main/daemon/daemon-init-dependency-mocks.ts' + it('keeps real unit and type contracts while omitting unrelated packages for a modification', () => { + const repo = createMobileCiScopeRepository(true, helper) + writeFileSync(repo.tool, 'export const revision = 2\n') + repo.commit() + const { result, output } = repo.detect() + expect(result.code, result.stderr).toBe(0) + const flags = readFileSync(output, 'utf8') + expect(flags).toContain('package=false\n') + expect(flags).toContain('package_windows=false\n') + expect(flags).toContain('test=true\n') + expect(flags).toContain('typecheck=true\n') + }) + it('routes the complete four-file modification through the real detector', () => { + const companions = [ + 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts', + 'config/scripts/process-host-build-dist.test.mjs', + 'src/main/codex/codex-app-server-connection.test.ts' + ] + const repo = createMobileCiScopeRepository(true, helper, companions) + for (const file of [helper, ...companions]) { + writeFileSync(join(repo.directory, file), 'export const revision = 2\n') + } + repo.commit() + const { result, output } = repo.detect() + expect(result.code, result.stderr).toBe(0) + const flags = readFileSync(output, 'utf8') + for (const name of ['package', 'package_windows']) { + expect(classifyPrJobs([helper, ...companions])[name]).toBe(true) + expect(flags).toContain(`${name}=false\n`) + } + for (const name of ['test', 'static_analysis', 'typecheck', 'cross-version-wire']) { + expect(flags).toContain(`${name}=true\n`) + } + const e2e = repo.detect('e2e_filter') + expect(e2e.result.code, e2e.result.stderr).toBe(0) + const routes = readFileSync(e2e.output, 'utf8') + expect(routes).toContain('test_files=[]\n') + expect(routes).toContain('should_run=false\n') + }) + it.each(['addition', 'deletion', 'rename', 'type change'])( + 'keeps platform packages for an actual %s', + (change) => { + const repo = createMobileCiScopeRepository(change !== 'addition', helper) + if (change === 'addition') { + writeFileSync(repo.tool, 'export const revision = 1\n') + } else if (change === 'deletion') { + rmSync(repo.tool) + } else if (change === 'rename') { + repo.git('mv', helper, helper.replace('.ts', '-renamed.ts')) + } else { + rmSync(repo.tool) + symlinkSync('../../../config/scripts/pr-code-change-scope.mjs', repo.tool) + } + repo.commit() + const { result, output } = repo.detect() + expect(result.code, result.stderr).toBe(0) + expect(readFileSync(output, 'utf8')).toContain('package=true\n') + expect(readFileSync(output, 'utf8')).toContain('package_windows=true\n') + } + ) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 13e5d6b1e5e..149c49a2d11 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' +import { isLayoutOracleSource } from './pr-layout-oracle-route.mjs' const DOCS_ONLY_FILES = new Set([ 'README.md', @@ -30,7 +31,8 @@ export const PR_CHECK_JOBS = [ 'cross-version-wire', 'managed_hook_node18', 'package', - 'package_windows' + 'package_windows', + 'layout_oracle' ] const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test']) @@ -499,14 +501,43 @@ export function needsMobileDependencies(changedFiles) { return changedFiles.length === 0 || changedFiles.some((file) => file.startsWith('mobile/')) } -export function classifyPrJobs(changedFiles, { allowMobileCiTools = false } = {}) { +export const DAEMON_INIT_TEST_SUPPORT = 'src/main/daemon/daemon-init-dependency-mocks.ts' + +const DAEMON_INIT_TEST_SCOPE = new Set([ + DAEMON_INIT_TEST_SUPPORT, + 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts', + 'config/scripts/process-host-build-dist.test.mjs', + 'src/main/codex/codex-app-server-connection.test.ts' +]) + +export function isDaemonInitTestSupportChange(files, modifiedOnly = false) { + return ( + modifiedOnly && + files.includes(DAEMON_INIT_TEST_SUPPORT) && + new Set(files).size === files.length && + files.every((file) => DAEMON_INIT_TEST_SCOPE.has(file)) + ) +} + +export function classifyPrJobs( + changedFiles, + { allowMobileCiTools = false, allowDaemonInitTestSupport = false } = {} +) { const emptyDiff = changedFiles.length === 0 const shouldRun = shouldRunPrChecks(changedFiles) const forceAll = emptyDiff || changedFiles.some(isGlobalForcePath) + const bundleFiles = isDaemonInitTestSupportChange(changedFiles, allowDaemonInitTestSupport) + ? changedFiles.filter((file) => file !== DAEMON_INIT_TEST_SUPPORT) + : changedFiles const jobs = Object.fromEntries( PR_CHECK_JOBS.map((job) => [ job, - shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles)) + shouldRun && + (forceAll || + ALWAYS_ON_CODE_JOBS.has(job) || + jobDetector(job)( + ['package', 'package_windows'].includes(job) ? bundleFiles : changedFiles + )) ]) ) // Why outside should_run: a mobile-only diff is desktop-irrelevant and skips every job above, @@ -562,6 +593,8 @@ function jobDetector(job) { return (files) => files.some(isLinuxPackagePath) case 'package_windows': return (files) => files.some(isWindowsPackagePath) + case 'layout_oracle': + return (files) => files.some(isLayoutOracleSource) default: return () => true } @@ -634,7 +667,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) } const files = input.split(/\r?\n/).filter(Boolean) const classification = classifyPrJobs(files, { - allowMobileCiTools: process.argv.includes('--mobile-ci-tools') + allowMobileCiTools: process.argv.includes('--mobile-ci-tools'), + allowDaemonInitTestSupport: process.argv.includes('--daemon-init-test-support') }) for (const [name, value] of Object.entries(classification)) { process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`) diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 97a1b0f11e3..5f80a7112da 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -394,7 +394,9 @@ describe('per-job path classification', () => { expectClassification([file], { 'cross-version-wire': true, package: true, - package_windows: true + package_windows: true, + // The session-tabs RPC publishes layout, so the layout oracle gates it too. + layout_oracle: file === 'src/main/runtime/rpc/methods/session-tabs.ts' }) } expectClassification( diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 60c6c46248e..df9c9cc4a60 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -237,3 +237,25 @@ describe('PR E2E gate contract', () => { ) }) }) + +describe('Daemon mock modified-only source routing', () => { + const helper = 'src/main/daemon/daemon-init-dependency-mocks.ts' + const verified = { allowDaemonInitTestSupport: true } + it('omits the real D7 route only for the proven unit-support scope', () => { + expect(selectPrE2eSpecs([helper])).toContain('tests/e2e/orcad-serve-mode-switch.spec.ts') + expect(selectPrE2eSpecs([helper], undefined, verified)).toEqual([]) + const companion = 'src/main/codex/codex-app-server-connection.test.ts' + expect(selectPrE2eSpecs([helper, companion], undefined, verified)).toEqual( + selectPrE2eSpecs([companion]) + ) + }) + it.each([ + { files: [] }, + { files: [helper, 'src/main/daemon/daemon-init.ts'] }, + { files: [helper, 'package.json'] }, + { files: [helper, 'src/main/daemon/unknown-support.ts'] }, + { files: [helper, helper] } + ])('retains existing routes for an uncertain or mixed vector $files', ({ files }) => { + expect(selectPrE2eSpecs(files, undefined, verified)).toEqual(selectPrE2eSpecs(files)) + }) +}) diff --git a/config/scripts/pr-e2e-layout-oracle-routing.test.mjs b/config/scripts/pr-e2e-layout-oracle-routing.test.mjs index f0962edff15..b4afd8e5c4a 100644 --- a/config/scripts/pr-e2e-layout-oracle-routing.test.mjs +++ b/config/scripts/pr-e2e-layout-oracle-routing.test.mjs @@ -1,71 +1,104 @@ import { describe, expect, it } from 'vitest' -import { existsSync } from 'node:fs' -import { join, resolve } from 'node:path' +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { parse } from 'yaml' import { classifyE2eJobs, selectGeneralE2eSpecs } from './ci-e2e-job-selection.mjs' -import { - PR_E2E_SOURCE_ROUTES, - selectPrE2eSpecs, - shouldRunReusablePrE2e -} from './pr-e2e-source-routing.mjs' +import { classifyPrJobs } from './pr-code-change-scope.mjs' +import { isLayoutOracleSource, LAYOUT_ORACLE_E2E_SPECS } from './pr-layout-oracle-route.mjs' +import { selectPrE2eSpecs, shouldRunReusablePrE2e } from './pr-e2e-source-routing.mjs' const projectDir = resolve(import.meta.dirname, '../..') - -const ORACLE_ROUTE = PR_E2E_SOURCE_ROUTES.find((route) => route.id === 'workspace-layout.oracle') -const TAB_STORE_ROUTE = PR_E2E_SOURCE_ROUTES.find( - (route) => route.id === 'workspace-layout.oracle-tab-store' -) +const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) /** One product file per layout writer family; each alone must run the oracle. */ const LAYOUT_AUTHORITIES = [ 'src/shared/workspace-layout/workspace-layout-pane-commands.ts', + 'src/shared/workspace-layout/old-client/index.ts', 'src/main/persistence/terminal-topology/terminal-topology-commit.ts', 'src/main/persistence/restoring-sessions/session-owner-fields.ts', 'src/main/persistence/loading-store/pty-binding-persistence.ts', + 'src/main/remote-layout-snapshot/index.ts', 'src/main/runtime/orca-runtime-split-pty-backed-terminal.ts', 'src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts', 'src/main/runtime/orca-runtime-create-terminal.ts', 'src/renderer/src/runtime/mobile-session-tab-close.ts', 'src/renderer/src/runtime/runtime-layout-client.ts', + 'src/renderer/src/store/slices/tabs.ts', + 'src/renderer/src/store/slices/tabs/tabs-move-actions.ts', 'tests/e2e/workspace-layout-oracle-known-on-main.ts', - 'tests/e2e/helpers/workspace-layout-oracle-compare.ts' + 'tests/e2e/helpers/workspace-layout-oracle-compare.ts', + ...LAYOUT_ORACLE_E2E_SPECS ] -describe('workspace layout oracle PR E2E routing', () => { - it('routes each layout authority on its own', () => { - expect(ORACLE_ROUTE, 'the oracle route was renamed or removed').toBeDefined() - for (const file of LAYOUT_AUTHORITIES) { - expect(selectPrE2eSpecs([file]), file).toEqual(ORACLE_ROUTE.specs.toSorted()) - expect(shouldRunReusablePrE2e([file]), file).toBe(true) - } - }) +/** Widened while the layout-holder stack is open: these still write layout. */ +const STACK_WRITERS = [ + 'src/renderer/src/store/slices/terminals.ts', + 'src/renderer/src/components/terminal-pane/TerminalPane.tsx', + 'src/renderer/src/runtime/sync-runtime-graph.ts', + 'src/renderer/src/runtime/web-session-tabs-sync.ts', + 'src/main/runtime/orca-runtime-split-terminal.ts', + 'src/main/runtime/orca-runtime-sync-window-graph.ts', + 'src/main/runtime/mobile-session-layout-projection.ts', + 'src/main/runtime/headless-runtime-graph.ts', + 'src/main/persistence/loading-store/session-host-partitions.ts', + 'src/main/persistence/loading-store/workspace-session-snapshot-publication.ts', + 'src/renderer/src/store/slices/tab-group-reference-repair.ts', + 'src/renderer/src/store/slices/terminal-session-row-hydration.ts', + 'src/renderer/src/store/workspace-layout-cache.ts', + 'src/renderer/src/app-shell/use-runtime-graph-sync.ts', + 'src/renderer/src/components/tab-group/tab-drag-drop-commit.ts', + 'src/shared/remote-layout-snapshot.ts', + 'src/shared/terminal-leaf-move.ts', + 'src/main/ipc/pty/ipc/leaf-move.ts', + 'src/main/persistence/closed-terminal-tab-write-fence.ts', + 'src/main/runtime/runtime-workspace-session-controller.ts', + 'src/main/runtime/terminal-surface-close.ts', + 'src/renderer/src/components/terminal/terminal-tab-actions.ts', + 'src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts', + 'src/shared/rpc-contract/workspace-layout-params.ts', + 'src/renderer/src/store/slices/recently-closed-tabs.ts' +] - it('runs the window and headless oracles, not the SSH lane, for the tab store', () => { - for (const file of [ - 'src/renderer/src/store/slices/tabs.ts', - 'src/renderer/src/store/slices/tabs/tabs-move-actions.ts' - ]) { - expect(selectPrE2eSpecs([file]), file).toEqual(TAB_STORE_ROUTE.specs.toSorted()) +/** Every test file the oracle specs import, transitively: a change there can break the gate. */ +function oracleHarnessFiles() { + const seen = new Set() + const pending = [...LAYOUT_ORACLE_E2E_SPECS] + while (pending.length > 0) { + const file = pending.pop() + if (seen.has(file)) { + continue + } + seen.add(file) + const source = readFileSync(join(projectDir, file), 'utf8') + for (const [, specifier] of source.matchAll(/(?:from|import)\s*\(?\s*'(\.[^']+)'/g)) { + const base = relative(projectDir, join(projectDir, dirname(file), specifier)) + const resolved = [base, `${base}.ts`, `${base}.tsx`].find( + (candidate) => /\.tsx?$/.test(candidate) && existsSync(join(projectDir, candidate)) + ) + if (resolved?.startsWith('tests/')) { + pending.push(resolved) + } + } + } + return [...seen] +} + +describe('workspace layout oracle merge gate', () => { + it('routes each layout authority and stack writer on its own', () => { + for (const file of [...LAYOUT_AUTHORITIES, ...STACK_WRITERS]) { + expect(isLayoutOracleSource(file), file).toBe(true) + expect(classifyPrJobs([file]).layout_oracle, file).toBe(true) expect(existsSync(join(projectDir, file)), file).toBe(true) } }) - it('sends each oracle spec to the job that can run it', () => { - const specs = ORACLE_ROUTE.specs - // Window spec runs in the general shards; headless runs in its dedicated job. - expect(selectGeneralE2eSpecs(specs)).toEqual(['tests/e2e/workspace-layout-oracle.spec.ts']) - expect(classifyE2eJobs(JSON.stringify(specs))).toEqual({ - e2e_run_changed: true, - e2e_needs_build: true - }) + it('routes every test file the oracle specs import', () => { + const harness = oracleHarnessFiles() + expect(harness.length).toBeGreaterThan(LAYOUT_ORACLE_E2E_SPECS.length) + expect(harness.filter((file) => !isLayoutOracleSource(file))).toEqual([]) }) - it('keeps every routed spec and authority a real file', () => { - for (const file of [...ORACLE_ROUTE.specs, ...LAYOUT_AUTHORITIES]) { - expect(existsSync(join(projectDir, file)), file).toBe(true) - } - }) - - it('leaves tests, fixtures and unrelated runtime modules alone', () => { + it('leaves tests, fixtures, harnesses and unrelated runtime modules alone', () => { for (const file of [ 'src/main/persistence/terminal-topology/terminal-topology-commit.test.ts', 'src/main/persistence/terminal-topology/terminal-leaf-move-fixture.ts', @@ -75,12 +108,46 @@ describe('workspace layout oracle PR E2E routing', () => { 'src/main/runtime/rpc/methods/terminal/terminal-create.ts', 'src/main/runtime/runtime-rpc-database.ts', 'src/main/runtime/orca-runtime-files.ts', - 'src/renderer/src/runtime/sync-runtime-graph.ts', 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts', 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts', + 'src/renderer/src/runtime/sync-runtime-graph-test-harness.ts', + 'src/renderer/src/runtime/sync-runtime-graph.test.ts', 'tests/e2e/workspace-layout-oracle-latency.spec.ts' ]) { - expect(ORACLE_ROUTE.matches(file) || TAB_STORE_ROUTE.matches(file), file).toBe(false) + expect(isLayoutOracleSource(file), file).toBe(false) } + expect(classifyPrJobs(['src/main/runtime/orca-runtime-files.ts']).layout_oracle).toBe(false) + }) + + it('keeps the oracle specs out of the advisory e2e job', () => { + expect(selectPrE2eSpecs(LAYOUT_AUTHORITIES)).toEqual([]) + expect(shouldRunReusablePrE2e(LAYOUT_AUTHORITIES)).toBe(false) + }) + + it('runs exactly the oracle specs, each in the lane that can run it', () => { + const job = prWorkflow.jobs.layout_oracle + expect(job.uses).toBe('./.github/workflows/e2e.yml') + // The merge commit, so a stale-base PR cannot pass on an old main. + expect(job.with.ref).toBe('${{ github.sha }}') + const specs = JSON.parse(job.with.test_files) + expect(specs.toSorted()).toEqual(LAYOUT_ORACLE_E2E_SPECS.toSorted()) + // Window spec runs in the changed-spec lane; headless runs in the orcad lane. + expect(selectGeneralE2eSpecs(specs)).toEqual(['tests/e2e/workspace-layout-oracle.spec.ts']) + expect(classifyE2eJobs(job.with.test_files)).toEqual({ + e2e_run_changed: true, + e2e_needs_build: true + }) + }) + + it('blocks the merge on the oracle and passes when it is skipped', () => { + const verify = prWorkflow.jobs.verify + const gate = verify.steps.find((step) => step.name === 'Require successful checks') + expect(verify.needs).toContain('layout_oracle') + expect(gate.run).toContain( + 'check_job layout_oracle "$LAYOUT_ORACLE" "$LAYOUT_ORACLE_SHOULD_RUN"' + ) + // Two calls of the reusable workflow in one run must not share artifact names. + expect(prWorkflow.jobs.layout_oracle.with.artifact_suffix).toBeTruthy() + expect(prWorkflow.jobs.e2e.with.artifact_suffix).toBeUndefined() }) }) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index d634a5f378c..9b989d7cf1b 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -1,10 +1,13 @@ import process from 'node:process' import { pathToFileURL } from 'node:url' -import { isUnitTestSupportSource } from './pr-code-change-scope.mjs' +import { + DAEMON_INIT_TEST_SUPPORT, + isDaemonInitTestSupportChange, + isUnitTestSupportSource +} from './pr-code-change-scope.mjs' +import { LAYOUT_ORACLE_E2E_SPECS } from './pr-layout-oracle-route.mjs' const isProductSource = (file) => !/\.test\.tsx?$/.test(file) && !isUnitTestSupportSource(file) -const isLayoutOracleSource = (file) => - isProductSource(file) && !/\.spec\.ts$|-fixtures?\.ts$/.test(file) // Why config/patches: the xterm fork owns the helper textarea an input method attaches to, so a // patch edit can break composition without touching a file named "ime". @@ -380,38 +383,25 @@ export const PR_E2E_SOURCE_ROUTES = [ /^src\/.*(?:[Cc]lient-?[Hh]osted|browser-host-(?:lease|page|client-page)|browser-client-(?:host|page)|runtime-browser-(?:client-)?page|session-tabs-sync|host-session-snapshot-authority|orca-runtime(?:-browser)?\.ts|\/runtime-(?:status|types)\.ts)/.test( file ) - }, - { - // Why: the oracle is the only check that reads every view of the stored layout back. Runtime - // matches only the modules that create, move, close, persist or publish layout, not every - // session or terminal file, so the orcad build and Docker SSH lane stay off unrelated work. - id: 'workspace-layout.oracle', - specs: [ - 'tests/e2e/workspace-layout-oracle-headless.spec.ts', - 'tests/e2e/workspace-layout-oracle.spec.ts' - ], - matches: (file) => - isLayoutOracleSource(file) && - /^(?:src\/shared\/workspace-layout\/|src\/main\/persistence\/(?:terminal-topology\/|restoring-sessions\/|loading-store\/pty-binding-)|src\/(?:main|renderer\/src)\/runtime\/(?:[^/]+\/)*[^/]*(?:pane-admission|workspace-layout|session-tab|headless-[^/]*tab|layout-client|create-terminal|split-pty-backed-terminal|terminal-split-layout|spawn-placement)[^/]*\.ts$|tests\/e2e\/(?:helpers\/)?(?:workspace-layout-oracle|headless-layout-oracle|terminal-layout-journeys))/.test( - file - ) - }, - { - // The window's tab store reaches SSH through the same layout writers, so not the SSH lane. - id: 'workspace-layout.oracle-tab-store', - specs: [ - 'tests/e2e/workspace-layout-oracle-headless.spec.ts', - 'tests/e2e/workspace-layout-oracle.spec.ts' - ], - matches: (file) => - isLayoutOracleSource(file) && file.startsWith('src/renderer/src/store/slices/tabs') } ] -export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) { - const specs = new Set(changedPaths.filter((file) => /^tests\/e2e\/.*\.spec\.ts$/.test(file))) +export function selectPrE2eSpecs( + changedPaths, + reportRoute = () => undefined, + { allowDaemonInitTestSupport = false } = {} +) { + const routePaths = isDaemonInitTestSupportChange(changedPaths, allowDaemonInitTestSupport) + ? changedPaths.filter((file) => file !== DAEMON_INIT_TEST_SUPPORT) + : changedPaths + // The required layout_oracle job owns the oracle specs; running them here too would double the cost. + const specs = new Set( + changedPaths.filter( + (file) => /^tests\/e2e\/.*\.spec\.ts$/.test(file) && !LAYOUT_ORACLE_E2E_SPECS.includes(file) + ) + ) for (const route of PR_E2E_SOURCE_ROUTES) { - const matchedFiles = changedPaths.filter(route.matches) + const matchedFiles = routePaths.filter(route.matches) if (matchedFiles.length === 0) { continue } @@ -432,9 +422,9 @@ export function hasNativeImeSourceChange(changedPaths) { ).some((route) => changedPaths.some(route.matches)) } -export function shouldRunReusablePrE2e(changedPaths) { +export function shouldRunReusablePrE2e(changedPaths, options = {}) { // Native IME has its own workflow. - return selectPrE2eSpecs(changedPaths).some( + return selectPrE2eSpecs(changedPaths, undefined, options).some( (spec) => spec !== 'tests/e2e/terminal-ibus-hangul-native.spec.ts' ) } @@ -453,14 +443,17 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) input += chunk } const changedPaths = input.split(/\r?\n/).filter(Boolean) + const options = { + allowDaemonInitTestSupport: process.argv.includes('--daemon-init-test-support') + } if (process.argv.includes('--reusable-workflow')) { - process.stdout.write(`${shouldRunReusablePrE2e(changedPaths)}\n`) + process.stdout.write(`${shouldRunReusablePrE2e(changedPaths, options)}\n`) } else if (process.argv.includes('--wsl-source')) { process.stdout.write(`${hasWslSourceChange(changedPaths)}\n`) } else if (process.argv.includes('--native-ime-source')) { process.stdout.write(`${hasNativeImeSourceChange(changedPaths)}\n`) } else { - const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message)) + const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message), options) process.stdout.write(`${JSON.stringify(specs)}\n`) } } diff --git a/config/scripts/pr-layout-oracle-route.mjs b/config/scripts/pr-layout-oracle-route.mjs new file mode 100644 index 00000000000..d1c18eeb9c2 --- /dev/null +++ b/config/scripts/pr-layout-oracle-route.mjs @@ -0,0 +1,81 @@ +// Paths whose change runs the required `layout_oracle` job in pr.yml. + +/** The layout oracle's two specs; the job runs both and nothing else. */ +export const LAYOUT_ORACLE_E2E_SPECS = [ + 'tests/e2e/workspace-layout-oracle-headless.spec.ts', + 'tests/e2e/workspace-layout-oracle.spec.ts' +] + +const LAYOUT_ORACLE_PREFIXES = [ + // Layout model, persistence and compatibility folders. + 'src/shared/workspace-layout/', + 'src/shared/remote-layout-snapshot.ts', + 'src/shared/terminal-leaf-move.ts', + 'src/main/persistence/terminal-topology/', + 'src/main/persistence/restoring-sessions/', + 'src/main/persistence/loading-store/pty-binding-', + 'src/main/persistence/loading-store/session-host-partitions.ts', + 'src/main/persistence/loading-store/workspace-session-snapshot-publication.ts', + 'src/main/remote-layout-snapshot/', + 'src/main/runtime/old-session-tabs-stream/', + 'src/renderer/src/runtime/remote-workspace-frozen/', + // Window-side layout writers the layout-holder stack replaces; widened while it is open. + 'src/renderer/src/store/slices/tabs', + 'src/renderer/src/store/slices/tab-group', + 'src/renderer/src/store/slices/terminals', + 'src/renderer/src/store/slices/terminal-', + 'src/renderer/src/store/terminals/', + 'src/renderer/src/store/workspace-layout-cache', + 'src/renderer/src/components/terminal-pane/', + 'src/renderer/src/components/tab-group/tab-drag-drop-commit.ts', + 'src/renderer/src/app-shell/use-runtime-graph-sync.ts', + 'src/renderer/src/runtime/sync-runtime-graph', + 'src/renderer/src/runtime/web-session-tabs-sync', + // Other layout writers the stack edits. + 'src/main/ipc/pty/ipc/leaf-move.ts', + 'src/main/persistence/closed-terminal-tab-write-fence.ts', + 'src/main/runtime/runtime-workspace-session-controller.ts', + 'src/main/runtime/terminal-surface-close.ts', + 'src/renderer/src/components/terminal/terminal-tab-actions.ts', + 'src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts', + 'src/shared/rpc-contract/workspace-layout-params.ts', + 'src/renderer/src/store/slices/recently-closed-tabs.ts', + // The client the oracle reads every view through. + 'src/cli/runtime/client.ts', + 'src/shared/remote-runtime-client.ts', + 'src/shared/remote-runtime-subscription-transport.ts', + 'src/shared/paired-runtime-connection.ts', + 'src/shared/pairing.ts', + // The harness: the oracle imports most of the shared e2e helpers, not just its own. + 'tests/e2e/workspace-layout-oracle', + 'tests/e2e/terminal-probe-input-sequence.ts', + 'tests/e2e/global-', + 'tests/e2e/helpers/', + 'tests/playwright.config.ts', + '.github/scripts/e2e-with-window-manager.sh', + // The job itself: the called workflow, its window-spec lane picker, and this route. + '.github/workflows/e2e.yml', + 'config/scripts/ci-e2e-job-selection.mjs', + 'config/scripts/pr-layout-oracle-route.mjs' +] + +// Runtime matches only the modules that create, move, close, persist or publish layout. +const LAYOUT_ORACLE_RUNTIME_MODULE = + /^src\/(?:main|renderer\/src)\/runtime\/(?:[^/]+\/)*[^/]*(?:pane-admission|workspace-layout|session-tab|headless-[^/]*tab|layout-client|layout-command-router|layout-projection|create-terminal|split-terminal|split-pty-backed-terminal|terminal-split-layout|spawn-placement|sync-window-graph|runtime-graph|leaf-move)[^/]*\.ts$/ + +// Why fixtures only under src/: there they serve unit tests; e2e fixtures are the oracle's harness. +const TEST_SUPPORT = + /\.(?:test|spec)\.(?:[cm]?js|tsx?)$|\/__tests__\/|^src\/.*-fixtures?\.ts$|-test-harness\.tsx?$/ + +export function isLayoutOracleSource(file) { + if (LAYOUT_ORACLE_E2E_SPECS.includes(file)) { + return true + } + if (TEST_SUPPORT.test(file)) { + return false + } + return ( + LAYOUT_ORACLE_PREFIXES.some((prefix) => file.startsWith(prefix)) || + LAYOUT_ORACLE_RUNTIME_MODULE.test(file) + ) +} diff --git a/config/scripts/repo-owner-settings-selector-benchmark.mjs b/config/scripts/repo-owner-settings-selector-benchmark.mjs deleted file mode 100644 index def882252f9..00000000000 --- a/config/scripts/repo-owner-settings-selector-benchmark.mjs +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env node -// Benchmark: cost of the owner-routed settings selector per store write. -// -// getSettingsForRepoRuntimeOwner() is called from useShallow selectors at ~43 -// sites across PullRequestPage and TaskPage. Zustand re-runs every subscribed -// selector on every store write, so before the fix each unrelated write -// allocated one settings-sized object per row and then shallow-compared every -// field to conclude nothing had changed. -// -// The fix caches by repo id and reuses the reference while the settings object, -// repo list, and resolved owner are unchanged, so useShallow's equality check -// short-circuits on Object.is. -// -// The selector body is mirrored here (node cannot import the .ts source, matching -// the sibling benchmarks). The settings field count is read from the real -// GlobalSettings type so a drifted shape fails loudly instead of flattering the -// result with a stale, smaller object. -import { readFileSync } from 'node:fs' -import { performance } from 'node:perf_hooks' -import { fileURLToPath } from 'node:url' - -const TYPES_SOURCE = readFileSync( - fileURLToPath(new URL('../../src/shared/types.ts', import.meta.url)), - 'utf8' -) - -function countGlobalSettingsFields(source) { - const block = source.match(/export type GlobalSettings = \{([\s\S]*?)\n\}/) - if (!block) { - throw new Error('types.ts no longer declares GlobalSettings in the expected shape') - } - const fields = block[1].match(/^\s{2}\w+\??:/gm) ?? [] - if (fields.length < 50) { - throw new Error(`GlobalSettings parsed as only ${fields.length} fields; re-sync this benchmark`) - } - return fields.length -} - -const SETTINGS_FIELDS = countGlobalSettingsFields(TYPES_SOURCE) -const ROWS = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_ROWS ?? '43', 10) -const WRITES = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_WRITES ?? '2000', 10) -const WARMUP = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_WARMUP ?? '200', 10) - -for (const [name, value] of [ - ['ORCA_OWNER_SETTINGS_BENCH_ROWS', ROWS], - ['ORCA_OWNER_SETTINGS_BENCH_WRITES', WRITES], - ['ORCA_OWNER_SETTINGS_BENCH_WARMUP', WARMUP] -]) { - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`${name} must be a positive integer, received ${value}`) - } -} - -function makeSettings() { - const settings = { activeRuntimeEnvironmentId: 'focused-runtime' } - for (let index = 0; index < SETTINGS_FIELDS - 1; index += 1) { - settings[`field${index}`] = index % 3 === 0 ? `value-${index}` : index % 3 === 1 ? index : true - } - return settings -} - -function makeRepos(rows) { - return Array.from({ length: rows }, (_value, index) => ({ - id: `repo-${index}`, - connectionId: null, - executionHostId: `runtime:env-${index % 4}` - })) -} - -function resolveEnvironmentId(state, repoId) { - if (!repoId) { - return null - } - const matching = state.repos.filter((entry) => entry.id === repoId) - const repo = matching.length === 1 ? matching[0] : null - const hasOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim()) - if (repo && hasOwner) { - const hostId = repo.executionHostId ?? '' - return hostId.startsWith('runtime:') ? hostId.slice('runtime:'.length) : null - } - return state.settings?.activeRuntimeEnvironmentId?.trim() || null -} - -// Pre-fix: a fresh object every call. -function selectorBefore(state, repoId) { - return { ...state.settings, activeRuntimeEnvironmentId: resolveEnvironmentId(state, repoId) } -} - -// Post-fix: reuse the reference while nothing it derives from changed. -function makeCachedSelector() { - const cache = new Map() - return (state, repoId) => { - const environmentId = resolveEnvironmentId(state, repoId) - const cacheKey = repoId ?? '' - const cached = cache.get(cacheKey) - if ( - cached && - cached.settingsSource === state.settings && - cached.reposSource === state.repos && - cached.environmentId === environmentId - ) { - return cached.value - } - const value = { ...state.settings, activeRuntimeEnvironmentId: environmentId } - cache.set(cacheKey, { - settingsSource: state.settings, - reposSource: state.repos, - environmentId, - value - }) - if (cache.size > 256) { - const oldest = cache.keys().next() - if (!oldest.done) { - cache.delete(oldest.value) - } - } - return value - } -} - -// Mirror of zustand's useShallow comparison. -function shallowEqual(a, b) { - if (Object.is(a, b)) { - return true - } - if (a === null || b === null || a === undefined || b === undefined) { - return false - } - const keysA = Object.keys(a) - const keysB = Object.keys(b) - if (keysA.length !== keysB.length) { - return false - } - for (const key of keysA) { - if (!Object.is(a[key], b[key])) { - return false - } - } - return true -} - -// One unrelated store write: every subscribed row re-runs its selector and the -// result is compared against the previous one to decide whether to re-render. -function simulateWrite(selector, state, rows, previous) { - let changed = 0 - for (let row = 0; row < rows; row += 1) { - const next = selector(state, `repo-${row}`) - if (!shallowEqual(previous[row], next)) { - changed += 1 - } - previous[row] = next - } - return changed -} - -function measure(selector, state, rows) { - const previous = Array.from({ length: rows }, () => null) - for (let index = 0; index < WARMUP; index += 1) { - simulateWrite(selector, state, rows, previous) - } - const samples = [] - for (let round = 0; round < 5; round += 1) { - const start = performance.now() - for (let index = 0; index < WRITES; index += 1) { - simulateWrite(selector, state, rows, previous) - } - samples.push((performance.now() - start) / WRITES) - } - samples.sort((a, b) => a - b) - return samples[2] -} - -const settings = makeSettings() -const rowCounts = [1, 10, ROWS, 100] -const rows = [] -for (const rowCount of rowCounts) { - const state = { repos: makeRepos(Math.max(rowCount, ROWS)), settings } - const cached = makeCachedSelector() - // Equivalence: both selectors must produce the same value for every row. - for (let row = 0; row < rowCount; row += 1) { - const before = selectorBefore(state, `repo-${row}`) - const after = cached(state, `repo-${row}`) - if (!shallowEqual(before, after)) { - throw new Error(`selector mismatch at repo-${row}`) - } - } - rows.push({ - rowCount, - beforeMs: measure(selectorBefore, state, rowCount), - afterMs: measure(makeCachedSelector(), state, rowCount) - }) -} - -const pad = (value, width) => String(value).padStart(width) -console.log(`Owner-routed settings selector, per unrelated store write`) -console.log( - `GlobalSettings fields=${SETTINGS_FIELDS} writes=${WRITES} warmup=${WARMUP} (median of 5 rounds)` -) -console.log(`${pad('rows', 6)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('speedup', 9)}`) -for (const row of rows) { - console.log( - `${pad(row.rowCount, 6)} ${pad(row.beforeMs.toFixed(4), 11)} ${pad(row.afterMs.toFixed(4), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(0)}x`, 9)}` - ) -} -console.log( - `\nrows = subscribed selector call sites on screen (~${ROWS} across PullRequestPage/TaskPage).\nThe cost is paid on EVERY store write, including writes that touch nothing\nthese selectors read.` -) diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 2200f1b0c1c..db4db00ed17 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -192,6 +192,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/opencode/opencode-acp-stored-messages.test.ts', 'src/main/orcad/orcad-automations.test.ts', 'src/main/orcad/orcad-local-serve-selection.test.ts', + 'src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts', 'src/main/orcad/serve-profile-ssh-targets.test.ts', 'src/main/pi/rpc-launch-resolution.test.ts', 'src/main/runtime/agent-session-conversation-clear-commit.test.ts', diff --git a/config/verified-links/README.md b/config/verified-links/README.md deleted file mode 100644 index 6c1899457eb..00000000000 --- a/config/verified-links/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# Verified pairing links - -Orca Mobile pairs from `https://www.onorca.dev/pair#`. The pairing code is in the URL -fragment, which browsers never send to a server. iOS and Android open that link in Orca Mobile only -after they have fetched and verified the two files in this directory from `www.onorca.dev`. Links -that use the `orca://` scheme no longer carry a code into the app, because any app can claim that -scheme. - -The marketing site that serves `www.onorca.dev` is not in this repository, so these files must be -deployed there by hand. - -## What to deploy - -| File in this directory | Serve at | Content-Type | -| ---------------------------- | --------------------------------------------------------------- | ------------------ | -| `apple-app-site-association` | `https://www.onorca.dev/.well-known/apple-app-site-association` | `application/json` | -| `assetlinks.json` | `https://www.onorca.dev/.well-known/assetlinks.json` | `application/json` | -| `pair.html` | `https://www.onorca.dev/pair` | `text/html` | - -Both `.well-known` files must: - -- return `200` directly, with **no redirect**. The apex `onorca.dev` redirects to `www`, which is - why the links use `www.onorca.dev`. Neither OS follows a redirect while verifying. -- need **no authentication**, cookie, bot challenge, or other interstitial. Exempt `/.well-known/` - from any Cloudflare bot or challenge rule. -- be served with `Content-Type: application/json`. The Apple file has no extension, so most hosts - serve it as `application/octet-stream` unless a header rule overrides that. - -`pair.html` is the page people see when the app is not installed, is too old to claim the link, or -the OS has not verified the domain yet. It explains how to pair instead, and can copy the code from -the fragment for the paste flow, which older app versions also accept. Do not add analytics or any -third-party script to this page: a script on it could read the code from the fragment. - -## Placeholders to fill in before deploying - -- `apple-app-site-association`: replace `REPLACE_WITH_APPLE_TEAM_ID` with the Apple Developer Team - ID (the `APPLE_TEAM_ID` secret used by `mobile/fastlane`). -- `assetlinks.json`: replace `REPLACE_WITH_RELEASE_SIGNING_CERT_SHA256` with the SHA-256 fingerprint - of the certificate that signs the shipped APK. If the app is published on Google Play, use the - **app signing key** fingerprint from Play Console → App integrity, not the upload key. You can list - more than one fingerprint, for example both the Play key and a key used for direct APK downloads. - -The direct-download APK built by `.github/workflows/mobile-android-release.yml` is currently signed -with the build template's public debug keystore. Do not list that fingerprint (`FA:C6:17:45:…`): it -is public, so any app signed with it could claim the links. -`src/shared/verified-pairing-link-files.test.ts` fails if it appears. - -## App side - -- Android: `mobile/app.config.js` adds an `autoVerify` intent filter for `https://www.onorca.dev/pair`. - Android checks `assetlinks.json` when the app is installed or updated. -- iOS: the associated-domains entitlement is opt-in, because an App Store archive with it fails to - sign until the App ID has the capability. To turn it on: - 1. In the Apple Developer portal, enable **Associated Domains** on the App ID - `com.stably.orca.mobile`. The release lane regenerates the provisioning profile on every run. - 2. Set the GitHub repository variable `ORCA_IOS_ASSOCIATED_DOMAINS` to `1`. The iOS release - workflow then builds with `applinks:www.onorca.dev`. - -The host, the path, and the app identifiers are mirrored in `src/shared/verified-pairing-link.ts`, -`mobile/app.config.js`, and the files in this directory. `src/shared/verified-pairing-link-files.test.ts` -fails if they drift apart. - -## Checking a deployment - -```sh -curl -sI https://www.onorca.dev/.well-known/apple-app-site-association # 200, application/json -curl -sI https://www.onorca.dev/.well-known/assetlinks.json # 200, application/json -``` - -Apple fetches the file through its own CDN, which can lag by up to a day: -`https://app-site-association.cdn-apple.com/a/v1/www.onorca.dev`. On an Android device, run -`adb shell pm get-app-links com.stably.orca.mobile` and look for `www.onorca.dev: verified`. diff --git a/config/verified-links/apple-app-site-association b/config/verified-links/apple-app-site-association deleted file mode 100644 index a6569427f3c..00000000000 --- a/config/verified-links/apple-app-site-association +++ /dev/null @@ -1,15 +0,0 @@ -{ - "applinks": { - "details": [ - { - "appIDs": ["REPLACE_WITH_APPLE_TEAM_ID.com.stably.orca.mobile"], - "components": [ - { - "/": "/pair", - "comment": "Orca Mobile pairing link. The pairing code is in the fragment." - } - ] - } - ] - } -} diff --git a/config/verified-links/assetlinks.json b/config/verified-links/assetlinks.json deleted file mode 100644 index 149654528e4..00000000000 --- a/config/verified-links/assetlinks.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "relation": ["delegate_permission/common.handle_all_urls"], - "target": { - "namespace": "android_app", - "package_name": "com.stably.orca.mobile", - "sha256_cert_fingerprints": ["REPLACE_WITH_RELEASE_SIGNING_CERT_SHA256"] - } - } -] diff --git a/config/verified-links/pair.html b/config/verified-links/pair.html deleted file mode 100644 index 7be5160fd2f..00000000000 --- a/config/verified-links/pair.html +++ /dev/null @@ -1,155 +0,0 @@ - - - - - - - - - - Pair Orca Mobile - - - -
-

Pair Orca Mobile

-

- This link pairs your phone with Orca on your computer. It opens in the Orca app when Orca - Mobile is installed and up to date. -

-

Seeing this page instead?

-
    -
  1. Update Orca Mobile, then open the link again.
  2. -
  3. - Or open Orca Mobile, tap Pair, and scan the QR code shown in Orca on your - computer under Settings → Mobile. -
  4. - -
-

Don't have the app? Download Orca Mobile.

-

- Treat the pairing code like a password. It is in the part of this link after “#”, which your - browser keeps on this device and never sends to onorca.dev. -

-
- - - diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index 794e5c4c640..52d0684af93 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -264,7 +264,7 @@ Use this rubric when reviewing any Orca IDE screen, screenshot, or prototype. A - **Layout density:** avoid jamming controls together. Preserve breathing room around the primary workflow, reduce competing buttons, and keep toolbar groups visually distinct. Dense screens are acceptable only when grouping, alignment, and hierarchy make scanning faster. - **Cards and containers:** cards must be visually distinct from their parent surface through the existing `card`/`border` treatment. Avoid nesting cards inside cards. If a section is not a repeated item, modal, or framed tool, consider an unframed layout or full-width band instead. - **Side-by-side layouts:** default to row-by-row layouts for complex workflows because they are easier to align and scan. Use side-by-side layouts only when space is constrained or comparison is the point, then polish column widths, baselines, and wrapping states carefully. -- **Animation:** use subtle animation to soften expanding/collapsing content and prevent jumpy layout changes. Animation should clarify continuity, not decorate. Respect reduced motion element by element: turn movement off with `motion-reduce:` (slides, rotations, size changes); give spinners, pulses and progress indicators a still state that still says "working" (`LoadingSpinner` does this for spinners) instead of freezing them; short opacity fades can stay. +- **Animation:** use subtle animation to soften expanding/collapsing content and prevent jumpy layout changes. Animation should clarify continuity, not decorate. Respect reduced motion element by element: turn movement off with `motion-reduce:` (slides, rotations, size changes); give spinners, pulses and progress indicators a still state that still says "working" (`LoadingSpinner` does this for spinners) instead of freezing them; short opacity fades can stay. Scripted scrolls ignore CSS, so pass them through `motionSafeScrollBehavior()`. - **SSH and latency:** assume actions may run remotely. Disable submit controls immediately, delay visible loading feedback when appropriate, and keep focus stable while remote data arrives. ### 1. Match in-flight feedback to perceived duration diff --git a/mobile/app.config.js b/mobile/app.config.js index b3cb4df6f7c..e4bb110089f 100644 --- a/mobile/app.config.js +++ b/mobile/app.config.js @@ -11,37 +11,12 @@ const APS_ENVIRONMENT = process.env.ORCA_IOS_APS_ENVIRONMENT === 'production' ? 'production' : 'development' -// Verified pairing links. Mirrors src/shared/verified-pairing-link.ts and config/verified-links/; -// src/shared/verified-pairing-link-files.test.ts fails if they drift. -const VERIFIED_PAIRING_LINK_HOST = 'www.onorca.dev' -const VERIFIED_PAIRING_LINK_PATH = '/pair' -// Why opt-in: an App Store archive with this entitlement fails to sign until the App ID has the -// Associated Domains capability (config/verified-links/README.md). -const IOS_ASSOCIATED_DOMAINS = process.env.ORCA_IOS_ASSOCIATED_DOMAINS === '1' - module.exports = ({ config }) => ({ ...config, ios: { ...config.ios, - ...(IOS_ASSOCIATED_DOMAINS - ? { associatedDomains: [`applinks:${VERIFIED_PAIRING_LINK_HOST}`] } - : {}), entitlements: { ...config.ios?.entitlements, 'aps-environment': APS_ENVIRONMENT } }, - android: { - ...config.android, - intentFilters: [ - ...(config.android?.intentFilters ?? []), - { - action: 'VIEW', - autoVerify: true, - data: [ - { scheme: 'https', host: VERIFIED_PAIRING_LINK_HOST, path: VERIFIED_PAIRING_LINK_PATH } - ], - category: ['BROWSABLE', 'DEFAULT'] - } - ] - }, plugins: (config.plugins ?? []).map((plugin) => plugin === 'expo-notifications' ? [ diff --git a/mobile/app/+native-intent.ts b/mobile/app/+native-intent.ts deleted file mode 100644 index 94ca9c7a94e..00000000000 --- a/mobile/app/+native-intent.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { redirectIncomingPairingLink } from '../src/transport/incoming-pairing-link' - -// Why: rewrite before routing, so no OS-delivered custom-scheme link reaches a screen that reads a code. -export function redirectSystemPath({ path }: { path: string; initial: boolean }): string { - return redirectIncomingPairingLink(path) ?? path -} diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 54d9c2c4841..711ee41ffd3 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -4,10 +4,11 @@ import { readNativeNotificationData } from '../src/notifications/native-notifica import { setNotificationViewingWorkspace } from '../src/notifications/notification-viewing-policy' import { useCallback, useEffect, useRef } from 'react' import { View, StyleSheet } from 'react-native' -import { Stack, useGlobalSearchParams, usePathname } from 'expo-router' +import { Stack, useRouter, useGlobalSearchParams, usePathname } from 'expo-router' import { StatusBar } from 'expo-status-bar' import * as SplashScreen from 'expo-splash-screen' import * as Notifications from 'expo-notifications' +import * as Linking from 'expo-linking' import { colors } from '../src/theme/mobile-theme' import { OrcaLogo } from '../src/components/OrcaLogo' import { RpcClientProvider } from '../src/transport/client-context' @@ -21,6 +22,7 @@ import { import { startPushTokenSync } from '../src/notifications/push-registration' import { ensureDesktopNotificationChannel } from '../src/notifications/desktop-notification-channel' import { loadHostCatalog } from '../src/transport/host-store' +import { extractPairingCodeFromUrl } from '../src/transport/pairing' import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery' import { appUpdateChecker } from '../src/app-update/app-update-runtime' @@ -41,6 +43,7 @@ Notifications.setNotificationHandler({ }) export default function RootLayout() { + const router = useRouter() const pathname = usePathname() const { hostId, worktreeId } = useGlobalSearchParams<{ hostId?: string; worktreeId?: string }>() useEffect(() => { @@ -67,6 +70,32 @@ export default function RootLayout() { useEffect(() => appUpdateChecker.start(), []) useEffect(() => startAndroidForegroundPushPresentation(), []) + // Why: route `orca://pair?...` deep links to the confirm screen so + // the same pairing flow runs whether the link arrived via QR scan, + // paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL + // covers cold-start (link tapped while app was closed); the listener + // covers warm-start (link tapped while app is in memory). + useEffect(() => { + function handleUrl(url: string) { + const code = extractPairingCodeFromUrl(url) + if (code) { + // Why: Android camera launches can leave Expo Router's unmatched + // `orca://pair` route underneath this screen; replacing keeps cancel + // and edge-back from revealing the router error page. + router.replace({ pathname: '/pair-confirm', params: { code } }) + } + } + + void Linking.getInitialURL().then((url) => { + if (url) { + handleUrl(url) + } + }) + + const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url)) + return () => sub.remove() + }, [router]) + // ─── Notification tap routing ─── // Why: iOS delivers local notification taps through expo-notifications, // not Linking. Route both cold-start and warm-start responses to the host diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx index e4ac4881015..be790427088 100644 --- a/mobile/app/pair-confirm.tsx +++ b/mobile/app/pair-confirm.tsx @@ -3,10 +3,7 @@ import { View, Text, StyleSheet, Pressable, ActivityIndicator, BackHandler } fro import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import { ChevronLeft } from 'lucide-react-native' -import { - describePairingHost, - resolvePairConfirmRouteState -} from '../src/transport/pair-confirm-state' +import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state' import { startPreProfilePairing, type PreProfilePairingAttempt @@ -46,7 +43,6 @@ export default function PairConfirmScreen() { const routeState = resolvePairConfirmRouteState(params.code) const offer = routeState.offer - const host = offer ? describePairingHost(offer) : null const resolvedStatus = status === 'awaiting-confirm' && routeState.kind === 'error' ? 'error' : status const resolvedErrorMessage = @@ -156,24 +152,8 @@ export default function PairConfirmScreen() { <> Pair with this desktop? - Check that this address matches the one under the QR code in Orca on your computer. + You opened a pairing link from your desktop. Confirm to add it to your hosts. - {host && ( - - Address - - {host.endpoint} - - {host.keyFingerprint && ( - <> - Host key - - {host.keyFingerprint} - - - )} - - )} void confirm()}> Pair @@ -254,27 +234,6 @@ const styles = StyleSheet.create({ maxWidth: 520, alignSelf: 'center' }, - hostDetails: { - width: '100%', - maxWidth: 360, - alignSelf: 'center', - backgroundColor: colors.bgRaised, - borderColor: colors.borderSubtle, - borderWidth: 1, - borderRadius: radii.card, - padding: spacing.md, - marginBottom: spacing.xl - }, - detailLabel: { - color: colors.textSecondary, - fontSize: typography.metaSize - }, - detailValue: { - color: colors.textPrimary, - fontFamily: typography.monoFamily, - fontSize: typography.bodySize, - marginBottom: spacing.sm - }, actionStack: { width: '100%', maxWidth: 360, diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index 3a93ade8482..61e99f19f2a 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -9,10 +9,9 @@ import { } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { CameraView, useCameraPermissions } from 'expo-camera' -import { useLocalSearchParams, useRouter } from 'expo-router' +import { useRouter } from 'expo-router' import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native' import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing' -import { PAIR_SCAN_LINK_REFUSED_NOTICE } from '../src/transport/incoming-pairing-link' import { startPreProfilePairing, type PreProfilePairingAttempt @@ -35,18 +34,6 @@ const PAIRING_OVERALL_TIMEOUT_MS = 25_000 const SCAN_RETICLE_SCALE = 0.62 const SCAN_RETICLE_MAX_SIZE = 360 -function LinkRefusedNotice({ notice }: { notice: string | undefined }) { - if (notice !== PAIR_SCAN_LINK_REFUSED_NOTICE) { - return null - } - return ( - - That orca:// link can’t pair your phone, because any app can open orca:// links. Scan the QR - code on your computer instead. - - ) -} - function Step({ number, text }: { number: number; text: string }) { return ( @@ -62,7 +49,6 @@ export default function PairScanScreen() { const router = useRouter() const refreshHostClient = useRefreshHostClient() const insets = useSafeAreaInsets() - const { notice } = useLocalSearchParams<{ notice?: string }>() const [permission, requestPermission] = useCameraPermissions() const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning') const [errorMessage, setErrorMessage] = useState('') @@ -74,15 +60,6 @@ export default function PairScanScreen() { const mountedRef = useRef(true) const activePairingAttemptRef = useRef(null) - // Why: a refused link can cold-start the app on this screen, with nothing to go back to. - const goBack = useCallback(() => { - if (router.canGoBack()) { - router.back() - } else { - router.replace('/') - } - }, [router]) - const setPairScanRootRef = useCallback((node: View | null): void => { if (node !== null) { mountedRef.current = true @@ -241,10 +218,9 @@ export default function PairScanScreen() { const canAskAgain = permission.canAskAgain !== false return ( - + router.back()}> - {canAskAgain ? 'Pair with desktop' : 'Camera Access Disabled'} @@ -275,7 +251,7 @@ export default function PairScanScreen() { visible={pasteVisible} title="Paste pairing code" message="Copy the code shown under the QR on your computer." - placeholder="Paste the pairing link or code" + placeholder="orca://pair?code=... or paste the code" onSubmit={handlePasteSubmit} onCancel={() => setPasteVisible(false)} /> @@ -285,11 +261,10 @@ export default function PairScanScreen() { return ( - + router.back()}> - @@ -374,7 +349,7 @@ export default function PairScanScreen() { visible={pasteVisible} title="Paste pairing code" message="Copy the code shown under the QR on your computer." - placeholder="Paste the pairing link or code" + placeholder="orca://pair?code=... or paste the code" onSubmit={handlePasteSubmit} onCancel={() => setPasteVisible(false)} /> diff --git a/mobile/app/pair.tsx b/mobile/app/pair.tsx index a1ec81f3772..6f3483763e1 100644 --- a/mobile/app/pair.tsx +++ b/mobile/app/pair.tsx @@ -1,6 +1,87 @@ -import { Redirect } from 'expo-router' +import { useCallback, useEffect, useState } from 'react' +import { ActivityIndicator, Linking, Pressable, StyleSheet, Text, View } from 'react-native' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' +import { extractPairingCodeFromUrl } from '../src/transport/pairing' -// Why: +native-intent rewrites OS links before routing; this route never reads a pairing code. export default function PairRedirectScreen() { - return + const router = useRouter() + const params = useLocalSearchParams<{ code?: string }>() + const [missingCode, setMissingCode] = useState(false) + + const goHome = useCallback(() => { + router.replace('/') + }, [router]) + + useEffect(() => { + let disposed = false + + async function redirectToConfirm() { + const codeParam = Array.isArray(params.code) ? params.code[0] : params.code + if (codeParam) { + router.replace({ pathname: '/pair-confirm', params: { code: codeParam } }) + return + } + + const initialUrl = await Linking.getInitialURL().catch(() => null) + const code = initialUrl ? extractPairingCodeFromUrl(initialUrl) : null + if (disposed) { + return + } + if (code) { + router.replace({ pathname: '/pair-confirm', params: { code } }) + return + } + setMissingCode(true) + } + + void redirectToConfirm() + return () => { + disposed = true + } + }, [params.code, router]) + + return ( + + {missingCode ? ( + <> + Missing pairing code + + Back to home + + + ) : ( + + )} + + ) } + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + errorText: { + color: colors.statusRed, + fontSize: typography.bodySize, + lineHeight: 20, + marginBottom: spacing.xl, + textAlign: 'center' + }, + primaryButton: { + alignItems: 'center', + backgroundColor: colors.textPrimary, + borderRadius: radii.button, + paddingHorizontal: spacing.xl, + paddingVertical: spacing.sm + 2 + }, + primaryButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/src/pair-scan-styles.ts b/mobile/src/pair-scan-styles.ts index edd185b7339..af1b936fb54 100644 --- a/mobile/src/pair-scan-styles.ts +++ b/mobile/src/pair-scan-styles.ts @@ -15,17 +15,6 @@ export const pairScanStyles = StyleSheet.create({ justifyContent: 'center', marginBottom: spacing.sm }, - notice: { - color: colors.textPrimary, - backgroundColor: colors.bgRaised, - borderColor: colors.borderSubtle, - borderWidth: 1, - borderRadius: radii.card, - fontSize: typography.bodySize, - lineHeight: 20, - padding: spacing.md, - marginBottom: spacing.lg - }, steps: { gap: spacing.sm, marginBottom: spacing.lg, diff --git a/mobile/src/transport/incoming-pairing-link-router-agreement.test.ts b/mobile/src/transport/incoming-pairing-link-router-agreement.test.ts deleted file mode 100644 index 570bcca55e2..00000000000 --- a/mobile/src/transport/incoming-pairing-link-router-agreement.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { extractExpoPathFromURL } from 'expo-router/build/fork/extractPathFromURL' -import { parseUrlUsingCustomBase } from 'expo-router/build/utils/url' -import { describe, expect, it } from 'vitest' -import { redirectIncomingPairingLink } from './incoming-pairing-link' - -// The oracle is the router itself: what its linking reads from an OS link after the hook passes it on. -function routerParams(url: string): URLSearchParams | null { - try { - return parseUrlUsingCustomBase(extractExpoPathFromURL([], url)).searchParams - } catch { - return null - } -} - -const TARGETS = [ - 'orca://pair-confirm?code=SECRET', - 'orca://pair?code=SECRET', - 'orca://pair-confirm?a=1&code=SECRET', - 'orca:///x/../pair-confirm?code=SECRET', - 'orca://(group)/pair-confirm?code=SECRET', - 'orca://h/host-1/session/wt-1?code=SECRET', - 'https://evil.example/pair-confirm?code=SECRET', - 'exp+orca-mobile://pair-confirm?code=SECRET' -] -// Tab, LF and CR are removed anywhere; C0 controls and space are trimmed from the ends. -const STRIPPED = ['\t', '\n', '\r'] -const TRIMMED = ['\u0000', '\u001f', ' '] - -function* mutations(url: string): Generator { - yield url - yield url.toUpperCase() - yield url.replace('code', 'CODE') - yield url.replace('code', '%63ode') - yield url.replace('code', 'code') - yield url.replace('pair-confirm', 'pair%2Dconfirm') - yield url.replace('?code=', '?code%3D') - yield url.replace('&code=', '%26code=') - for (const character of STRIPPED) { - for (let index = 0; index <= url.length; index++) { - yield url.slice(0, index) + character + url.slice(index) - } - yield url.replace('code', `co${encodeURIComponent(character)}de`) - } - for (const character of TRIMMED) { - yield `${character}${url}${character}` - } -} - -function* wrapped(url: string): Generator { - for (const depth of [0, 1, 2, 7]) { - for (const layers of [0, 1, 2, 3]) { - let current = url - for (let level = 0; level < depth; level++) { - let inner = current - for (let layer = 0; layer < layers; layer++) { - inner = encodeURIComponent(inner) - } - current = `orca://expo-development-client/?url=${inner}` - } - yield current - if (depth === 0) { - break - } - } - } -} - -function* corpus(): Generator { - for (const target of TARGETS) { - for (const mutated of mutations(target)) { - yield* wrapped(mutated) - } - } -} - -describe('redirectIncomingPairingLink agrees with the router', () => { - it('never passes on a link from which the router would read a code', () => { - let checked = 0 - for (const url of corpus()) { - for (const allowDevClientLinks of [true, false]) { - if (redirectIncomingPairingLink(url, allowDevClientLinks) === null) { - const params = routerParams(url) - expect(params, url).not.toBeNull() - expect(params?.has('code'), JSON.stringify(url)).toBe(false) - } - } - checked++ - } - expect(checked).toBeGreaterThan(1000) - }, 60_000) - - it('refuses every development-client wrapper in a release build', () => { - for (const url of corpus()) { - if (url.toLowerCase().includes('orca://expo-development-client')) { - expect(redirectIncomingPairingLink(url, false), JSON.stringify(url)).not.toBeNull() - } - } - }, 60_000) - - it('still lets ordinary links and the dev launcher through', () => { - expect(redirectIncomingPairingLink('orca://h/host-1/session/wt-1', false)).toBeNull() - expect( - redirectIncomingPairingLink( - `orca://expo-development-client/?url=${encodeURIComponent('http://192.168.1.5:8081')}`, - true - ) - ).toBeNull() - }) -}) diff --git a/mobile/src/transport/incoming-pairing-link.test.ts b/mobile/src/transport/incoming-pairing-link.test.ts deleted file mode 100644 index c8dc58568c7..00000000000 --- a/mobile/src/transport/incoming-pairing-link.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { PAIR_SCAN_LINK_REFUSED_NOTICE, redirectIncomingPairingLink } from './incoming-pairing-link' - -const refused = `/pair-scan?notice=${PAIR_SCAN_LINK_REFUSED_NOTICE}` - -describe('redirectIncomingPairingLink', () => { - it('sends a verified https link to the confirm screen with the fragment code', () => { - expect(redirectIncomingPairingLink('https://www.onorca.dev/pair#abc_1-2')).toBe( - '/pair-confirm?code=abc_1-2' - ) - }) - - it('refuses a pairing code carried by the custom scheme', () => { - for (const url of [ - 'orca://pair?code=abc123', - 'orca://pair#abc123', - 'ORCA://PAIR?code=abc123', - 'orca://pair/?code=abc123', - 'orca:///pair?code=abc123', - 'orca://pair-confirm?code=abc123', - 'orca:///pair-confirm?code=abc123', - 'exp+orca-mobile://pair-confirm?code=abc123', - 'orca://pair%2Dconfirm?code=abc123', - 'orca://./pair-confirm?code=abc123', - 'orca://h/host-1?code=abc123', - 'orca://pair-confirm?%63ode=abc123', - 'orca://pair-confirm?CODE=abc123', - 'orca:///ignored/../pair-confirm?code=abc123', - `orca://expo-development-client/?url=${encodeURIComponent('orca://pair-confirm?code=abc123')}`, - `exp+orca-mobile://expo-development-client/?url=${encodeURIComponent( - encodeURIComponent('orca://pair-confirm?code=abc123') - )}`, - `orca://expo-development-client/?url=${encodeURIComponent('orca://pair-confirm?%2563ode=abc')}`, - `orca://expo-development-client/?junk=%ZZ&url=${encodeURIComponent('orca://pair-confirm?code=x')}`, - 'orca://expo-development-client/?url=orca%3A%2F%2Fpair-confirm%3Fa%3D%E0%26code%3DSECRET', - 'orca://expo-development-client/?url=orca://pair-confirm?a%3D%E0%26code%3DSECRET', - 'orca://pair-confirm?code=SECRET#\u2029x', - 'orca://pair-confirm?code=SECRET#\nx', - 'orca://pair-confirm?code=SECRET#\u2028x', - 'orca://expo-development-client/?url=orca://pair-confirm?co%09de=SECRET', - 'orca://expo-development-client/?url=orca://pair-confirm?c%0Aode=SECRET', - 'orca://expo-development-client/?url=orca://pair-confirm%3Fcode%0D=SECRET' - ]) { - expect(redirectIncomingPairingLink(url), url).toBe(refused) - } - }) - - it('refuses a code nested in development-client wrappers at any depth', () => { - let url = 'orca://pair-confirm?code=abc123' - for (let depth = 0; depth < 12; depth++) { - url = `orca://expo-development-client/?url=${encodeURIComponent(url)}` - } - expect(redirectIncomingPairingLink(url)).toBe(refused) - }) - - it('opens the scanner for a bare custom-scheme pairing link', () => { - expect(redirectIncomingPairingLink('orca://pair')).toBe('/pair-scan') - expect(redirectIncomingPairingLink('orca://pair/')).toBe('/pair-scan') - }) - - it('refuses unverified https links that target a pairing screen', () => { - expect(redirectIncomingPairingLink('https://evil.example/pair#abc123')).toBe(refused) - expect(redirectIncomingPairingLink('https://www.onorca.dev/pair-confirm?code=abc')).toBe( - refused - ) - expect(redirectIncomingPairingLink('https://www.onorca.dev/pair?code=abc')).toBe(refused) - }) - - it('leaves every other link to the router', () => { - expect(redirectIncomingPairingLink('orca://h/host-1/session/wt-1')).toBeNull() - expect(redirectIncomingPairingLink('orca://pairing')).toBeNull() - expect(redirectIncomingPairingLink('orca://h/host-1?notice=x#y')).toBeNull() - expect(redirectIncomingPairingLink('orca://pair-scan')).toBeNull() - expect(redirectIncomingPairingLink('orca://h/host-1/session/pair')).toBeNull() - expect(redirectIncomingPairingLink('/settings')).toBeNull() - }) -}) diff --git a/mobile/src/transport/incoming-pairing-link.ts b/mobile/src/transport/incoming-pairing-link.ts deleted file mode 100644 index b08ef1d18e0..00000000000 --- a/mobile/src/transport/incoming-pairing-link.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { extractExpoPathFromURL } from 'expo-router/build/fork/extractPathFromURL' -import { parseUrlUsingCustomBase } from 'expo-router/build/utils/url' -import { extractVerifiedPairingLinkCode } from '../../../src/shared/verified-pairing-link' - -export const PAIR_SCAN_LINK_REFUSED_NOTICE = 'custom-scheme-link' - -const REFUSED_PATH = `/pair-scan?notice=${PAIR_SCAN_LINK_REFUSED_NOTICE}` -const PAIRING_ROUTES = new Set(['pair', 'pair-confirm']) -const DEV_CLIENT_HOST = 'expo-development-client' - -/** The router's own reading of an OS link: the same functions its linking uses, after this hook. */ -export function resolveRouterTarget(url: string): { - pathname: string - searchParams: URLSearchParams - hash: string -} { - const parsed = parseUrlUsingCustomBase(extractExpoPathFromURL([], url)) - return { pathname: parsed.pathname, searchParams: parsed.searchParams, hash: parsed.hash } -} - -/** - * Rewrites an OS-delivered link before the router sees it. Null leaves the link to the router. - * Why: the OS hands an `orca://` URL to whichever app claimed the scheme, so a code it carries may - * already be in another app's hands. Only a verified https link may start pairing. The decision is - * made on the router's own parse, never a re-implementation, and anything unparseable is refused. - */ -export function redirectIncomingPairingLink( - url: string, - allowDevClientLinks: boolean = typeof __DEV__ !== 'undefined' && __DEV__ -): string | null { - const verifiedCode = extractVerifiedPairingLinkCode(url) - if (verifiedCode) { - return `/pair-confirm?code=${encodeURIComponent(verifiedCode)}` - } - // Why: the router unwraps `expo-development-client/?url=` links; release builds have no use for them. - const outer = parseOuterUrl(url) - if (!allowDevClientLinks && outer?.hostname === DEV_CLIENT_HOST) { - return REFUSED_PATH - } - let target: ReturnType - try { - target = resolveRouterTarget(url) - } catch { - return REFUSED_PATH - } - if ([...target.searchParams.keys()].some((key) => key.toLowerCase() === 'code')) { - return REFUSED_PATH - } - // Pairing screens are top-level routes; groups like `(tabs)` don't count as segments. - const route = target.pathname - .split('/') - .map((segment) => safeDecode(segment).toLowerCase()) - .find((segment) => segment !== '' && !/^\(.*\)$/.test(segment)) - if (!route || !PAIRING_ROUTES.has(route)) { - return null - } - // Why the outer hash too: the router drops it, but a legacy `orca://pair#` still earns the notice. - const carriesPayload = - [...target.searchParams.keys()].length > 0 || - target.hash.length > 1 || - (outer?.hash.length ?? 0) > 1 - return carriesPayload ? REFUSED_PATH : '/pair-scan' -} - -// Same check the router applies before unwrapping a development-client link. -function parseOuterUrl(url: string): URL | null { - try { - return new URL(url) - } catch { - return null - } -} - -function safeDecode(value: string): string { - try { - return decodeURIComponent(value) - } catch { - return value - } -} diff --git a/mobile/src/transport/pair-confirm-state.test.ts b/mobile/src/transport/pair-confirm-state.test.ts index 11f2f0bc79c..1a318dd7e07 100644 --- a/mobile/src/transport/pair-confirm-state.test.ts +++ b/mobile/src/transport/pair-confirm-state.test.ts @@ -1,7 +1,7 @@ import { Buffer } from 'node:buffer' import { describe, expect, it } from 'vitest' -import { describePairingHost, resolvePairConfirmRouteState } from './pair-confirm-state' +import { resolvePairConfirmRouteState } from './pair-confirm-state' import type { PairingOffer } from './types' function encodeOffer(offer: PairingOffer): string { @@ -52,28 +52,3 @@ describe('resolvePairConfirmRouteState', () => { }) }) }) - -describe('describePairingHost', () => { - const publicKeyB64 = Buffer.alloc(32, 7).toString('base64') - - it('shows the endpoint and a host key fingerprint before pairing', () => { - const host = describePairingHost({ - v: 2, - endpoint: 'ws://192.168.1.10:6768', - deviceToken: 'token-abc', - publicKeyB64 - }) - expect(host.endpoint).toBe('ws://192.168.1.10:6768') - expect(host.keyFingerprint).toMatch(/^[A-Za-z0-9_-]{16}$/) - }) - - it('omits the fingerprint for an unreadable key', () => { - const host = describePairingHost({ - v: 2, - endpoint: 'ws://192.168.1.10:6768', - deviceToken: 'token-abc', - publicKeyB64: 'pubkey-xyz' - }) - expect(host.keyFingerprint).toBeNull() - }) -}) diff --git a/mobile/src/transport/pair-confirm-state.ts b/mobile/src/transport/pair-confirm-state.ts index c9d58232e25..242e67d7ffe 100644 --- a/mobile/src/transport/pair-confirm-state.ts +++ b/mobile/src/transport/pair-confirm-state.ts @@ -1,4 +1,3 @@ -import { deriveHostFingerprint } from '../notifications/push-host-fingerprint' import { parsePairingCode } from './pairing' import type { PairingOffer } from './types' @@ -18,11 +17,3 @@ export function resolvePairConfirmRouteState(code: string | undefined): PairConf return { kind: 'ready', offer, errorMessage: '' } } - -/** What the user checks against the desktop before pairing; a verified link proves the app, not the host. */ -export function describePairingHost(offer: PairingOffer): { - endpoint: string - keyFingerprint: string | null -} { - return { endpoint: offer.endpoint, keyFingerprint: deriveHostFingerprint(offer.publicKeyB64) } -} diff --git a/mobile/src/transport/pairing.test.ts b/mobile/src/transport/pairing.test.ts index 16a3141c911..9b7b7514369 100644 --- a/mobile/src/transport/pairing.test.ts +++ b/mobile/src/transport/pairing.test.ts @@ -39,11 +39,6 @@ describe('pairing deep links', () => { expect(extractPairingCodeFromUrl('orca://pair?code=query-code#hash-code')).toBe('query-code') }) - it('extracts the code from a verified https pairing link fragment', () => { - expect(extractPairingCodeFromUrl('https://www.onorca.dev/pair#abc123')).toBe('abc123') - expect(extractPairingCodeFromUrl('https://www.onorca.dev/pair?code=abc123')).toBeNull() - }) - it('ignores empty and unrelated URLs', () => { expect(extractPairingCodeFromUrl('orca://pair')).toBeNull() expect(extractPairingCodeFromUrl('https://example.com/pair#abc123')).toBeNull() @@ -68,13 +63,6 @@ describe('pairing deep links', () => { expect(parsePairingCode(code)).toEqual(offer) }) - it('parses a pasted or scanned verified https pairing link', () => { - const link = `https://www.onorca.dev/pair#${encodeOffer()}` - - expect(parsePairingCode(link)).toEqual(offer) - expect(decodePairingUrl(link)).toEqual(offer) - }) - it('preserves a TLS reverse-proxy endpoint with an explicit port and path', () => { const proxiedOffer = { ...offer, diff --git a/mobile/src/transport/pairing.ts b/mobile/src/transport/pairing.ts index 5e3c0aab99b..d330f1834fa 100644 --- a/mobile/src/transport/pairing.ts +++ b/mobile/src/transport/pairing.ts @@ -1,4 +1,3 @@ -import { extractVerifiedPairingLinkCode } from '../../../src/shared/verified-pairing-link' import { PairingOfferSchema, type PairingOffer } from './types' // Why: this file mirrors src/shared/pairing.ts (which is covered by CI @@ -18,14 +17,10 @@ export function decodePairingUrl(url: string): PairingOffer | null { } } -// Why: in-app QR scan and paste never pass through the OS link dispatcher, so -// they keep accepting the custom scheme that old desktops emit. OS-delivered -// links go through incoming-pairing-link.ts, which refuses custom-scheme codes. +// Why: system camera apps hand us the raw custom-scheme URL. Keeping +// extraction here makes QR scan, paste, and external deep-link flows +// accept the same URL shapes. export function extractPairingCodeFromUrl(url: string): string | null { - const verifiedCode = extractVerifiedPairingLinkCode(url) - if (verifiedCode) { - return verifiedCode - } const trimmed = url.trim() const match = /^orca:\/\/([^/?#]*)([^?#]*)?/i.exec(trimmed) if (!match) { @@ -54,8 +49,8 @@ export function extractPairingCodeFromUrl(url: string): string | null { return null } -// Why: accept an `orca://pair?...` URL, a verified https link, or the bare -// base64 string so the paste-pair flow can take whichever the user actually +// Why: accept either an `orca://pair?...` URL or the bare base64 +// string so the paste-pair flow can take whichever the user actually // copied from desktop. export function parsePairingCode(input: string): PairingOffer | null { const trimmed = input.trim() @@ -63,7 +58,7 @@ export function parsePairingCode(input: string): PairingOffer | null { return null } try { - if (/^(?:orca|https):\/\//i.test(trimmed)) { + if (/^orca:\/\//i.test(trimmed)) { return decodePairingUrl(trimmed) } return decodePairingBase64(trimmed) diff --git a/package.json b/package.json index 4b13af3fd03..f091d5bb3ff 100644 --- a/package.json +++ b/package.json @@ -330,7 +330,6 @@ "vite": "npm:rolldown-vite@7.3.1", "vitest": "5.0.3", "vscode-oniguruma": "^2.0.1", - "vscode-textmate": "^9.3.2", "zustand": "^5.0.15" }, "optionalDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ac7ef6021a..9e32cdad737 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -625,9 +625,6 @@ importers: vscode-oniguruma: specifier: ^2.0.1 version: 2.0.1 - vscode-textmate: - specifier: ^9.3.2 - version: 9.3.2 zustand: specifier: ^5.0.15 version: 5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) @@ -7839,9 +7836,6 @@ packages: vscode-oniguruma@2.0.1: resolution: {integrity: sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==} - vscode-textmate@9.3.2: - resolution: {integrity: sha512-n2uGbUcrjhUEBH16uGA0TvUfhWwliFZ1e3+pTjrkim1Mt7ydB41lV08aUvsi70OlzDWp6X7Bx3w/x3fAXIsN0Q==} - w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -15627,8 +15621,6 @@ snapshots: vscode-oniguruma@2.0.1: {} - vscode-textmate@9.3.2: {} - w3c-keyname@2.2.8: {} web-namespaces@2.0.1: {} diff --git a/src/main/agent-hooks/server-ingest-structured-children.test.ts b/src/main/agent-hooks/server-ingest-structured-children.test.ts index 92efc8ee553..4f61e3f5348 100644 --- a/src/main/agent-hooks/server-ingest-structured-children.test.ts +++ b/src/main/agent-hooks/server-ingest-structured-children.test.ts @@ -116,6 +116,21 @@ describe('structured child-work ingest', () => { expect(server.getStructuredChildWork(SUBJECT)).toEqual([first]) }) + // A quit stops hook ingress before the chat teardown decides which chats to offer to resume, + // and that decision reads these records: a chat whose only work was a background command or a + // subagent was otherwise never offered. + it('keeps the children readable through a quit stop', () => { + const server = new AgentHookServer() + server.ingestStructuredStatus(summary(), SUBJECT) + server.ingestStructuredChildWork(SUBJECT, started, 'claude') + server.stop({ keepStatus: true }) + expect(server.getStructuredChildWorkViews(SUBJECT)).toEqual([ + expect.objectContaining({ description: 'Audit the build', state: 'working' }) + ]) + server.stop() + expect(server.getStructuredChildWorkViews(SUBJECT)).toEqual([]) + }) + it('rejects an address that names no session', () => { const server = new AgentHookServer() expect(() => diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 2480c960b88..323066631ed 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -214,7 +214,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook this.endpointFileWritten = false } - stop(): void { + /** `keepStatus`: quit only. The chat teardown after it still reads each chat's subagents and + * background tasks from the status store to decide what to offer to resume; exit frees it. */ + stop(options?: { keepStatus?: boolean }): void { // Terminal status may still have a pending write while hook ingress is disabled. if (this.statusHooksEnabled || this.statusPersistTimer) { this.flushStatusPersistSync() @@ -262,8 +264,10 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook this.ownerStateInitialized = false // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. clearAllListenerCaches(this.state) - this.resetCanonicalStatus() - this.notifyStatusChangeListeners() + if (!options?.keepStatus) { + this.resetCanonicalStatus() + this.notifyStatusChangeListeners() + } this.paneStatusClearListeners.clear() this.statusDropListeners.clear() this.statusChangeListeners.clear() diff --git a/src/main/azure-devops/pull-request-creation.test.ts b/src/main/azure-devops/pull-request-creation.test.ts index b48150f8a02..894d80edf80 100644 --- a/src/main/azure-devops/pull-request-creation.test.ts +++ b/src/main/azure-devops/pull-request-creation.test.ts @@ -7,20 +7,14 @@ import { _resetAzureDevOpsPreviewApiVersionCache } from './azure-devops-api-requ import { _resetAzureDevOpsRepoRefCache } from './repository-ref' import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' -const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn() +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: () => 0 -})) - vi.mock('../source-control/pull-request-template', () => ({ readHostedPullRequestTemplate: vi.fn(async () => 'Template body') })) @@ -32,7 +26,6 @@ describe('Azure DevOps pull request creation', () => { beforeEach(() => { process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'pat-token' } gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() gitExecFileAsyncMock.mockResolvedValue({ stdout: 'https://dev.azure.com/acme/Project/_git/repo\n', stderr: '' @@ -183,44 +176,6 @@ describe('Azure DevOps pull request creation', () => { expect(fetchMock).toHaveBeenCalledOnce() }) - it('resolves Azure DevOps remotes through the SSH git provider', async () => { - const remoteGit = { - exec: vi.fn(async () => ({ - stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo.git\n', - stderr: '' - })) - } - getSshGitProviderMock.mockReturnValue(remoteGit) - globalThis.fetch = vi.fn(async () => - Response.json({ - pullRequestId: 38, - title: 'Remote Azure create', - status: 'active', - creationDate: '2026-06-01T00:00:00Z' - }) - ) as never - - await expect( - createAzureDevOpsPullRequest( - '/remote/repo', - { - provider: 'azure-devops', - base: 'main', - head: 'feature/azure', - title: 'Remote Azure create' - }, - 'ssh:ssh-1' - ) - ).resolves.toMatchObject({ - ok: true, - number: 38 - }) - expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo', { - signal: expect.any(AbortSignal) - }) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - it('classifies auth failures without retrying shell commands', async () => { globalThis.fetch = vi.fn(async () => Response.json({ message: 'Unauthorized' }, { status: 401 }) diff --git a/src/main/azure-devops/repository-ref.test.ts b/src/main/azure-devops/repository-ref.test.ts index 8b8a98f625b..425d2548059 100644 --- a/src/main/azure-devops/repository-ref.test.ts +++ b/src/main/azure-devops/repository-ref.test.ts @@ -1,8 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - sshExecMock: vi.fn() +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ @@ -15,19 +14,15 @@ import { getAzureDevOpsRepoRefForRemote, parseAzureDevOpsRepoRef } from './repository-ref' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' describe('parseAzureDevOpsRepoRef', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - sshExecMock.mockReset() - unregisterSshGitProvider('conn-1') _resetAzureDevOpsRepoRefCache() }) afterEach(() => { - unregisterSshGitProvider('conn-1') _resetAzureDevOpsRepoRefCache() }) @@ -102,28 +97,6 @@ describe('parseAzureDevOpsRepoRef', () => { expect(parseAzureDevOpsRepoRef('git@github.com:stablyai/orca.git')).toBeNull() }) - it('resolves repository refs through the SSH git provider for connected repos', async () => { - sshExecMock.mockResolvedValueOnce({ - stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo\n', - stderr: '' - }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getAzureDevOpsRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({ - host: 'dev.azure.com', - organization: 'acme', - project: 'Project', - repository: 'repo', - apiBaseUrl: 'https://dev.azure.com/acme/Project', - webBaseUrl: 'https://dev.azure.com/acme/Project/_git/repo' - }) - - expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', { - signal: expect.any(AbortSignal) - }) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - it('keeps local host and local WSL repository-ref cache entries separate', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ @@ -165,14 +138,13 @@ describe('parseAzureDevOpsRepoRef', () => { }) it('bounds cached repository refs for distinct repo paths', async () => { - sshExecMock.mockResolvedValue({ + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo\n', stderr: '' }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) for (let i = 0; i < 513; i += 1) { - await getAzureDevOpsRepoRefForRemote(`/repo-${i}`, 'origin', 'conn-1') + await getAzureDevOpsRepoRefForRemote(`/repo-${i}`, 'origin') } expect(_getAzureDevOpsRepoRefCacheSize()).toBe(512) diff --git a/src/main/bitbucket/pull-request-creation.test.ts b/src/main/bitbucket/pull-request-creation.test.ts index 88fc73bc5de..cb0e0b26236 100644 --- a/src/main/bitbucket/pull-request-creation.test.ts +++ b/src/main/bitbucket/pull-request-creation.test.ts @@ -5,20 +5,14 @@ import { } from './pull-request-creation' import { _resetBitbucketRepoRefCache } from './repository-ref' -const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn() +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: () => 0 -})) - vi.mock('../source-control/pull-request-template', () => ({ readHostedPullRequestTemplate: vi.fn(async () => 'Template body') })) @@ -49,7 +43,6 @@ describe('Bitbucket pull request creation', () => { beforeEach(() => { process.env = { ...OLD_ENV, ORCA_BITBUCKET_ACCESS_TOKEN: 'bb-token' } gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() gitExecFileAsyncMock.mockResolvedValue({ stdout: 'https://bitbucket.org/team/repo.git\n', stderr: '' diff --git a/src/main/bitbucket/repository-ref.test.ts b/src/main/bitbucket/repository-ref.test.ts index 42aa0454c1f..2831efae16c 100644 --- a/src/main/bitbucket/repository-ref.test.ts +++ b/src/main/bitbucket/repository-ref.test.ts @@ -1,8 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - sshExecMock: vi.fn() +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ @@ -12,24 +11,18 @@ vi.mock('../git/runner', () => ({ import { _getBitbucketRepoRefCacheSize, _resetBitbucketRepoRefCache, - getBitbucketRepoRefForRemote, getBitbucketRepoRef, parseBitbucketRepoRef } from './repository-ref' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' describe('Bitbucket repository refs', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - sshExecMock.mockReset() - unregisterSshGitProvider('conn-1') _resetBitbucketRepoRefCache() }) - afterEach(() => { - unregisterSshGitProvider('conn-1') - }) + afterEach(() => {}) it('parses HTTPS, SSH, and ssh:// Bitbucket remotes', () => { expect(parseBitbucketRepoRef('https://bitbucket.org/team/project.git')).toEqual({ @@ -108,22 +101,4 @@ describe('Bitbucket repository refs', () => { expect(_getBitbucketRepoRefCacheSize()).toBe(512) }) - - it('resolves project refs through the SSH git provider for connected repos', async () => { - sshExecMock.mockResolvedValueOnce({ - stdout: 'git@bitbucket.org:remote/project.git\n', - stderr: '' - }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getBitbucketRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toEqual({ - workspace: 'remote', - repoSlug: 'project' - }) - - expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', { - signal: expect.any(AbortSignal) - }) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/browser/doc-preview-file-reader.test.ts b/src/main/browser/doc-preview-file-reader.test.ts index 73b6414ebd0..fc450d1c305 100644 --- a/src/main/browser/doc-preview-file-reader.test.ts +++ b/src/main/browser/doc-preview-file-reader.test.ts @@ -1,17 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - callRuntimeEnvironment: vi.fn(), - readDocPreviewFile: vi.fn(), - requireSshFilesystemProvider: vi.fn() + callRuntimeEnvironment: vi.fn() })) vi.mock('../ipc/runtime-environment-transport-routing', () => ({ callRuntimeEnvironment: mocks.callRuntimeEnvironment })) -vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/user-data' })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - requireSshFilesystemProvider: mocks.requireSshFilesystemProvider +vi.mock('../persistence', () => ({ + getCanonicalUserDataPath: () => '/user-data' })) import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader' @@ -53,9 +50,6 @@ function runtimeGrant(root = '/srv/repo/docs'): ReturnType { vi.clearAllMocks() revokeAllDocPreviewGrants() - mocks.requireSshFilesystemProvider.mockReturnValue({ - readDocPreviewFile: mocks.readDocPreviewFile - }) }) describe('docPreviewContentType', () => { @@ -68,131 +62,15 @@ describe('docPreviewContentType', () => { }) describe('readDocPreviewFile — ssh owner', () => { - it('reads text through the SSH filesystem provider', async () => { - mocks.readDocPreviewFile.mockResolvedValue({ content: '

hi

', isBinary: false }) + it('404s with the not-served message instead of reading anything', async () => { + const grant = sshGrant() - const outcome = await readDocPreviewFile(sshGrant(), 'index.html') - - expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1') - expect(mocks.readDocPreviewFile).toHaveBeenCalledWith({ - boundaryPath: '/home/alice/docs', - entryPath: '/home/alice/docs/index.html', - implicitRootPath: null, - authorizedRootPaths: ['/home/alice/docs'], - targetPath: '/home/alice/docs/index.html', - maxTextBytes: 10 * 1024 * 1024, - maxBinaryBytes: 10 * 1024 * 1024 - }) - expect(outcome).toEqual({ - ok: true, - bytes: Buffer.from('

hi

', 'utf8'), - contentType: 'text/html; charset=utf-8' - }) - }) - - it('decodes a base64 binary asset', async () => { - const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]) - mocks.readDocPreviewFile.mockResolvedValue({ content: png.toString('base64'), isBinary: true }) - - const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png') - - expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' }) - }) - - // Why: the SSH reader rejects an over-cap file rather than clamping it, so a completed read is - // always whole and needs no truncation flag. - it('serves a whole SSH read that carries no truncation flag', async () => { - mocks.readDocPreviewFile.mockResolvedValue({ content: '

whole

', isBinary: false }) - - expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true }) - }) - - // Older hosts refuse fonts without bytes or a MIME type; keep that answer explicit. - it('reports a file type the host will not send as unsupported-asset', async () => { - mocks.readDocPreviewFile.mockResolvedValue({ content: '', isBinary: true }) - - const outcome = await readDocPreviewFile(sshGrant(), 'assets/font.woff2') - - expect(outcome).toMatchObject({ ok: false, status: 415, reason: 'unsupported-asset' }) - }) - - // Why: a host that still named the type read a 0-byte file, so 0 bytes is the honest answer — - // reporting it as a refused format would be a failure the workspace never reported. - it('serves an empty file the host still typed instead of calling it unsupported', async () => { - mocks.readDocPreviewFile.mockResolvedValue({ - content: '', - isBinary: true, - mimeType: 'image/png' - }) - - const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png') - - expect(outcome).toEqual({ ok: true, bytes: Buffer.alloc(0), contentType: 'image/png' }) - }) - - it('404s when the host cannot canonicalize the path at all', async () => { - mocks.readDocPreviewFile.mockRejectedValue(new Error('no such file')) - - expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ + await expect(readDocPreviewFile(grant, 'index.html')).resolves.toMatchObject({ ok: false, - status: 404 - }) - expect(mocks.readDocPreviewFile).toHaveBeenCalledOnce() - }) - - it('404s a path outside the grant root without touching the provider', async () => { - const outcome = await readDocPreviewFile(sshGrant(), '../../etc/passwd') - - expect(outcome).toMatchObject({ ok: false, status: 404 }) - expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled() - }) - - it('requires approval for a sibling directory before touching the SSH provider', async () => { - const grant = mintDocPreviewGrant({ - owner: { kind: 'ssh', connectionId: 'ssh-1' }, - requestBase: '/home/alice', - root: '/home/alice/docs', - entryRelativePath: 'docs/index.html', - browserPageId: 'page-1' - }) - - await expect(readDocPreviewFile(grant, 'assets/logo.png')).resolves.toMatchObject({ - ok: false, - status: 403, - reason: 'authorization-required' - }) - expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled() - - authorizeDocPreviewDirectory(grant.id, 'assets/logo.png') - mocks.readDocPreviewFile.mockResolvedValue({ content: 'logo', isBinary: false }) - - await expect(readDocPreviewFile(grant, 'assets/logo.png')).resolves.toMatchObject({ ok: true }) - expect(mocks.readDocPreviewFile).toHaveBeenCalledWith( - expect.objectContaining({ - boundaryPath: '/home/alice', - implicitRootPath: '/home/alice/docs', - authorizedRootPaths: ['/home/alice/assets'], - targetPath: '/home/alice/assets/logo.png' - }) - ) - }) - - it('reports an over-cap SSH file as too large rather than unreadable', async () => { - mocks.readDocPreviewFile.mockRejectedValue(new Error('file_too_large')) - - expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({ - ok: false, - status: 413 - }) - }) - - it('404s when the provider read fails', async () => { - mocks.readDocPreviewFile.mockRejectedValue(new Error('no such file')) - - expect(await readDocPreviewFile(sshGrant(), 'missing.html')).toMatchObject({ - ok: false, - status: 404 + status: 404, + message: 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' }) + expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled() }) }) diff --git a/src/main/browser/doc-preview-file-reader.ts b/src/main/browser/doc-preview-file-reader.ts index 3531d5065aa..959d314ca0f 100644 --- a/src/main/browser/doc-preview-file-reader.ts +++ b/src/main/browser/doc-preview-file-reader.ts @@ -3,9 +3,8 @@ import type { RuntimeFilePreviewResult } from '../../shared/runtime-file-contrac import type { DocPreviewFileFailureReason } from '../../shared/doc-preview-scheme' import { FONT_FILE_MIME_TYPES } from '../../shared/font-file-extensions' import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' -import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' import { getCanonicalUserDataPath } from '../persistence' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error' import { resolveDocPreviewAuthorityPaths, resolveDocPreviewCandidatePath, @@ -16,8 +15,6 @@ import { } from './doc-preview-grant-registry' const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000 -const DIRECT_SSH_DOC_PREVIEW_TEXT_MAX_BYTES = 10 * 1024 * 1024 -const DIRECT_SSH_DOC_PREVIEW_BINARY_MAX_BYTES = 10 * 1024 * 1024 /** Older hosts may refuse a binary format, so report the file type they cannot send. */ const UNSERVABLE_ASSET_PREVIEW_MESSAGE = 'This workspace cannot send this file type to a preview.' @@ -29,16 +26,12 @@ const TRUNCATED_PREVIEW_MESSAGE = 'This document is too large for the server to /** The paired host rejects an over-cap asset outright instead of clamping it. */ const RUNTIME_TOO_LARGE_ERROR = 'file_too_large' -/** Same stance as the SSH relay message: previews fail closed on a host without scoped reads. */ +/** Previews fail closed on a host without scoped reads. */ const RUNTIME_DOC_PREVIEW_UPDATE_REQUIRED_MESSAGE = 'Secure document previews require a newer Orca on the paired machine. Update it and try again.' -/** Both owners refuse an over-cap file; only their error shapes differ. */ function isTooLargeReadError(error: unknown): boolean { - return ( - error instanceof FileReadCapExceededError || - (error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR) - ) + return error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR } export type DocPreviewReadOutcome = @@ -168,23 +161,7 @@ export async function readDocPreviewFile( const contentType = docPreviewContentType(relativePath) try { if (grant.owner.kind === 'ssh') { - const provider = requireSshFilesystemProvider(grant.owner.connectionId) - const authority = resolveDocPreviewAuthorityPaths(grant) - if (!authority.entryPath || !provider.readDocPreviewFile) { - return notFoundOutcome() - } - return toOutcome( - await provider.readDocPreviewFile({ - boundaryPath: grant.requestBase, - entryPath: authority.entryPath, - implicitRootPath: authority.implicitRootPath, - authorizedRootPaths: authority.authorizedRootPaths, - targetPath: absolutePath, - maxTextBytes: DIRECT_SSH_DOC_PREVIEW_TEXT_MAX_BYTES, - maxBinaryBytes: DIRECT_SSH_DOC_PREVIEW_BINARY_MAX_BYTES - }), - contentType - ) + return notFoundOutcome(SSH_HOST_NOT_SERVED_MESSAGE) } const runtimeOwner = grant.owner const worktreeRelativePath = toRuntimeWorktreeRelativePath( diff --git a/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs index 799b6454624..310dbb822c7 100644 --- a/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs +++ b/src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs @@ -8,14 +8,21 @@ // { steps: Step[], controlResponses?: { [subtype]: }, // controlResponseLeadingFrames?: { [subtype]: [] } } where a Step is // { emit: } | { awaitUserMessage: true } | { stderr: } | -// { awaitControlResponse: } | { delayMs: } | { exit: } +// { awaitControlResponse: } | { awaitFile: { path, timeoutMs } } | +// { delayMs: } | { exit: } // ORCA_SDK_CONTRACT_REPORT_PATH — where argv/env observations are written // ORCA_SDK_CONTRACT_IGNORE_SIGTERM — trap SIGTERM/SIGINT and outlive stdin close // ORCA_SDK_CONTRACT_IGNORE_CONTROL_REQUESTS — record control requests but never answer // ORCA_SDK_CONTRACT_DESCENDANT — fork an idle grandchild and report its pid import { spawn } from 'node:child_process' -import { readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { createInterface } from 'node:readline' +import { setTimeout as delay } from 'node:timers/promises' + +if (process.argv.includes('--version')) { + process.stdout.write('2.1.258 (Claude Code)\n') + process.exit(0) +} const scenarioPath = process.env.ORCA_SDK_CONTRACT_SCENARIO_PATH const reportPath = process.env.ORCA_SDK_CONTRACT_REPORT_PATH @@ -23,6 +30,13 @@ const reportPath = process.env.ORCA_SDK_CONTRACT_REPORT_PATH const report = { argv: process.argv.slice(1), execPath: process.execPath, + cwd: process.cwd(), + env: Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => + key.startsWith('ORCA_') || ['HOME', 'CODEX_HOME', 'CLAUDE_CONFIG_DIR'].includes(key) + ) + ), controlRequests: [], controlResponses: [], userMessages: [], @@ -136,6 +150,14 @@ for (const step of scenario.steps) { await waitFor('user') } else if (step.awaitControlResponse !== undefined) { await waitFor('control_response', step.awaitControlResponse) + } else if (step.awaitFile) { + const deadline = Date.now() + step.awaitFile.timeoutMs + while (!existsSync(step.awaitFile.path)) { + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for test acknowledgement: ${step.awaitFile.path}`) + } + await delay(10) + } } else if (step.delayMs) { await new Promise((resolve) => setTimeout(resolve, step.delayMs)) } else if (step.exit !== undefined) { diff --git a/src/main/ephemeral-vm-runtime-ssh.test.ts b/src/main/ephemeral-vm-runtime-ssh.test.ts index 9e65afe6349..46f27166309 100644 --- a/src/main/ephemeral-vm-runtime-ssh.test.ts +++ b/src/main/ephemeral-vm-runtime-ssh.test.ts @@ -5,8 +5,10 @@ const mocks = vi.hoisted(() => ({ remove: vi.fn(async () => {}) })) +vi.mock('./ssh/ssh-target-registry', () => ({ + connectRegisteredSshTarget: mocks.connect +})) vi.mock('./ipc/ssh', () => ({ - connectRegisteredSshTarget: mocks.connect, getSshConnectionStore: () => ({ upsertRuntimeOwnedTarget: (runtimeId: string) => ({ id: `runtime-ssh-${runtimeId}`, diff --git a/src/main/ephemeral-vm-runtime-ssh.ts b/src/main/ephemeral-vm-runtime-ssh.ts index 63d23ff0094..6b6580ecae0 100644 --- a/src/main/ephemeral-vm-runtime-ssh.ts +++ b/src/main/ephemeral-vm-runtime-ssh.ts @@ -1,4 +1,5 @@ -import { connectRegisteredSshTarget, getSshConnectionStore } from './ipc/ssh' +import { getSshConnectionStore } from './ipc/ssh' +import { connectRegisteredSshTarget } from './ssh/ssh-target-registry' import { disconnectRegisteredSshTarget, removeRegisteredSshTarget diff --git a/src/main/git/git-capability-state.test.ts b/src/main/git/git-capability-state.test.ts index caaf4e4b4f9..09de7f19587 100644 --- a/src/main/git/git-capability-state.test.ts +++ b/src/main/git/git-capability-state.test.ts @@ -1,9 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshGitProvider } from '../providers/ssh-git-provider' import { clearGitCapabilityStateForTests, getLocalGitCapabilityCache, - getSshGitCapabilityCache, withLocalGitCapabilityCacheForExecution } from './git-capability-state' import { @@ -11,9 +9,6 @@ import { seedWslLinkedWorktreeGitRoutingForTests } from './wsl-linked-worktree-git-routing' -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cache keys providers by reference only and never calls a method on them. -const createProviderIdentity = (): SshGitProvider => ({}) as SshGitProvider - describe('Git capability execution-host state', () => { beforeEach(() => { clearGitCapabilityStateForTests() @@ -46,16 +41,6 @@ describe('Git capability execution-host state', () => { ).toBe(true) }) - it('shares one SSH provider lifetime without leaking into a replacement provider', () => { - const provider = createProviderIdentity() - const replacementProvider = createProviderIdentity() - - expect(getSshGitCapabilityCache(provider)).toBe(getSshGitCapabilityCache(provider)) - expect(getSshGitCapabilityCache(provider)).not.toBe( - getSshGitCapabilityCache(replacementProvider) - ) - }) - it('uses native capability state for a prepared host-routed WSL worktree', async () => { const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') try { diff --git a/src/main/git/git-capability-state.ts b/src/main/git/git-capability-state.ts index 11ca33790ce..d2369b22836 100644 --- a/src/main/git/git-capability-state.ts +++ b/src/main/git/git-capability-state.ts @@ -1,5 +1,4 @@ import { GitCapabilityCache } from '../../shared/git-capability-cache' -import type { SshGitProvider } from '../providers/ssh-git-provider' import { parseWslUncPath } from '../../shared/wsl-paths' import { isWslLinkedWorktreeGitRoutingCandidate, @@ -14,9 +13,6 @@ type LocalGitCapabilityTarget = { const localCapabilitiesByExecutionHost = new Map() const MAX_LOCAL_GIT_CAPABILITY_HOSTS = 128 -// Why: reconnecting creates a new provider, while concurrent IPC/runtime users -// of one SSH connection must share the same remote Git capability results. -let sshCapabilitiesByProvider = new WeakMap() function getLocalGitExecutionHostKey(target: LocalGitCapabilityTarget): string { const wslDistro = @@ -66,16 +62,6 @@ export function withLocalGitCapabilityCacheForExecution( ) } -export function getSshGitCapabilityCache(provider: SshGitProvider): GitCapabilityCache { - let cache = sshCapabilitiesByProvider.get(provider) - if (!cache) { - cache = new GitCapabilityCache() - sshCapabilitiesByProvider.set(provider, cache) - } - return cache -} - export function clearGitCapabilityStateForTests(): void { localCapabilitiesByExecutionHost.clear() - sshCapabilitiesByProvider = new WeakMap() } diff --git a/src/main/git/git-username.ts b/src/main/git/git-username.ts index e69d46d25f9..03b2a69d2ff 100644 --- a/src/main/git/git-username.ts +++ b/src/main/git/git-username.ts @@ -1,4 +1,3 @@ -import type { SshGitProvider } from '../providers/ssh-git-provider' import { extractExecError, ghExecFileAsync, gitExecFileAsync } from './runner' import { parseHostedRemote } from './hosted-remote-url' import { resolveDefaultBaseRefViaExec } from './repo' @@ -82,26 +81,6 @@ function normalizeConfiguredLogin(value: string): string { */ export type ResolvedGitUsername = { username: string; authoritative: boolean } -export async function getSshGitUsername( - provider: SshGitProvider, - repoPath: string -): Promise { - // Why: SSH targets cannot rely on the local `gh` account, and git email/name - // are author identity rather than hosted-account usernames. - for (const key of EXPLICIT_USERNAME_CONFIG_KEYS) { - try { - const { stdout } = await provider.exec(['config', '--get', key], repoPath) - const username = normalizeConfiguredLogin(stdout) - if (username) { - return username - } - } catch { - // Missing config keys are expected; try the next explicit username key. - } - } - return '' -} - type GhLoginProbeResult = { stdout: string; stderr: string; timedOut: boolean } type GhLoginOutcome = { login: string; timedOut: boolean } diff --git a/src/main/git/remote-ref-probe-cache.test.ts b/src/main/git/remote-ref-probe-cache.test.ts index c1f6b0f0981..3058facc9c0 100644 --- a/src/main/git/remote-ref-probe-cache.test.ts +++ b/src/main/git/remote-ref-probe-cache.test.ts @@ -1,17 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { getSshGitProviderMock, getSshGitProviderGenerationMock, gitExecFileAsyncMock } = vi.hoisted( - () => ({ - getSshGitProviderMock: vi.fn(), - getSshGitProviderGenerationMock: vi.fn(() => 0), - gitExecFileAsyncMock: vi.fn() - }) -) - -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: getSshGitProviderGenerationMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH Git provider unavailable' +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) @@ -35,9 +25,6 @@ function deferred(): { promise: Promise; resolve: (value: T) => void } { describe('remote ref probe cache (P1-D)', () => { beforeEach(() => { - getSshGitProviderMock.mockReset() - getSshGitProviderGenerationMock.mockReset() - getSshGitProviderGenerationMock.mockReturnValue(0) gitExecFileAsyncMock.mockReset() vi.useFakeTimers() vi.setSystemTime(1_000_000) @@ -89,27 +76,6 @@ describe('remote ref probe cache (P1-D)', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) - it('does not let a lookup on a reconnected provider join the old connection probe', async () => { - const cache = createRemoteRefProbeCache(parseExampleRef) - let releaseStalled = (): void => {} - const execMock = vi - .fn() - .mockImplementationOnce( - async () => await new Promise((resolve) => (releaseStalled = () => resolve({ stdout: '' }))) - ) - .mockResolvedValue({ stdout: 'git@example.com:team/repo.git\n' }) - getSshGitProviderMock.mockReturnValue({ exec: execMock }) - - const stalled = cache.get('/repo', 'origin', 'conn-1') - getSshGitProviderGenerationMock.mockReturnValue(1) - - await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toEqual({ repo: 'team/repo' }) - expect(execMock).toHaveBeenCalledTimes(2) - - releaseStalled() - await expect(stalled).resolves.toBeNull() - }) - it('does not let a probe abandoned as stale overwrite its successor answer', async () => { const cache = createRemoteRefProbeCache(parseExampleRef) const stalled = deferred<{ stdout: string }>() @@ -143,46 +109,12 @@ describe('remote ref probe cache (P1-D)', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) }) - it('never caches a miss from a runtime that could not be asked at all', async () => { + it('answers an SSH repo with null without running git on this machine', async () => { const cache = createRemoteRefProbeCache(parseExampleRef) - getSshGitProviderMock.mockReturnValueOnce(undefined) - - await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull() - - // The provider was gone, not the remote: reconnecting must not need the TTL. - getSshGitProviderMock.mockReturnValue({ - exec: vi.fn(async () => ({ stdout: 'git@example.com:team/repo.git\n' })) - }) - await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toEqual({ repo: 'team/repo' }) - }) - - it('holds an SSH repo that has no such remote instead of re-asking every poll', async () => { - const cache = createRemoteRefProbeCache(parseExampleRef) - const exec = vi.fn(async () => { - throw new Error("fatal: No such remote 'origin'") - }) - getSshGitProviderMock.mockReturnValue({ exec }) await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull() await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull() - expect(exec).toHaveBeenCalledTimes(1) - - vi.setSystemTime(1_000_000 + NEGATIVE_ENTRY_TTL_MS + 1) - await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull() - expect(exec).toHaveBeenCalledTimes(2) - }) - - it('keeps re-asking an SSH repo whose probe died with its transport', async () => { - const cache = createRemoteRefProbeCache(parseExampleRef) - const exec = vi - .fn() - .mockRejectedValueOnce(new Error('relay request failed: connection closed')) - .mockResolvedValue({ stdout: 'git@example.com:team/repo.git\n' }) - getSshGitProviderMock.mockReturnValue({ exec }) - - await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toBeNull() - await expect(cache.get('/repo', 'origin', 'conn-1')).resolves.toEqual({ repo: 'team/repo' }) - expect(exec).toHaveBeenCalledTimes(2) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) it('does not cache a probe killed on its deadline as a definitive miss', async () => { diff --git a/src/main/git/remote-ref-probe-cache.ts b/src/main/git/remote-ref-probe-cache.ts index 8f8255c1046..f69780c4f2b 100644 --- a/src/main/git/remote-ref-probe-cache.ts +++ b/src/main/git/remote-ref-probe-cache.ts @@ -1,7 +1,5 @@ -import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { runCoalescedProbe, type CoalescedProbes } from './coalesced-probe' import { isTransientGitProbeError, readRemoteUrl } from './remote-url-probe' -import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error' import type { GitAdmissionTier } from './command-runner/git-exec-options' /** @@ -86,10 +84,7 @@ export function createRemoteRefProbeCache( }, remoteName ) - // Why: null is the SSH runtime being disconnected, not an answer about the - // remote — and it costs no `git`, so there is nothing here to spare. It is - // deliberately the one negative with no TTL floor: flooring it would make a - // reconnected host wait the interval out for a probe it could serve now. + // Why: null is an SSH repo this process cannot read, not an answer about the remote. if (stdout === null) { return null } @@ -97,16 +92,10 @@ export function createRemoteRefProbeCache( publish(result) return result } catch (error) { - // Why: a probe killed on its deadline says nothing about the remote, and an - // SSH failure is usually a reconnect or tunnel state rather than an answer. - // Only "no such remote" is the repo itself saying it is not this provider's - // — anything else cached would poison it, on SSH for the generation's life. + // Why: a probe killed on its deadline says nothing about the remote. if (isTransientGitProbeError(error)) { return null } - if (connectionId && !isStableMissingGitRemoteError(error)) { - return null - } publish(null) return null } @@ -114,12 +103,7 @@ export function createRemoteRefProbeCache( return { async get(repoPath, remoteName, connectionId, localGitOptions = {}) { - // Why: a reconnect retires the connection an answer came from, and with it - // the probe still running on it — stamping the generation stops a caller on - // the new connection from adopting either. - const runtimeKey = connectionId - ? `${connectionId}:${getSshGitProviderGeneration(connectionId)}` - : `local:${localGitOptions.wslDistro ?? 'host'}` + const runtimeKey = connectionId || `local:${localGitOptions.wslDistro ?? 'host'}` const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}` const cached = repoRefCache.get(cacheKey) if (cached) { diff --git a/src/main/git/remote-url-probe.test.ts b/src/main/git/remote-url-probe.test.ts index a075f188b11..feb4ed4b1fd 100644 --- a/src/main/git/remote-url-probe.test.ts +++ b/src/main/git/remote-url-probe.test.ts @@ -1,16 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { getSshGitProviderMock, gitExecFileAsyncMock } = vi.hoisted(() => ({ - getSshGitProviderMock: vi.fn(), +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH Git provider unavailable', - sshGitProviderMissingError: () => new Error('SSH Git provider unavailable') -})) - vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' @@ -23,7 +16,6 @@ import { describe('remote URL probe', () => { beforeEach(() => { - getSshGitProviderMock.mockReset() gitExecFileAsyncMock.mockReset() }) @@ -48,27 +40,6 @@ describe('remote URL probe', () => { }) }) - it('bounds the SSH remote read with the same deadline as the local one', async () => { - const exec = vi.fn( - async (_args: string[], _cwd: string, _options?: { signal?: AbortSignal }) => ({ - stdout: 'git@github.com:acme/orca.git\n' - }) - ) - getSshGitProviderMock.mockReturnValue({ exec }) - - await expect( - readRemoteUrl({ repoPath: '/repo', connectionId: 'ssh-1' }, 'origin') - ).resolves.toContain('github.com') - - const [args, cwd, options] = exec.mock.calls[0] - expect(args).toEqual(['remote', 'get-url', 'origin']) - expect(cwd).toBe('/repo') - // The relay bounds each phase separately and resets on every frame, so only a - // signal spans the whole round trip. - expect(options?.signal).toBeInstanceOf(AbortSignal) - expect(options?.signal?.aborted).toBe(false) - }) - it('reports a probe cut off by its deadline as unavailable, not as an answer', async () => { const aborted = new Error('Request was cancelled') aborted.name = 'AbortError' @@ -93,8 +64,6 @@ describe('remote URL probe', () => { }) it('does not turn an SSH target into a false readable result', async () => { - getSshGitProviderMock.mockReturnValue({ exec: vi.fn() }) - await expect( assertRemoteUrlReadable({ repoPath: '/repo', connectionId: 'ssh-1' }) ).rejects.toThrow(SshHostNotServedError) diff --git a/src/main/git/remote-url-probe.ts b/src/main/git/remote-url-probe.ts index 8cc90896165..bd232a361b8 100644 --- a/src/main/git/remote-url-probe.ts +++ b/src/main/git/remote-url-probe.ts @@ -1,4 +1,3 @@ -import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { gitExecFileAsync } from './runner' import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error' import type { GitAdmissionTier } from './command-runner/git-exec-options' @@ -13,9 +12,7 @@ import { getConnectionExecutionHostId } from '../../shared/execution-host' * wedged host — a dead network mount or stalled WSL interop. Unbounded, the call * never returns and every caller above it hangs with it. Passing a timeout is * also what arms the runner's kill path; Node's own waits forever on a child - * that ignores signals. The SSH branch spends the same budget as one deadline - * over the whole round trip: the relay's own bounds are per-phase and restart on - * every frame, so a relay dribbling output outlives them. + * that ignores signals. */ export const REMOTE_URL_PROBE_TIMEOUT_MS = 30_000 @@ -26,20 +23,13 @@ export type RemoteUrlProbeContext = { admissionTier?: GitAdmissionTier } -/** Reads a remote URL, or null when the repo's SSH runtime is not connected. */ +/** Reads a remote URL, or null for an SSH repo, which this process does not serve. */ export async function readRemoteUrl( context: RemoteUrlProbeContext, remoteName: string ): Promise { if (context.connectionId) { - const provider = getSshGitProvider(context.connectionId) - if (!provider) { - return null - } - const { stdout } = await provider.exec(['remote', 'get-url', remoteName], context.repoPath, { - signal: AbortSignal.timeout(REMOTE_URL_PROBE_TIMEOUT_MS) - }) - return stdout + return null } const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], { cwd: context.repoPath, diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index f1bb563be62..a24490c592f 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -110,7 +110,7 @@ export async function getRecentDriftSubjects( } /** Parse `git remote` stdout into a remote count. */ -export function parseRemoteCount(stdout: string): number { +function parseRemoteCount(stdout: string): number { return stdout.split('\n').filter((line) => line.trim().length > 0).length } diff --git a/src/main/git/worktree-removal-real-git.test.ts b/src/main/git/worktree-removal-real-git.test.ts index 2df5f7fa46a..0f0641d2bf9 100644 --- a/src/main/git/worktree-removal-real-git.test.ts +++ b/src/main/git/worktree-removal-real-git.test.ts @@ -195,6 +195,7 @@ describe('worktree removal and the Node file pool', () => { sentinels.push(sentinel) } const checkoutDeleted = (): boolean => sentinels.every((path) => statSync(path).nlink === 1) + const deletionStarted = (): boolean => sentinels.some((path) => statSync(path).nlink === 1) const probePath = join(repoPath, 'seed.txt') let removalSettled = false @@ -207,6 +208,7 @@ describe('worktree removal and the Node file pool', () => { let maxQueued = 0 let maxStatMs = 0 let statCount = 0 + let statsDuringDeletion = 0 const sampler = setInterval(() => { maxQueued = Math.max(maxQueued, queuedFsRequests()) }, 1) @@ -214,9 +216,13 @@ describe('worktree removal and the Node file pool', () => { while (!removalSettled || !checkoutDeleted()) { expect(performance.now() - startedAt).toBeLessThan(60_000) maxQueued = Math.max(maxQueued, queuedFsRequests()) + const deletionInProgress = deletionStarted() && !checkoutDeleted() const statStartedAt = performance.now() await stat(probePath) maxStatMs = Math.max(maxStatMs, performance.now() - statStartedAt) + if (deletionInProgress && !checkoutDeleted()) { + statsDuringDeletion += 1 + } statCount += 1 } } finally { @@ -226,10 +232,10 @@ describe('worktree removal and the Node file pool', () => { await removal console.log( - `[pool] files=${POOL_FIXTURE_FILES} delete=${deletionMs.toFixed(0)}ms stats=${statCount} maxStat=${maxStatMs.toFixed(1)}ms maxQueuedFsRequests=${maxQueued}` + `[pool] files=${POOL_FIXTURE_FILES} delete=${deletionMs.toFixed(0)}ms stats=${statCount} statsDuringDeletion=${statsDuringDeletion} maxStat=${maxStatMs.toFixed(1)}ms maxQueuedFsRequests=${maxQueued}` ) expect(existsSync(worktreePath)).toBe(false) expect(maxQueued).toBeLessThan(32) - expect(maxStatMs).toBeLessThan(deletionMs / 4) + expect(statsDuringDeletion).toBeGreaterThan(0) }, 120_000) }) diff --git a/src/main/gitea/client-scan-invalidation.test.ts b/src/main/gitea/client-scan-invalidation.test.ts index b93bf4e1824..d25fcd07f24 100644 --- a/src/main/gitea/client-scan-invalidation.test.ts +++ b/src/main/gitea/client-scan-invalidation.test.ts @@ -6,10 +6,6 @@ vi.mock('../git/runner', () => ({ stderr: '' })) })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSSHGitProvider: vi.fn(() => undefined), - getSSHGitProviderGeneration: () => 0 -})) vi.mock('../source-control/pull-request-template', () => ({ readHostedPullRequestTemplate: vi.fn(async () => '') })) diff --git a/src/main/gitea/pull-request-creation.test.ts b/src/main/gitea/pull-request-creation.test.ts index a94123737d3..30a79586db9 100644 --- a/src/main/gitea/pull-request-creation.test.ts +++ b/src/main/gitea/pull-request-creation.test.ts @@ -2,20 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createGiteaPullRequest, isGiteaReviewCreationAuthenticated } from './pull-request-creation' import { _resetGiteaRepoRefCache } from './repository-ref' -const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn() +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: () => 0 -})) - vi.mock('../source-control/pull-request-template', () => ({ readHostedPullRequestTemplate: vi.fn(async () => 'Template body') })) @@ -27,7 +21,6 @@ describe('Gitea pull request creation', () => { beforeEach(() => { process.env = { ...OLD_ENV, ORCA_GITEA_TOKEN: 'gitea-token' } gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() gitExecFileAsyncMock.mockResolvedValue({ stdout: 'https://git.example.com/code/team/repo.git\n', stderr: '' @@ -102,46 +95,6 @@ describe('Gitea pull request creation', () => { expect(fetchMock).toHaveBeenCalledOnce() }) - it('resolves Gitea remotes through the SSH git provider', async () => { - const remoteGit = { - exec: vi.fn(async () => ({ - stdout: 'git@git.example.com:code/team/repo.git\n', - stderr: '' - })) - } - getSshGitProviderMock.mockReturnValue(remoteGit) - globalThis.fetch = vi.fn(async () => - Response.json({ - number: 14, - title: 'Remote Gitea create', - state: 'open', - html_url: 'https://git.example.com/code/team/repo/pulls/14', - updated_at: '2026-06-01T00:00:00Z', - mergeable: true - }) - ) as never - - await expect( - createGiteaPullRequest( - '/remote/repo', - { - provider: 'gitea', - base: 'main', - head: 'feature/gitea', - title: 'Remote Gitea create' - }, - 'ssh:ssh-1' - ) - ).resolves.toMatchObject({ - ok: true, - number: 14 - }) - expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo', { - signal: expect.any(AbortSignal) - }) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - it('classifies validation failures from the REST API', async () => { globalThis.fetch = vi.fn(async () => Response.json({ message: 'Validation failed' }, { status: 422 }) diff --git a/src/main/gitea/repository-ref.test.ts b/src/main/gitea/repository-ref.test.ts index ddd4076fcbd..b33a3f7abb0 100644 --- a/src/main/gitea/repository-ref.test.ts +++ b/src/main/gitea/repository-ref.test.ts @@ -1,8 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - sshExecMock: vi.fn() +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ @@ -13,23 +12,17 @@ import { _getGiteaRepoRefCacheSize, _resetGiteaRepoRefCache, getGiteaRepoRef, - getGiteaRepoRefForRemote, parseGiteaRepoRef } from './repository-ref' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' describe('Gitea repository ref parsing', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - sshExecMock.mockReset() - unregisterSshGitProvider('conn-1') _resetGiteaRepoRefCache() }) - afterEach(() => { - unregisterSshGitProvider('conn-1') - }) + afterEach(() => {}) it('parses HTTPS remotes and derives the API base URL', () => { expect(parseGiteaRepoRef('https://git.example.com/team/project.git')).toEqual({ @@ -139,23 +132,4 @@ describe('Gitea repository ref parsing', () => { expect(_getGiteaRepoRefCacheSize()).toBe(512) }) - - it('resolves repository refs through the SSH git provider for connected repos', async () => { - sshExecMock.mockResolvedValueOnce({ - stdout: 'git@gitea.example.test:remote/project.git\n', - stderr: '' - }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getGiteaRepoRefForRemote('/repo', 'origin', 'conn-1')).resolves.toMatchObject({ - host: 'gitea.example.test', - owner: 'remote', - repo: 'project' - }) - - expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', { - signal: expect.any(AbortSignal) - }) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index f2b9dc39ad1..6abfa5908cf 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { readFile } from 'node:fs/promises' const { ghExecFileAsyncMock, @@ -8,8 +7,7 @@ const { getEnterpriseGitHubRepoSlugMock, extractExecErrorMock, acquireMock, - releaseMock, - getSshFilesystemProviderMock + releaseMock } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), getOwnerRepoMock: vi.fn(), @@ -23,12 +21,7 @@ const { } }), acquireMock: vi.fn(), - releaseMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn() -})) - -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock + releaseMock: vi.fn() })) vi.mock('./gh-utils', () => ({ @@ -88,7 +81,6 @@ describe('createGitHubPullRequest', () => { extractExecErrorMock.mockClear() acquireMock.mockReset() releaseMock.mockReset() - getSshFilesystemProviderMock.mockReset() acquireMock.mockResolvedValue(undefined) }) @@ -354,76 +346,6 @@ describe('createGitHubPullRequest', () => { expect(options).not.toHaveProperty('cwd') }) - it.each([ - ['.github/pull_request_template.md', ['.github/pull_request_template.md']], - [ - '.github/PULL_REQUEST_TEMPLATE.md', - ['.github/pull_request_template.md', '.github/PULL_REQUEST_TEMPLATE.md'] - ], - [ - 'docs/PULL_REQUEST_TEMPLATE.md', - [ - '.github/pull_request_template.md', - '.github/PULL_REQUEST_TEMPLATE.md', - 'pull_request_template.md', - 'PULL_REQUEST_TEMPLATE.md', - 'docs/pull_request_template.md', - 'docs/PULL_REQUEST_TEMPLATE.md' - ] - ] - ] as [string, string[]][])( - 'reads PR templates from the SSH filesystem provider at %s', - async (relativeTemplatePath, expectedRelativeLookups) => { - const templateBody = `Remote template body from ${relativeTemplatePath}` - const readRemoteFile = vi.fn(async (path: string) => { - if (path === `/remote/repo-root/${relativeTemplatePath}`) { - return { - content: templateBody, - isBinary: false - } - } - throw new Error('missing template') - }) - getSshFilesystemProviderMock.mockReturnValue({ readFile: readRemoteFile }) - getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - const writtenBodies: string[] = [] - ghExecFileAsyncMock.mockImplementationOnce(async (args: string[]) => { - const bodyPath = args[args.indexOf('--body-file') + 1] - writtenBodies.push(await readFile(bodyPath, 'utf8')) - return { - stdout: JSON.stringify({ - number: 46, - url: 'https://github.com/acme/widgets/pull/46' - }) - } - }) - - await expect( - createGitHubPullRequest( - '/remote/repo-root', - { - provider: 'github', - base: 'main', - head: 'feature/ssh-template', - title: 'SSH Template PR', - body: '', - useTemplate: true - }, - 'ssh:ssh-1' - ) - ).resolves.toEqual({ - ok: true, - number: 46, - url: 'https://github.com/acme/widgets/pull/46' - }) - - expect(readRemoteFile.mock.calls.map(([path]) => path)).toEqual( - expectedRelativeLookups.map((relativeLookup) => `/remote/repo-root/${relativeLookup}`) - ) - expect(writtenBodies).toEqual([templateBody]) - } - ) - it('falls back to parsing the PR URL for older gh output', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ diff --git a/src/main/github/client-merge-queue-auto-merge.test.ts b/src/main/github/client-merge-queue-auto-merge.test.ts index f0d62ff75c6..60694245d48 100644 --- a/src/main/github/client-merge-queue-auto-merge.test.ts +++ b/src/main/github/client-merge-queue-auto-merge.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-merged-pr-visibility.test.ts b/src/main/github/client-merged-pr-visibility.test.ts index e24c903b891..6ec9cd5242a 100644 --- a/src/main/github/client-merged-pr-visibility.test.ts +++ b/src/main/github/client-merged-pr-visibility.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-pr-branch-discovery.test.ts b/src/main/github/client-pr-branch-discovery.test.ts index 3db1e981ed1..ca41a3361f3 100644 --- a/src/main/github/client-pr-branch-discovery.test.ts +++ b/src/main/github/client-pr-branch-discovery.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-pr-comment-reactions.test.ts b/src/main/github/client-pr-comment-reactions.test.ts index 4123dbc94a8..31436e42139 100644 --- a/src/main/github/client-pr-comment-reactions.test.ts +++ b/src/main/github/client-pr-comment-reactions.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-pr-conflict-summary.test.ts b/src/main/github/client-pr-conflict-summary.test.ts index 2df6b81b609..79455340ff7 100644 --- a/src/main/github/client-pr-conflict-summary.test.ts +++ b/src/main/github/client-pr-conflict-summary.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-pr-fallback-number.test.ts b/src/main/github/client-pr-fallback-number.test.ts index 397a22fa1b5..c3e6282347d 100644 --- a/src/main/github/client-pr-fallback-number.test.ts +++ b/src/main/github/client-pr-fallback-number.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-pr-linked-lookup.test.ts b/src/main/github/client-pr-linked-lookup.test.ts index a7ce6cce203..4e0d94da810 100644 --- a/src/main/github/client-pr-linked-lookup.test.ts +++ b/src/main/github/client-pr-linked-lookup.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-pr-local-runtime.test.ts b/src/main/github/client-pr-local-runtime.test.ts index 0633b137b1c..f9d7eb91111 100644 --- a/src/main/github/client-pr-local-runtime.test.ts +++ b/src/main/github/client-pr-local-runtime.test.ts @@ -84,11 +84,6 @@ vi.mock('./github-enterprise-repository', () => ({ isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true) })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProviderGeneration: vi.fn(() => 0), - getSshGitProvider: vi.fn() -})) - vi.mock('./conflict-summary', () => ({ getPRConflictSummary: vi.fn() })) diff --git a/src/main/github/client-pr-lookup-coalescing.test.ts b/src/main/github/client-pr-lookup-coalescing.test.ts index 6227f9ece75..28e159a3da9 100644 --- a/src/main/github/client-pr-lookup-coalescing.test.ts +++ b/src/main/github/client-pr-lookup-coalescing.test.ts @@ -28,7 +28,6 @@ vi.mock('./gh-utils', () => ({ vi.mock('./client/lookup/branch-lookup-resolution', () => ({ resolvePRForBranchOutcome: mocks.resolve })) -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProviderGeneration: () => 1 })) import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome' const outcome: PRRefreshOutcome = { kind: 'no-pr', fetchedAt: 1 } diff --git a/src/main/github/client-pr-push-target.test.ts b/src/main/github/client-pr-push-target.test.ts index 210cfbf6d51..1e13e41c3ec 100644 --- a/src/main/github/client-pr-push-target.test.ts +++ b/src/main/github/client-pr-push-target.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-pr-state.test.ts b/src/main/github/client-pr-state.test.ts index 3522d302206..50a7fc04f41 100644 --- a/src/main/github/client-pr-state.test.ts +++ b/src/main/github/client-pr-state.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-rate-limit-block.test.ts b/src/main/github/client-rate-limit-block.test.ts index a5070fbb4ba..54c695607ff 100644 --- a/src/main/github/client-rate-limit-block.test.ts +++ b/src/main/github/client-rate-limit-block.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-ssh-provider-execution-boundary.test.ts b/src/main/github/client-ssh-provider-execution-boundary.test.ts index 6b6ce2da5db..875fd10406d 100644 --- a/src/main/github/client-ssh-provider-execution-boundary.test.ts +++ b/src/main/github/client-ssh-provider-execution-boundary.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) @@ -34,7 +33,6 @@ const { ghExecFileAsyncMock, getOwnerRepoMock, gitExecFileAsyncMock, - getSshGitProviderMock, resolvePRRepositoryCandidatesMock } = clientMocks @@ -123,7 +121,6 @@ describe('getPRForBranch SSH execution boundary', () => { }) it('does not rev-parse HEAD locally when the SSH provider is unregistered', async () => { - getSshGitProviderMock.mockReturnValue(undefined) mockMergedBranchPRLookup() gitExecFileAsyncMock.mockResolvedValue({ stdout: `${MERGED_HEAD_OID}\n`, stderr: '' }) @@ -171,7 +168,6 @@ describe('getPRForBranch SSH execution boundary', () => { }) it('does not read tracked upstreams locally when the SSH provider is unregistered', async () => { - getSshGitProviderMock.mockReturnValue(undefined) mockUpstreamOnlyPRLookup() gitExecFileAsyncMock.mockResolvedValue({ stdout: `${MERGED_BRANCH}\0origin/contributor/original\n`, @@ -186,9 +182,6 @@ describe('getPRForBranch SSH execution boundary', () => { }) it('reports an upstream error when the SSH HEAD probe loses its provider mid-flight', async () => { - getSshGitProviderMock.mockReturnValue({ - exec: vi.fn().mockRejectedValue(new Error('SSH transport closed')) - }) mockMergedBranchPRLookup() const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1') @@ -198,9 +191,6 @@ describe('getPRForBranch SSH execution boundary', () => { }) it('reports an upstream error when the SSH upstream probe loses its provider mid-flight', async () => { - getSshGitProviderMock.mockReturnValue({ - exec: vi.fn().mockRejectedValue(new Error('SSH transport closed')) - }) mockUpstreamOnlyPRLookup() const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1') diff --git a/src/main/github/client-stack-merge-guard.test.ts b/src/main/github/client-stack-merge-guard.test.ts index ce2cdc540f3..859ef902d98 100644 --- a/src/main/github/client-stack-merge-guard.test.ts +++ b/src/main/github/client-stack-merge-guard.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-starred.test.ts b/src/main/github/client-starred.test.ts index 48a7f2d284e..3e3bf4357f3 100644 --- a/src/main/github/client-starred.test.ts +++ b/src/main/github/client-starred.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-test-harness.ts b/src/main/github/client-test-harness.ts index 645c4f8a0c5..ae8e3c8a692 100644 --- a/src/main/github/client-test-harness.ts +++ b/src/main/github/client-test-harness.ts @@ -41,7 +41,6 @@ export function resetPRForBranchMocks(mocks: GitHubClientMocks): void { mocks.noteRateLimitSpendMock.mockReset() mocks.ghRepoExecOptionsMock.mockClear() mocks.githubRepoContextMock.mockClear() - mocks.getSshGitProviderMock.mockReset() mocks.readLocalGitConfigSignatureMock.mockReset() mocks.readLocalGitConfigSignatureMock.mockResolvedValue(undefined) mocks.acquireMock.mockReset() diff --git a/src/main/github/client-test-mocks.ts b/src/main/github/client-test-mocks.ts index 15fe0f36214..4868f41fb11 100644 --- a/src/main/github/client-test-mocks.ts +++ b/src/main/github/client-test-mocks.ts @@ -23,7 +23,6 @@ export type GitHubClientMocks = { noteRateLimitSpendMock: Mock ghRepoExecOptionsMock: Mock githubRepoContextMock: Mock - getSshGitProviderMock: Mock readLocalGitConfigSignatureMock: Mock acquireMock: Mock releaseMock: Mock @@ -62,7 +61,6 @@ export function createGitHubClientMocks(): GitHubClientMocks { ...localGitOptions }) ), - getSshGitProviderMock: vi.fn(), readLocalGitConfigSignatureMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() @@ -131,22 +129,10 @@ export function gitRunnerModuleMock(mocks: GitHubClientMocks): GitRunnerModuleMo } } -export type SshGitDispatchModuleMock = { - getSshGitProviderGeneration: Mock - getSshGitProvider: Mock - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: string +export type LocalGitConfigSignatureModuleMock = { + readLocalGitConfigSignature: Mock } -export function sshGitDispatchModuleMock(mocks: GitHubClientMocks): SshGitDispatchModuleMock { - return { - getSshGitProviderGeneration: vi.fn(() => 0), - getSshGitProvider: mocks.getSshGitProviderMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.' - } -} - -export type LocalGitConfigSignatureModuleMock = { readLocalGitConfigSignature: Mock } - export function localGitConfigSignatureModuleMock( mocks: GitHubClientMocks ): LocalGitConfigSignatureModuleMock { diff --git a/src/main/github/client-tracked-upstream-default-branch.test.ts b/src/main/github/client-tracked-upstream-default-branch.test.ts index e92dfc76c48..3cf563fb61d 100644 --- a/src/main/github/client-tracked-upstream-default-branch.test.ts +++ b/src/main/github/client-tracked-upstream-default-branch.test.ts @@ -10,7 +10,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-tracked-upstream-fork-owner.test.ts b/src/main/github/client-tracked-upstream-fork-owner.test.ts index b46f9463256..617bbaff365 100644 --- a/src/main/github/client-tracked-upstream-fork-owner.test.ts +++ b/src/main/github/client-tracked-upstream-fork-owner.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client-tracked-upstream-snapshot.test.ts b/src/main/github/client-tracked-upstream-snapshot.test.ts index bc70d2ff01c..bb1d78a6212 100644 --- a/src/main/github/client-tracked-upstream-snapshot.test.ts +++ b/src/main/github/client-tracked-upstream-snapshot.test.ts @@ -9,7 +9,6 @@ const { clientMocks, moduleMocks } = await vi.hoisted(async () => { vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks)) vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks)) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks)) vi.mock('./local-git-config-signature', () => moduleMocks.localGitConfigSignatureModuleMock(clientMocks) ) diff --git a/src/main/github/client/check/get-pr-checks.ts b/src/main/github/client/check/get-pr-checks.ts index 0fc997c8793..d9ff4097ee0 100644 --- a/src/main/github/client/check/get-pr-checks.ts +++ b/src/main/github/client/check/get-pr-checks.ts @@ -1,5 +1,4 @@ import { runCoalescedProbe, type CoalescedProbes } from '../../../git/coalesced-probe' -import { getSshGitProviderGeneration } from '../../../providers/ssh-git-dispatch' import { githubReadExecutionScope } from '../../github-read-execution-scope' import type { PRCheckDetail } from '../../../../shared/github/check-types' import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items' @@ -253,7 +252,6 @@ export function getPRChecks( prRepo ?? null, Boolean(options?.noCache), connectionId ?? null, - connectionId ? getSshGitProviderGeneration(connectionId) : null, githubReadExecutionScope(localGitOptions) ]) return runCoalescedProbe(checksReads, key, () => diff --git a/src/main/github/client/create/pull-request-template.ts b/src/main/github/client/create/pull-request-template.ts index 5d60966a0c2..5ceceb74372 100644 --- a/src/main/github/client/create/pull-request-template.ts +++ b/src/main/github/client/create/pull-request-template.ts @@ -1,7 +1,5 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' -import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' -import { joinWorktreeRelativePath } from '../../../runtime/runtime-relative-paths' import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from '../../gh-utils' import { getHostedReviewLocalGitOptions, @@ -60,21 +58,11 @@ export async function readPullRequestTemplate( 'docs/pull_request_template.md', 'docs/PULL_REQUEST_TEMPLATE.md' ] - const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined - if (connectionId && !remoteProvider) { + if (connectionId) { return '' } for (const relativeCandidate of relativeCandidates) { try { - if (remoteProvider) { - const result = await remoteProvider.readFile( - joinWorktreeRelativePath(repoPath, relativeCandidate) - ) - if (result.isBinary) { - continue - } - return result.content - } return await readFile(join(repoPath, relativeCandidate), 'utf8') } catch { // Try the next conventional PR template path. diff --git a/src/main/github/client/lookup/pr-for-branch-outcome.ts b/src/main/github/client/lookup/pr-for-branch-outcome.ts index 9de76705737..33e741b4c24 100644 --- a/src/main/github/client/lookup/pr-for-branch-outcome.ts +++ b/src/main/github/client/lookup/pr-for-branch-outcome.ts @@ -4,7 +4,6 @@ import { scopeGeneration } from '../../../source-control/hosted-review-scope-generations' import { runCoalescedProbe, type CoalescedProbes } from '../../../git/coalesced-probe' -import { getSshGitProviderGeneration } from '../../../providers/ssh-git-dispatch' import { githubReadExecutionScope } from '../../github-read-execution-scope' import type { PRRefreshOutcome } from '../../../../shared/github/pull-request-refresh-types' import { acquire, release, ghRepoExecOptions, githubRepoContext } from '../../gh-utils' @@ -35,7 +34,6 @@ export async function getPRForBranchOutcome( const key = JSON.stringify([ executionScope, - connectionId ? getSshGitProviderGeneration(connectionId) : null, repoPath, scopeGeneration(hostedReviewRepoScope(repoPath, getRepoExecutionHostId({ connectionId }))), branchName, diff --git a/src/main/github/default-branch-stale-pr.test.ts b/src/main/github/default-branch-stale-pr.test.ts index c4faa88a528..803d02d3f72 100644 --- a/src/main/github/default-branch-stale-pr.test.ts +++ b/src/main/github/default-branch-stale-pr.test.ts @@ -35,7 +35,6 @@ const { noteRepositoryRateLimitSpendMock, ghRepoExecOptionsMock, githubRepoContextMock, - getSshGitProviderMock, readLocalGitConfigSignatureMock, acquireMock, releaseMock @@ -67,7 +66,6 @@ const { connectionId: connectionId ?? null, ...localGitOptions })), - getSshGitProviderMock: vi.fn(), readLocalGitConfigSignatureMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() @@ -107,10 +105,6 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock -})) - vi.mock('./local-git-config-signature', () => ({ readLocalGitConfigSignature: readLocalGitConfigSignatureMock })) @@ -221,7 +215,6 @@ describe('issue #9171: default-branch checkout must not attach a stale non-open noteRateLimitSpendMock.mockReset() ghRepoExecOptionsMock.mockClear() githubRepoContextMock.mockClear() - getSshGitProviderMock.mockReset() readLocalGitConfigSignatureMock.mockReset() readLocalGitConfigSignatureMock.mockResolvedValue(undefined) acquireMock.mockReset() diff --git a/src/main/github/gh-utils.test.ts b/src/main/github/gh-utils.test.ts index c239dc026f2..5ccddbd57fe 100644 --- a/src/main/github/gh-utils.test.ts +++ b/src/main/github/gh-utils.test.ts @@ -3,24 +3,15 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, getSshGitProviderGenerationMock, getSshGitProviderMock } = vi.hoisted( - () => ({ - gitExecFileAsyncMock: vi.fn(), - getSshGitProviderGenerationMock: vi.fn(() => 0), - getSshGitProviderMock: vi.fn() - }) -) +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, ghExecFileAsync: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProviderGeneration: getSshGitProviderGenerationMock, - getSshGitProvider: getSshGitProviderMock -})) - import { _resetRemoteNameListingCache } from '../git/remote-name-listing' import { _getOwnerRepoCacheSize, @@ -73,9 +64,6 @@ function gitRemoteListCalls(): unknown[][] { describe('github owner/repo resolution', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - getSshGitProviderGenerationMock.mockReset() - getSshGitProviderGenerationMock.mockReturnValue(0) - getSshGitProviderMock.mockReset() _resetOwnerRepoCache() _resetRemoteNameListingCache() __resetLocalGitConfigSignatureCacheForTests() @@ -200,52 +188,6 @@ describe('github owner/repo resolution', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) - it('resolves SSH repo remotes through the registered SSH git provider', async () => { - const sshProvider = { - exec: vi.fn(async (args: string[]) => { - if (args[0] === 'remote' && args[1] !== 'get-url') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[2] === 'upstream') { - throw new Error("fatal: No such remote 'upstream'") - } - return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' } - }) - } - getSshGitProviderMock.mockReturnValue(sshProvider) - - await expect(getOwnerRepo('/home/user/orca', 'openclaw-2')).resolves.toEqual({ - owner: 'stablyai', - repo: 'orca' - }) - - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - expect(getSshGitProviderMock).toHaveBeenCalledWith('openclaw-2') - expect(sshProvider.exec).toHaveBeenCalledWith( - ['remote', 'get-url', 'origin'], - '/home/user/orca', - { - signal: expect.any(AbortSignal) - } - ) - }) - - it('keeps local and SSH owner/repo cache entries separate for the same path', async () => { - const sshProvider = { - exec: vi.fn(async (args: string[]) => { - if (args[0] === 'remote' && args[1] !== 'get-url') { - return { stdout: 'origin\n', stderr: '' } - } - return { stdout: 'git@github.com:remote/orca.git\n', stderr: '' } - }) - } - mockGitRemoteCommands({ origin: 'git@github.com:local/orca.git\n' }) - getSshGitProviderMock.mockReturnValue(sshProvider) - - await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'local', repo: 'orca' }) - await expect(getOwnerRepo('/repo', 'ssh-1')).resolves.toEqual({ owner: 'remote', repo: 'orca' }) - }) - it('keeps local host and local WSL owner/repo cache entries separate for the same path', async () => { gitExecFileAsyncMock.mockImplementation( async (args: string[], options: { wslDistro?: string } = {}) => { @@ -747,7 +689,6 @@ describe('github owner/repo resolution', () => { describe('resolveIssueSource', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() _resetOwnerRepoCache() _resetRemoteNameListingCache() }) diff --git a/src/main/github/github-api-repository-probe.ts b/src/main/github/github-api-repository-probe.ts index 08d0c5fd6e3..569f4153bb0 100644 --- a/src/main/github/github-api-repository-probe.ts +++ b/src/main/github/github-api-repository-probe.ts @@ -1,5 +1,4 @@ import type { LocalGitExecOptions } from './github-repository-identity' -import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' export function githubApiRepositoryProbeCacheKey( repoPath: string, @@ -9,7 +8,7 @@ export function githubApiRepositoryProbeCacheKey( requireVerifiedSshProbe: boolean ): string { const runtimeKey = connectionId - ? `ssh:${connectionId}:${getSshGitProviderGeneration(connectionId)}` + ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}` return `${runtimeKey}\0${repoPath}\0${remoteName}\0${requireVerifiedSshProbe ? 'verified' : 'tolerant'}` } diff --git a/src/main/github/github-api-repository.test.ts b/src/main/github/github-api-repository.test.ts index b9fc0f159f2..a660ff71c93 100644 --- a/src/main/github/github-api-repository.test.ts +++ b/src/main/github/github-api-repository.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as SshGitDispatch from '../providers/ssh-git-dispatch' import type * as GitHubEnterpriseRepository from './github-enterprise-repository' import type * as GhUtils from './gh-utils' @@ -7,23 +6,16 @@ const { getEnterpriseGitHubRepoSlugMock, getOwnerRepoMock, getOwnerRepoForRemoteMock, - getSshGitProviderGenerationMock, isGitHubHostAuthenticatedMock, shouldProbeGitRemoteMock } = vi.hoisted(() => ({ getEnterpriseGitHubRepoSlugMock: vi.fn(), getOwnerRepoMock: vi.fn(), getOwnerRepoForRemoteMock: vi.fn(), - getSshGitProviderGenerationMock: vi.fn(() => 0), isGitHubHostAuthenticatedMock: vi.fn(), shouldProbeGitRemoteMock: vi.fn(async () => true) })) -vi.mock('../providers/ssh-git-dispatch', async (importOriginal) => ({ - ...(await importOriginal()), - getSshGitProviderGeneration: getSshGitProviderGenerationMock -})) - vi.mock('./gh-utils', async (importOriginal) => ({ ...(await importOriginal()), getOwnerRepo: getOwnerRepoMock, @@ -57,7 +49,6 @@ beforeEach(() => { getEnterpriseGitHubRepoSlugMock.mockReset().mockResolvedValue(null) getOwnerRepoMock.mockReset().mockResolvedValue(null) getOwnerRepoForRemoteMock.mockReset().mockResolvedValue(null) - getSshGitProviderGenerationMock.mockReset().mockReturnValue(0) isGitHubHostAuthenticatedMock.mockReset().mockResolvedValue(false) shouldProbeGitRemoteMock.mockReset().mockResolvedValue(true) }) @@ -204,23 +195,6 @@ describe('resolveGitHubRepoExecution', () => { }) describe('origin repository cache', () => { - it('isolates Enterprise identity across SSH provider generations', async () => { - const beforeReconnect = { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' } - const afterReconnect = { owner: 'acme', repo: 'other', host: 'github.acme-corp.com' } - getEnterpriseGitHubRepoSlugMock - .mockResolvedValueOnce(beforeReconnect) - .mockResolvedValueOnce(afterReconnect) - - await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toEqual( - beforeReconnect - ) - getSshGitProviderGenerationMock.mockReturnValue(1) - await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toEqual( - afterReconnect - ) - expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2) - }) - it('keeps an indeterminate auth inventory unverifiable during candidate discovery', async () => { getEnterpriseGitHubRepoSlugMock.mockResolvedValue(undefined) @@ -280,83 +254,6 @@ describe('origin repository cache', () => { await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull() expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) }) - - it('does not reuse a cached answer after the SSH provider reconnects', async () => { - const oldRepository = { - owner: 'old-owner', - repo: 'widgets', - host: 'github.acme-corp.com' - } - const newRepository = { - owner: 'new-owner', - repo: 'widgets', - host: 'github.acme-corp.com' - } - getSshGitProviderGenerationMock.mockReturnValue(1) - getEnterpriseGitHubRepoSlugMock - .mockResolvedValueOnce(oldRepository) - .mockResolvedValueOnce(newRepository) - - await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(oldRepository) - await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(oldRepository) - expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) - - getSshGitProviderGenerationMock.mockReturnValue(2) - await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(newRepository) - expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2) - }) - - it('does not reuse a cached negative after the SSH provider reconnects', async () => { - const repository = { - owner: 'acme', - repo: 'widgets', - host: 'github.acme-corp.com' - } - getSshGitProviderGenerationMock.mockReturnValue(1) - getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce(null).mockResolvedValueOnce(repository) - - await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toBeNull() - await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toBeNull() - expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) - - getSshGitProviderGenerationMock.mockReturnValue(2) - await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(repository) - expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2) - }) - - it('isolates an in-flight probe from an older SSH provider generation', async () => { - const oldRepository = { - owner: 'old-owner', - repo: 'widgets', - host: 'github.acme-corp.com' - } - const newRepository = { - owner: 'new-owner', - repo: 'widgets', - host: 'github.acme-corp.com' - } - let resolveOldProbe: (repository: typeof oldRepository) => void = () => {} - const oldProbeResult = new Promise((resolve) => { - resolveOldProbe = resolve - }) - getEnterpriseGitHubRepoSlugMock - .mockImplementationOnce(() => oldProbeResult) - .mockResolvedValueOnce(newRepository) - getSshGitProviderGenerationMock.mockReturnValue(1) - - const oldProbe = getOriginGitHubApiRepository('/repo', 'ssh-1') - await vi.waitFor(() => expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1)) - - getSshGitProviderGenerationMock.mockReturnValue(2) - const newProbe = getOriginGitHubApiRepository('/repo', 'ssh-1') - await vi.waitFor(() => expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)) - await expect(newProbe).resolves.toEqual(newRepository) - - resolveOldProbe(oldRepository) - await expect(oldProbe).resolves.toEqual(oldRepository) - await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(newRepository) - expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2) - }) }) describe('skip missing upstream remote probes', () => { diff --git a/src/main/github/github-enterprise-repository.test.ts b/src/main/github/github-enterprise-repository.test.ts index c6605be58c5..d6db52e01b9 100644 --- a/src/main/github/github-enterprise-repository.test.ts +++ b/src/main/github/github-enterprise-repository.test.ts @@ -27,7 +27,6 @@ import { isGitHubHostAuthenticatedForGlobalCli } from './github-enterprise-repository' import { _resetSshHostnameResolutionCache } from './github-ssh-host-alias-resolution' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' function mockOriginRemote(url: string): void { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -437,74 +436,6 @@ describe('isGitHubHostAuthenticated', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) - it('does not reuse a cached answer after the SSH provider reconnects', async () => { - const connectionId = 'ssh-reconnected-auth-cache' - ghExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: - 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)', - stderr: '' - }) - .mockResolvedValueOnce({ - stdout: 'github.com\n ✓ Logged in to github.com account kelora (keyring)', - stderr: '' - }) - - registerSshGitProvider(connectionId, {} as never) - try { - await expect( - isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId) - ).resolves.toBe(true) - await expect( - isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId) - ).resolves.toBe(true) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - - registerSshGitProvider(connectionId, {} as never) - await expect( - isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId) - ).resolves.toBe(false) - } finally { - unregisterSshGitProvider(connectionId) - } - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - }) - - it('does not join an in-flight auth probe from an older SSH provider generation', async () => { - const connectionId = 'ssh-reconnected-auth-in-flight' - let finishOldProbe: (() => void) | undefined - ghExecFileAsyncMock - .mockImplementationOnce( - () => - new Promise((resolve) => { - finishOldProbe = () => - resolve({ - stdout: - 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)', - stderr: '' - }) - }) - ) - .mockResolvedValueOnce({ - stdout: 'github.com\n ✓ Logged in to github.com account kelora (keyring)', - stderr: '' - }) - - registerSshGitProvider(connectionId, {} as never) - try { - const oldProbe = isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId) - registerSshGitProvider(connectionId, {} as never) - const newProbe = isGitHubHostAuthenticated('github.acme-corp.com', '/repo', connectionId) - - await expect(newProbe).resolves.toBe(false) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - finishOldProbe?.() - await expect(oldProbe).resolves.toBe(true) - } finally { - unregisterSshGitProvider(connectionId) - } - }) - // The probe answers differ by runtime: a local repo gets a cwd, a // connection-backed repo does not. Sharing one entry attributes whichever // resolved first to both hosts. diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts index 6e356665e5a..83bd264baee 100644 --- a/src/main/github/github-enterprise-repository.ts +++ b/src/main/github/github-enterprise-repository.ts @@ -19,7 +19,6 @@ import { } from './github-remote-identity-parsing' import { resolveSshConfigHostname } from './github-ssh-host-alias-resolution' import { parseWslPath } from '../wsl' -import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { isStableMissingGitRemoteError } from '../git/stable-missing-git-remote-error' import { requireReachableGitRoute } from '../providers/execution-host-provider-dispatch' import { getConnectionExecutionHostId } from '../../shared/execution-host' @@ -46,14 +45,13 @@ const hostAuthInFlight = new Map>() // repos, but the answer still describes that connection's runtime (it is probed // without the repository cwd). Keying it per connection stops a local repo and // an SSH-hosted repo at the same path from adopting each other's auth state. -// Provider generation fences stale answers when reconnect replaces the runtime. function runtimeCacheKey( repoPath: string, connectionId?: string | null, wslDistro?: string ): string { if (connectionId) { - return `connection:${connectionId}:${getSshGitProviderGeneration(connectionId)}` + return `connection:${connectionId}` } const resolvedDistro = wslDistro ?? parseWslPath(repoPath)?.distro return `local:${resolvedDistro?.toLowerCase() ?? 'host'}` diff --git a/src/main/github/github-repository-identity.fork-owner-repo.test.ts b/src/main/github/github-repository-identity.fork-owner-repo.test.ts index 4eef43a27af..67657046933 100644 --- a/src/main/github/github-repository-identity.fork-owner-repo.test.ts +++ b/src/main/github/github-repository-identity.fork-owner-repo.test.ts @@ -20,10 +20,6 @@ vi.mock('../git/runner', async (importOriginal) => ({ ghExecFileAsync: ghExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: () => null -})) - vi.mock('./local-git-config-signature', () => ({ readLocalGitConfigSignature: readLocalGitConfigSignatureMock })) diff --git a/src/main/github/github-repository-identity.signed-cache.test.ts b/src/main/github/github-repository-identity.signed-cache.test.ts index 0b11e2f8331..862b0673308 100644 --- a/src/main/github/github-repository-identity.signed-cache.test.ts +++ b/src/main/github/github-repository-identity.signed-cache.test.ts @@ -13,12 +13,6 @@ vi.mock('../git/runner', async (importOriginal) => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: () => null, - getSshGitProviderGeneration: () => 0, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'ssh git provider unavailable' -})) - vi.mock('./local-git-config-signature', () => ({ readLocalGitConfigSignature: readLocalGitConfigSignatureMock })) diff --git a/src/main/github/github-repository-identity.ssh-host-alias.test.ts b/src/main/github/github-repository-identity.ssh-host-alias.test.ts index bd3a2dab6a6..5b718e397ab 100644 --- a/src/main/github/github-repository-identity.ssh-host-alias.test.ts +++ b/src/main/github/github-repository-identity.ssh-host-alias.test.ts @@ -3,15 +3,11 @@ import type * as GitRunner from '../git/runner' const { commandExecFileAsyncMock, - getSshGitProviderGenerationMock, - getSshGitProviderMock, gitExecFileAsyncMock, resolveWithSshGMock, readLocalGitConfigSignatureMock } = vi.hoisted(() => ({ commandExecFileAsyncMock: vi.fn(), - getSshGitProviderGenerationMock: vi.fn(() => 0), - getSshGitProviderMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), resolveWithSshGMock: vi.fn(), readLocalGitConfigSignatureMock: vi.fn(async () => 'sig-10284') @@ -23,13 +19,6 @@ vi.mock('../git/runner', async (importOriginal) => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: getSshGitProviderGenerationMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.', - sshGitProviderMissingError: () => new Error('Remote connection dropped.') -})) - vi.mock('./local-git-config-signature', () => ({ readLocalGitConfigSignature: readLocalGitConfigSignatureMock })) @@ -73,29 +62,10 @@ function mockRemoteUrl(url: string): void { }) } -function sshProvider(hostname: string, remoteUrl = 'git@github-work:team/orca.git') { - return { - exec: vi.fn().mockResolvedValue({ - stdout: `${remoteUrl}\n`, - stderr: '' - }), - execNonInteractive: vi.fn().mockResolvedValue({ - stdout: `hostname ${hostname}\nport 22\n`, - stderr: '', - exitCode: 0, - timedOut: false, - canceled: false - }) - } -} - beforeEach(() => { _resetOwnerRepoCache() _resetSshHostnameResolutionCache() commandExecFileAsyncMock.mockReset() - getSshGitProviderGenerationMock.mockReset() - getSshGitProviderGenerationMock.mockReturnValue(0) - getSshGitProviderMock.mockReset() gitExecFileAsyncMock.mockReset() resolveWithSshGMock.mockReset() readLocalGitConfigSignatureMock.mockClear() @@ -158,27 +128,7 @@ describe('#10284 SSH Host alias → github.com owner/repo', () => { expect(resolveWithSshGMock).not.toHaveBeenCalled() }) - it('resolves aliases inside the repository SSH runtime', async () => { - const provider = sshProvider('github.com') - getSshGitProviderMock.mockReturnValue(provider) - getSshGitProviderGenerationMock.mockReturnValue(4) - - await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({ - owner: 'team', - repo: 'orca' - }) - expect(provider.execNonInteractive).toHaveBeenCalledWith( - 'ssh', - ['-G', '--', 'github-work'], - '/remote/repo', - 5_000 - ) - expect(resolveWithSshGMock).not.toHaveBeenCalled() - }) - it('does not read an SSH repoPath locally when its provider is missing', async () => { - getSshGitProviderMock.mockReturnValue(undefined) - await expect( getOwnerRepoForRemote( '/remote/repo', @@ -213,43 +163,6 @@ describe('#10284 SSH Host alias → github.com owner/repo', () => { expect(commandExecFileAsyncMock).toHaveBeenCalledTimes(1) }) - it('invalidates alias resolution when an SSH provider reconnects', async () => { - const context = { repoPath: '/remote/repo', connectionId: 'ssh-1' } - getSshGitProviderGenerationMock.mockReturnValue(1) - getSshGitProviderMock.mockReturnValue(sshProvider('github.com')) - - await expect( - resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', context) - ).resolves.toEqual({ owner: 'team', repo: 'orca' }) - - getSshGitProviderGenerationMock.mockReturnValue(2) - getSshGitProviderMock.mockReturnValue(sshProvider('gitlab.com')) - await expect( - resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', context) - ).resolves.toBeNull() - }) - - it('invalidates owner/repo identity when an SSH provider reconnects', async () => { - getSshGitProviderGenerationMock.mockReturnValue(1) - getSshGitProviderMock.mockReturnValue( - sshProvider('github.com', 'git@github-work:team/orca.git') - ) - - await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({ - owner: 'team', - repo: 'orca' - }) - - getSshGitProviderGenerationMock.mockReturnValue(2) - getSshGitProviderMock.mockReturnValue( - sshProvider('github.com', 'git@github-work:acme/widgets.git') - ) - await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({ - owner: 'acme', - repo: 'widgets' - }) - }) - it('does not call ssh -G for literal github.com remotes', async () => { mockRemoteUrl('git@github.com:team/orca.git') diff --git a/src/main/github/github-repository-identity.ts b/src/main/github/github-repository-identity.ts index 26b03d768d0..98d835568cf 100644 --- a/src/main/github/github-repository-identity.ts +++ b/src/main/github/github-repository-identity.ts @@ -2,7 +2,6 @@ import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe' import { readRemoteUrl } from '../git/remote-url-probe' import type { GhAccountBinding } from '../../shared/github/account-binding' import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' -import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { readLocalGitConfigSignature } from './local-git-config-signature' import { parseGitHubOwnerRepo, @@ -138,7 +137,7 @@ export async function getOwnerRepoForRemote( requireReachableGitRoute(getConnectionExecutionHostId(context.connectionId)) } const runtimeKey = context.connectionId - ? `ssh:${context.connectionId}:${getSshGitProviderGeneration(context.connectionId)}` + ? `ssh:${context.connectionId}` : `local:${context.wslDistro ?? 'host'}` const cacheKey = `${runtimeKey}\0${context.repoPath}\0${remoteName}` const now = Date.now() diff --git a/src/main/github/github-ssh-host-alias-resolution.ts b/src/main/github/github-ssh-host-alias-resolution.ts index 82c0f8b3fb4..8decd2ec8d0 100644 --- a/src/main/github/github-ssh-host-alias-resolution.ts +++ b/src/main/github/github-ssh-host-alias-resolution.ts @@ -1,6 +1,5 @@ import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' import { commandExecFileAsync } from '../git/runner' -import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { parseWslPath } from '../wsl' import { resolveWithSshG } from '../ssh/ssh-g-config-resolution' import { @@ -58,8 +57,7 @@ function pruneSshHostnameCache(now: number): void { function sshRuntimeCacheKey(context: SshConfigResolutionContext): string { if (context.connectionId) { - const generation = getSshGitProviderGeneration(context.connectionId) - return `ssh:${context.connectionId}:${generation}` + return `ssh:${context.connectionId}` } const distro = context.wslDistro ?? parseWslPath(context.repoPath)?.distro return `local:${distro?.toLowerCase() ?? 'host'}` @@ -80,23 +78,7 @@ async function resolveSshHostnameInRuntime( context: SshConfigResolutionContext ): Promise { if (context.connectionId) { - const provider = getSshGitProvider(context.connectionId) - if (!provider) { - return null - } - try { - const result = await provider.execNonInteractive( - 'ssh', - ['-G', '--', host], - context.repoPath, - SSH_G_TIMEOUT_MS - ) - return result.exitCode === 0 && !result.timedOut && !result.canceled - ? parseSshGHostname(result.stdout) - : null - } catch { - return null - } + return null } const wslDistro = context.wslDistro ?? parseWslPath(context.repoPath)?.distro diff --git a/src/main/github/pr-head-tracking-ref.test.ts b/src/main/github/pr-head-tracking-ref.test.ts index c70576f772f..df70d6ba060 100644 --- a/src/main/github/pr-head-tracking-ref.test.ts +++ b/src/main/github/pr-head-tracking-ref.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshGitProvider } from '../providers/ssh-git-provider' const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) @@ -26,7 +25,7 @@ describe('fetchPrHeadTrackingRef', () => { }) it('fetches into the remote-tracking ref with real git for local repos', async () => { - await fetchPrHeadTrackingRef({ path: '/repo', connectionId: null }, null, 'origin', 'feature/x') + await fetchPrHeadTrackingRef({ path: '/repo', connectionId: null }, 'origin', 'feature/x') expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['fetch', 'origin', '+refs/heads/feature/x:refs/remotes/origin/feature/x'], @@ -34,28 +33,9 @@ describe('fetchPrHeadTrackingRef', () => { ) }) - it('uses the SSH tracking-ref RPC for connected repos and never runs git directly', async () => { - const fetchRemoteTrackingRef = vi.fn(async () => {}) - - await fetchPrHeadTrackingRef( - { path: '/repo', connectionId: 'conn-1' }, - { fetchRemoteTrackingRef } as unknown as SshGitProvider, - 'origin', - 'feature/x' - ) - - expect(fetchRemoteTrackingRef).toHaveBeenCalledWith( - '/repo', - 'origin', - 'feature/x', - 'refs/remotes/origin/feature/x' - ) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('throws when a connected repo has no available SSH provider', async () => { + it('throws for an SSH repo, which this process does not serve', async () => { await expect( - fetchPrHeadTrackingRef({ path: '/repo', connectionId: 'conn-1' }, null, 'origin', 'feature/x') + fetchPrHeadTrackingRef({ path: '/repo', connectionId: 'conn-1' }, 'origin', 'feature/x') ).rejects.toThrow('SSH Git provider is not available') expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -63,7 +43,6 @@ describe('fetchPrHeadTrackingRef', () => { it('fetches a GitHub pull head into its remote-scoped Orca ref for local repos', async () => { const localRef = await fetchGitHubPullRequestHeadRef( { path: '/repo', connectionId: null }, - null, 'origin', 42 ) @@ -86,7 +65,7 @@ describe('fetchPrHeadTrackingRef', () => { }) await expect( - fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, null, 'origin', 42) + fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 42) ).rejects.toThrow('Remote "origin" is not configured.') expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( expect.arrayContaining(['fetch']), @@ -95,7 +74,7 @@ describe('fetchPrHeadTrackingRef', () => { }) it('keeps WSL routing while bounding the pull-head fetch', async () => { - await fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, null, 'origin', 42, { + await fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 42, { localGitExecOptions: { cwd: '/repo', wslDistro: 'Ubuntu' } }) @@ -109,40 +88,19 @@ describe('fetchPrHeadTrackingRef', () => { ) }) - it('uses the SSH GitHub pull-head RPC and never runs git directly', async () => { - const expectedRef = `refs/orca/pull/${ORIGIN_COMPONENT}/42` - const fetchGitHubPullRequestHead = vi.fn(async () => expectedRef) - - const localRef = await fetchGitHubPullRequestHeadRef( - { path: '/repo', connectionId: 'conn-1' }, - { fetchGitHubPullRequestHead } as unknown as SshGitProvider, - 'origin', - 42 - ) - - expect(fetchGitHubPullRequestHead).toHaveBeenCalledWith('/repo', 'origin', 42) - expect(localRef).toBe(expectedRef) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('rejects a connected GitHub pull-head fetch without an SSH provider', async () => { + it('rejects a GitHub pull-head fetch for an SSH repo', async () => { await expect( - fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: 'conn-1' }, null, 'origin', 42) + fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: 'conn-1' }, 'origin', 42) ).rejects.toThrow('SSH Git provider is not available') expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) it('rejects invalid PR numbers and option-shaped remotes before running git', async () => { await expect( - fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, null, 'origin', 4.2) + fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 4.2) ).rejects.toThrow('Invalid pull request number') await expect( - fetchGitHubPullRequestHeadRef( - { path: '/repo', connectionId: null }, - null, - '--upload-pack=x', - 42 - ) + fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, '--upload-pack=x', 42) ).rejects.toThrow('must not start with "-"') expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) diff --git a/src/main/github/pr-head-tracking-ref.ts b/src/main/github/pr-head-tracking-ref.ts index 9ffa7374225..f74057aa3e0 100644 --- a/src/main/github/pr-head-tracking-ref.ts +++ b/src/main/github/pr-head-tracking-ref.ts @@ -6,39 +6,33 @@ import { REVIEW_HEAD_FETCH_TIMEOUT_MS } from '../../shared/review-head-tracking-ref' import { getReviewHeadRemoteComponent } from '../git/review-head-remote-identity' -import type { SshGitProvider } from '../providers/ssh-git-provider' +import { + SSH_GIT_RECONNECT_MESSAGE, + SshHostNotServedError +} from '../providers/ssh-host-not-served-error' type LocalGitExecOptions = { cwd: string wslDistro?: string } -// Why: the relay's read-only git.exec channel rejects `fetch`, so SSH repos -// must use the dedicated git.fetchRemoteTrackingRef RPC. export async function fetchPrHeadTrackingRef( repo: { path: string; connectionId?: string | null }, - sshGitProvider: SshGitProvider | null | undefined, remote: string, branch: string, options: { localGitExecOptions?: LocalGitExecOptions } = {} ): Promise { - const ref = `refs/remotes/${remote}/${branch}` - if (!repo.connectionId) { - await gitExecFileAsync( - ['fetch', remote, `+refs/heads/${branch}:${ref}`], - options.localGitExecOptions ?? { cwd: repo.path } - ) - return + if (repo.connectionId) { + throw new SshHostNotServedError(repo.connectionId, SSH_GIT_RECONNECT_MESSAGE) } - if (!sshGitProvider) { - throw new Error('SSH Git provider is not available. Reconnect to this target and try again.') - } - await sshGitProvider.fetchRemoteTrackingRef(repo.path, remote, branch, ref) + await gitExecFileAsync( + ['fetch', remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`], + options.localGitExecOptions ?? { cwd: repo.path } + ) } export async function fetchGitHubPullRequestHeadRef( repo: { path: string; connectionId?: string | null }, - sshGitProvider: SshGitProvider | null | undefined, remote: string, prNumber: number, options: { localGitExecOptions?: LocalGitExecOptions } = {} @@ -49,22 +43,16 @@ export async function fetchGitHubPullRequestHeadRef( if (!isSafeReviewHeadFetchRemote(remote)) { throw new Error('Pull request fetch remote must not start with "-".') } - if (!repo.connectionId) { - const localGitExecOptions = options.localGitExecOptions ?? { cwd: repo.path } - const remoteComponent = await getReviewHeadRemoteComponent(remote, localGitExecOptions) - // Why: return the same path the fetch wrote so callers don't re-resolve identity. - const localRef = githubPullRequestHeadLocalRef(remoteComponent, prNumber) - await gitExecFileAsync( - ['fetch', '--no-tags', remote, `+refs/pull/${prNumber}/head:${localRef}`], - { - ...localGitExecOptions, - timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS - } - ) - return localRef + if (repo.connectionId) { + throw new SshHostNotServedError(repo.connectionId, SSH_GIT_RECONNECT_MESSAGE) } - if (!sshGitProvider) { - throw new Error('SSH Git provider is not available. Reconnect to this target and try again.') - } - return sshGitProvider.fetchGitHubPullRequestHead(repo.path, remote, prNumber) + const localGitExecOptions = options.localGitExecOptions ?? { cwd: repo.path } + const remoteComponent = await getReviewHeadRemoteComponent(remote, localGitExecOptions) + // Why: return the same path the fetch wrote so callers don't re-resolve identity. + const localRef = githubPullRequestHeadLocalRef(remoteComponent, prNumber) + await gitExecFileAsync( + ['fetch', '--no-tags', remote, `+refs/pull/${prNumber}/head:${localRef}`], + { ...localGitExecOptions, timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS } + ) + return localRef } diff --git a/src/main/github/pr-mutation-review-invalidation.test.ts b/src/main/github/pr-mutation-review-invalidation.test.ts index 5dcacd5ff55..5e3cc0a242a 100644 --- a/src/main/github/pr-mutation-review-invalidation.test.ts +++ b/src/main/github/pr-mutation-review-invalidation.test.ts @@ -42,7 +42,6 @@ vi.mock('./client/lookup/pr-number-lookup', () => ({ vi.mock('./client/lookup/branch-lookup-resolution', () => ({ resolvePRForBranchOutcome: mocks.resolve })) -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProviderGeneration: () => 1 })) vi.mock('../ipc/github-work-item-mutation-events', () => ({ broadcastGitHubWorkItemMutation: vi.fn() })) diff --git a/src/main/gitlab/gitlab-known-host-probe.test.ts b/src/main/gitlab/gitlab-known-host-probe.test.ts index e3bab30c803..ec999ab2d03 100644 --- a/src/main/gitlab/gitlab-known-host-probe.test.ts +++ b/src/main/gitlab/gitlab-known-host-probe.test.ts @@ -12,7 +12,6 @@ import { rememberGlabKnownHost, rememberGlabKnownHosts } from './gitlab-known-host-probe' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' describe('getGlabKnownHosts', () => { // A locally-keyed probe must never answer from the default WSL distro. @@ -280,24 +279,4 @@ describe('getGlabKnownHosts', () => { ]) expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) }) - - it('does not reuse a successful result after an SSH provider reconnects', async () => { - const connectionId = 'conn-reconnected' - registerSshGitProvider(connectionId, {} as never) - glabExecFileAsyncMock - .mockResolvedValueOnce({ stdout: 'Logged in to old-tunnel.test as user\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'Logged in to new-tunnel.test as user\n', stderr: '' }) - - await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([ - 'gitlab.com', - 'old-tunnel.test' - ]) - registerSshGitProvider(connectionId, {} as never) - await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([ - 'gitlab.com', - 'new-tunnel.test' - ]) - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) - unregisterSshGitProvider(connectionId) - }) }) diff --git a/src/main/gitlab/gitlab-known-host-probe.ts b/src/main/gitlab/gitlab-known-host-probe.ts index 6f65e189d6f..0376ed62b2b 100644 --- a/src/main/gitlab/gitlab-known-host-probe.ts +++ b/src/main/gitlab/gitlab-known-host-probe.ts @@ -1,7 +1,6 @@ import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe' import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' import { glabExecFileAsync } from '../git/runner' -import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost } from './project-ref-parser' import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' @@ -13,10 +12,7 @@ export type LocalGitExecOptions = { const GLAB_KNOWN_HOSTS_TIMEOUT_MS = 10_000 const UNAUTHENTICATED_HOSTS_MAX_ENTRIES = 128 export const KNOWN_HOSTS_CACHE_MAX_ENTRIES = 128 -const knownHostsCacheByExecutionContext = new Map< - string, - { key: string; hosts: readonly string[] } ->() +const knownHostsCacheByExecutionContext = new Map() const knownHostsInFlightByExecutionContext: CoalescedProbes = new Map() const unauthenticatedHostExpiries = new Map() @@ -25,25 +21,11 @@ function knownHostsExecutionKey( localGitOptions: LocalGitExecOptions = {} ): string { if (connectionId) { - // Why: reconnecting can replace the SSH/relay execution host under the same id. - return `connection:${connectionId}:${getSshGitProviderGeneration(connectionId)}` + return `connection:${connectionId}` } return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'native' } -function knownHostsCacheContext( - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): { key: string; cacheKey: string } { - const key = knownHostsExecutionKey(connectionId, localGitOptions) - const cacheKey = connectionId ? `connection:${connectionId}` : key - const cached = knownHostsCacheByExecutionContext.get(cacheKey) - if (cached && cached.key !== key) { - knownHostsCacheByExecutionContext.delete(cacheKey) - } - return { key, cacheKey } -} - /** @internal - exposed for tests only */ export function _resetKnownHostsCache(): void { knownHostsCacheByExecutionContext.clear() @@ -135,8 +117,8 @@ export function rememberGlabKnownHosts( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): void { - const { key, cacheKey } = knownHostsCacheContext(connectionId, localGitOptions) - const cached = knownHostsCacheByExecutionContext.get(cacheKey)?.hosts ?? DEFAULT_GITLAB_HOSTS + const key = knownHostsExecutionKey(connectionId, localGitOptions) + const cached = knownHostsCacheByExecutionContext.get(key) ?? DEFAULT_GITLAB_HOSTS const seen = new Set(cached.map(normalizeGitLabHost)) const additions: string[] = [] for (const host of hosts) { @@ -153,7 +135,7 @@ export function rememberGlabKnownHosts( if (additions.length === 0) { return } - knownHostsCacheByExecutionContext.set(cacheKey, { key, hosts: [...cached, ...additions] }) + knownHostsCacheByExecutionContext.set(key, [...cached, ...additions]) trimKnownHostsCache() } @@ -161,33 +143,29 @@ export async function getGlabKnownHosts( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const { key, cacheKey } = knownHostsCacheContext(connectionId, localGitOptions) - const cached = knownHostsCacheByExecutionContext.get(cacheKey)?.hosts + const key = knownHostsExecutionKey(connectionId, localGitOptions) + const cached = knownHostsCacheByExecutionContext.get(key) if (cached) { - const entry = knownHostsCacheByExecutionContext.get(cacheKey) - if (entry) { - knownHostsCacheByExecutionContext.delete(cacheKey) - knownHostsCacheByExecutionContext.set(cacheKey, entry) - } + knownHostsCacheByExecutionContext.delete(key) + knownHostsCacheByExecutionContext.set(key, cached) return cached } // Why: only join a probe still young enough to answer, so a wedged one cannot // pin every later retry for the life of the process (P1-D). return runCoalescedProbe(knownHostsInFlightByExecutionContext, key, (ownsKey) => - probeGlabKnownHosts(key, cacheKey, ownsKey, connectionId, localGitOptions) + probeGlabKnownHosts(key, ownsKey, connectionId, localGitOptions) ) } async function probeGlabKnownHosts( key: string, - cacheKey: string, ownsKey: () => boolean, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { try { - // Why: auth config belongs to the executing host; do not share native, WSL, - // or reconnected SSH/relay results, and bound an otherwise global probe. + // Why: auth config belongs to the executing host; do not share native, WSL or + // connection results, and bound an otherwise global probe. const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { timeout: GLAB_KNOWN_HOSTS_TIMEOUT_MS, // Why: a local probe would cache the default distro's auth under 'native' @@ -200,18 +178,16 @@ async function probeGlabKnownHosts( ...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {}) }) const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`) - const cached = knownHostsCacheByExecutionContext.get(cacheKey) - const remembered = cached?.key === key ? cached.hosts : [] + const remembered = knownHostsCacheByExecutionContext.get(key) ?? [] const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...remembered, ...hosts])) - if (ownsKey() && knownHostsExecutionKey(connectionId, localGitOptions) === key) { - knownHostsCacheByExecutionContext.set(cacheKey, { key, hosts: merged }) + if (ownsKey()) { + knownHostsCacheByExecutionContext.set(key, merged) trimKnownHostsCache() } return merged } catch { // Keep failures uncached so auth or tunnel recovery is discovered later. - const cached = knownHostsCacheByExecutionContext.get(cacheKey) - return cached?.key === key ? cached.hosts : [...DEFAULT_GITLAB_HOSTS] + return knownHostsCacheByExecutionContext.get(key) ?? [...DEFAULT_GITLAB_HOSTS] } } diff --git a/src/main/gitlab/gitlab-known-host-retirement.test.ts b/src/main/gitlab/gitlab-known-host-retirement.test.ts index 60bb2be13b2..fe2a75fd85c 100644 --- a/src/main/gitlab/gitlab-known-host-retirement.test.ts +++ b/src/main/gitlab/gitlab-known-host-retirement.test.ts @@ -1,42 +1,16 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' -const { execute, generations } = vi.hoisted(() => ({ - execute: vi.fn(), - generations: new Map() -})) +const { execute } = vi.hoisted(() => ({ execute: vi.fn() })) vi.mock('../git/runner', () => ({ glabExecFileAsync: execute })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProviderGeneration: (connectionId: string) => generations.get(connectionId) ?? 0 -})) -import { - _resetKnownHostsCache, - getGlabKnownHosts, - rememberGlabKnownHost -} from './gitlab-known-host-probe' +import { _resetKnownHostsCache, getGlabKnownHosts } from './gitlab-known-host-probe' import { PROBE_COALESCE_STALE_MS } from '../git/coalesced-probe' const response = (host: string) => ({ stdout: `Logged in to ${host} as user`, stderr: '' }) const deferred = () => Promise.withResolvers>() -async function collect(): Promise { - if (typeof globalThis.gc !== 'function') { - throw new Error('Run with the repository Vitest --expose-gc config') - } - for (let index = 0; index < 6; index++) { - await new Promise((resolve) => setImmediate(resolve)) - globalThis.gc() - } - await new Promise((resolve) => setImmediate(resolve)) -} - -async function weakResult(connectionId: string): Promise> { - return new WeakRef(await getGlabKnownHosts(connectionId)) -} - beforeEach(() => { _resetKnownHostsCache() - generations.clear() execute.mockReset() }) afterEach(() => { @@ -44,48 +18,6 @@ afterEach(() => { vi.restoreAllMocks() }) -it('releases successful host arrays from superseded SSH generations', async () => { - const results: WeakRef[] = [] - for (let generation = 1; generation <= 32; generation++) { - generations.set('connection', generation) - execute.mockResolvedValue(response(`host${generation}.test`)) - results.push(await weakResult('connection')) - } - await collect() - expect(results.filter((result) => result.deref() !== undefined)).toHaveLength(1) - await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'host32.test']) - expect(execute).toHaveBeenCalledTimes(32) -}) - -it('retires remembered generations without requiring an auth-status probe', async () => { - const results: WeakRef[] = [] - for (let generation = 1; generation <= 16; generation++) { - generations.set('connection', generation) - rememberGlabKnownHost(`host${generation}.test`, 'connection') - results.push(await weakResult('connection')) - } - await collect() - expect(results.filter((result) => result.deref() !== undefined)).toHaveLength(1) - await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'host16.test']) - expect(execute).not.toHaveBeenCalled() -}) - -it('does not retain a delayed old-generation result after a replacement answers', async () => { - const old = deferred() - generations.set('connection', 1) - execute.mockReturnValueOnce(old.promise) - const oldResult = weakResult('connection') - generations.set('connection', 2) - execute.mockResolvedValueOnce(response('replacement.test')) - await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'replacement.test']) - old.resolve(response('retired.test')) - const reference = await oldResult - await collect() - expect(reference.deref()).toBeUndefined() - await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'replacement.test']) - expect(execute).toHaveBeenCalledTimes(2) -}) - it('does not repopulate an explicitly reset cache from an earlier probe', async () => { const old = deferred() execute.mockReturnValueOnce(old.promise) @@ -133,39 +65,3 @@ it('prevents an abandoned same-generation probe from publishing over its success await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'replacement.test']) expect(execute).toHaveBeenCalledTimes(2) }) - -it('keeps native, WSL and other connection caches when one generation changes', async () => { - execute - .mockResolvedValueOnce(response('native.test')) - .mockResolvedValueOnce(response('ubuntu.test')) - .mockResolvedValueOnce(response('debian.test')) - .mockResolvedValueOnce(response('connection-a.test')) - .mockResolvedValueOnce(response('connection-b.test')) - const native = await getGlabKnownHosts() - const ubuntu = await getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }) - const debian = await getGlabKnownHosts(undefined, { wslDistro: 'Debian' }) - await getGlabKnownHosts('connection-a') - const other = await getGlabKnownHosts('connection-b') - generations.set('connection-a', 1) - rememberGlabKnownHost('replacement.test', 'connection-a') - await expect(getGlabKnownHosts('connection-a')).resolves.toEqual([ - 'gitlab.com', - 'replacement.test' - ]) - await expect(getGlabKnownHosts()).resolves.toBe(native) - await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toBe(ubuntu) - await expect(getGlabKnownHosts(undefined, { wslDistro: 'Debian' })).resolves.toBe(debian) - await expect(getGlabKnownHosts('connection-b')).resolves.toBe(other) - expect(execute).toHaveBeenCalledTimes(5) -}) - -it('does not serve a retired generation after the current probe fails', async () => { - execute.mockResolvedValueOnce(response('retired.test')) - await getGlabKnownHosts('connection') - generations.set('connection', 1) - execute.mockRejectedValueOnce(new Error('current host unavailable')) - await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com']) - execute.mockResolvedValueOnce(response('current.test')) - await expect(getGlabKnownHosts('connection')).resolves.toEqual(['gitlab.com', 'current.test']) - expect(execute).toHaveBeenCalledTimes(3) -}) diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts index 293030960e7..d15eda18684 100644 --- a/src/main/gitlab/gitlab-project-ref-resolution.ts +++ b/src/main/gitlab/gitlab-project-ref-resolution.ts @@ -4,7 +4,6 @@ import { shouldProbeGitRemote } from '../git/remote-name-listing' import { addWslEnvKeys } from '../wsl-env' import { isTransientGitProbeError, readRemoteUrl } from '../git/remote-url-probe' import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' -import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import type { IssueSourcePreference } from '../../shared/repo-types' import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight' import { @@ -75,13 +74,9 @@ export async function getProjectRefForRemote( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - // Why: a reconnect replaces the host an answer came from under the same id, so - // the generation is part of the signature; `knownHosts` carries the glab auth - // state, so logging into a self-hosted instance re-asks rather than reusing a - // ref resolved while that host was unknown. - const runtimeKey = connectionId - ? `${connectionId}:${getSshGitProviderGeneration(connectionId)}` - : `local:${localGitOptions.wslDistro ?? 'host'}` + // Why: `knownHosts` carries the glab auth state, so logging into a self-hosted + // instance re-asks rather than reusing a ref resolved while that host was unknown. + const runtimeKey = connectionId ?? `local:${localGitOptions.wslDistro ?? 'host'}` const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}\0${knownHosts.join(',')}` const cached = projectRefCache.get(cacheKey) if (cached) { diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index a895e4702e9..82344aa2cc5 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -1,9 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, glabExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({ +const { gitExecFileAsyncMock, glabExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), - glabExecFileAsyncMock: vi.fn(), - sshExecMock: vi.fn() + glabExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ @@ -33,7 +32,6 @@ import { resolveIssueSource } from './gl-utils' import { GlabNonListResponseError } from './glab-api-response' -import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' import { _resetRemoteNameListingCache } from '../git/remote-name-listing' import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache' @@ -71,15 +69,12 @@ describe('gitlab project ref resolution', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() glabExecFileAsyncMock.mockReset() - sshExecMock.mockReset() - unregisterSshGitProvider('conn-1') _resetProjectRefCache() _resetRemoteNameListingCache() }) afterEach(() => { vi.useRealTimers() - unregisterSshGitProvider('conn-1') }) it('keeps getProjectRef origin-based', async () => { @@ -215,21 +210,6 @@ describe('gitlab project ref resolution', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) }) - it('resolves project refs through the SSH git provider for connected repos', async () => { - sshExecMock.mockResolvedValueOnce({ stdout: 'git@gitlab.com:remote/orca.git\n', stderr: '' }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ - host: 'gitlab.com', - path: 'remote/orca' - }) - - expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo', { - signal: expect.any(AbortSignal) - }) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - it('bounds cached project refs for distinct repo paths', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@gitlab.com:stablyai/orca.git\n', @@ -243,34 +223,6 @@ describe('gitlab project ref resolution', () => { expect(_getProjectRefCacheSize()).toBe(512) }) - it('does not cache a missing SSH provider as a permanent null project ref', async () => { - await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull() - - sshExecMock.mockResolvedValueOnce({ - stdout: 'git@gitlab.com:remote/orca.git\n', - stderr: '' - }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ - host: 'gitlab.com', - path: 'remote/orca' - }) - }) - - it('does not cache transient SSH exec failures as permanent null project refs', async () => { - sshExecMock - .mockRejectedValueOnce(new Error('ssh tunnel not ready')) - .mockResolvedValueOnce({ stdout: 'git@gitlab.com:remote/orca.git\n', stderr: '' }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull() - await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ - host: 'gitlab.com', - path: 'remote/orca' - }) - }) - it('does not cache a local probe killed on its deadline as a definitive miss', async () => { gitExecFileAsyncMock .mockRejectedValueOnce(new Error('git timed out.')) @@ -352,28 +304,6 @@ describe('gitlab project ref resolution', () => { } expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) }) - - it('does not serve a project ref resolved on a retired SSH connection', async () => { - sshExecMock - .mockResolvedValueOnce({ stdout: 'git@gitlab.com:before/orca.git\n', stderr: '' }) - .mockResolvedValueOnce({ stdout: 'git@gitlab.com:after/orca.git\n', stderr: '' }) - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ - host: 'gitlab.com', - path: 'before/orca' - }) - - // A reconnect can swap the execution host under the same connection id. - unregisterSshGitProvider('conn-1') - registerSshGitProvider('conn-1', { exec: sshExecMock } as never) - - await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({ - host: 'gitlab.com', - path: 'after/orca' - }) - expect(sshExecMock).toHaveBeenCalledTimes(2) - }) }) describe('GitLab operation admission', () => { diff --git a/src/main/gitlab/merge-request-creation.test.ts b/src/main/gitlab/merge-request-creation.test.ts index 6efdbd45d82..b57406c1b58 100644 --- a/src/main/gitlab/merge-request-creation.test.ts +++ b/src/main/gitlab/merge-request-creation.test.ts @@ -7,8 +7,7 @@ const { glabHostEnvOptionsMock, glabRepoExecOptionsMock, acquireMock, - releaseMock, - getSshFilesystemProviderMock + releaseMock } = vi.hoisted(() => ({ getProjectSlugMock: vi.fn(), glabExecFileAsyncMock: vi.fn(), @@ -20,8 +19,7 @@ const { connectionId ? {} : { cwd: repoPath } ), acquireMock: vi.fn(), - releaseMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn() + releaseMock: vi.fn() })) vi.mock('./client', () => ({ @@ -37,10 +35,6 @@ vi.mock('./gl-utils', () => ({ glabRepoExecOptions: glabRepoExecOptionsMock })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock -})) - import { createGitLabMergeRequest } from './merge-request-creation' describe('createGitLabMergeRequest', () => { @@ -52,7 +46,6 @@ describe('createGitLabMergeRequest', () => { glabRepoExecOptionsMock.mockClear() acquireMock.mockReset() releaseMock.mockReset() - getSshFilesystemProviderMock.mockReset() acquireMock.mockResolvedValue(undefined) getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'acme/widgets' }) }) @@ -193,45 +186,6 @@ describe('createGitLabMergeRequest', () => { expect(options).not.toHaveProperty('cwd') }) - it('reads merge request templates from the SSH filesystem provider', async () => { - const readRemoteFile = vi.fn(async (path: string) => { - if (path === '/remote/repo-root/.gitlab/merge_request_templates/Default.md') { - return { content: 'Remote MR template body', isBinary: false } - } - throw new Error('missing template') - }) - getSshFilesystemProviderMock.mockReturnValue({ readFile: readRemoteFile }) - glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: 'https://gitlab.com/acme/widgets/-/merge_requests/46\n', - stderr: '' - }) - - await expect( - createGitLabMergeRequest( - '/remote/repo-root', - { - provider: 'gitlab', - base: 'main', - head: 'feature/ssh-template', - title: 'SSH Template MR', - body: '', - useTemplate: true - }, - 'ssh:ssh-1' - ) - ).resolves.toEqual({ - ok: true, - number: 46, - url: 'https://gitlab.com/acme/widgets/-/merge_requests/46' - }) - - const [args] = glabExecFileAsyncMock.mock.calls[0] - expect(readRemoteFile).toHaveBeenCalledWith( - '/remote/repo-root/.gitlab/merge_request_templates/Default.md' - ) - expect(args[args.indexOf('--description') + 1]).toBe('Remote MR template body') - }) - it('returns an existing merge request when GitLab reports a duplicate', async () => { glabExecFileAsyncMock .mockRejectedValueOnce(new Error('merge request already exists')) diff --git a/src/main/gitlab/merge-request-creation.ts b/src/main/gitlab/merge-request-creation.ts index 57153fb40ae..ca3f71a442e 100644 --- a/src/main/gitlab/merge-request-creation.ts +++ b/src/main/gitlab/merge-request-creation.ts @@ -7,8 +7,6 @@ import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef } from '../../shared/hosted-review-refs' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import { getHostedReviewLocalGitOptions, hasHostedReviewLocalGitOptions, @@ -100,21 +98,11 @@ async function readMergeRequestTemplate( '.gitlab/merge_request_template.md', '.gitlab/MERGE_REQUEST_TEMPLATE.md' ] - const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined - if (connectionId && !remoteProvider) { + if (connectionId) { return '' } for (const relativeCandidate of relativeCandidates) { try { - if (remoteProvider) { - const result = await remoteProvider.readFile( - joinWorktreeRelativePath(repoPath, relativeCandidate) - ) - if (result.isBinary) { - continue - } - return result.content - } return await readFile(join(repoPath, relativeCandidate), 'utf8') } catch { // Try the next conventional GitLab merge-request template path. diff --git a/src/main/gitlab/mr-head-tracking-ref.test.ts b/src/main/gitlab/mr-head-tracking-ref.test.ts index a83a94f8842..6d646d1ee28 100644 --- a/src/main/gitlab/mr-head-tracking-ref.test.ts +++ b/src/main/gitlab/mr-head-tracking-ref.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { SshGitProvider } from '../providers/ssh-git-provider' const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) @@ -28,7 +27,6 @@ describe('fetchGitLabMergeRequestHeadRef', () => { it('fetches a GitLab MR head into its remote-scoped Orca ref for local repos', async () => { const localRef = await fetchGitLabMergeRequestHeadRef( { path: '/repo', connectionId: null }, - null, 'origin', 42 ) @@ -56,7 +54,7 @@ describe('fetchGitLabMergeRequestHeadRef', () => { }) await expect( - fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, null, 'origin', 42) + fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 42) ).rejects.toThrow('Remote "origin" is not configured.') expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( expect.arrayContaining(['fetch']), @@ -65,15 +63,9 @@ describe('fetchGitLabMergeRequestHeadRef', () => { }) it('keeps WSL routing while bounding the MR-head fetch', async () => { - await fetchGitLabMergeRequestHeadRef( - { path: '/repo', connectionId: null }, - null, - 'origin', - 42, - { - localGitExecOptions: { cwd: '/repo', wslDistro: 'Ubuntu' } - } - ) + await fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 42, { + localGitExecOptions: { cwd: '/repo', wslDistro: 'Ubuntu' } + }) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], { cwd: '/repo', @@ -90,40 +82,19 @@ describe('fetchGitLabMergeRequestHeadRef', () => { ) }) - it('uses the SSH GitLab MR-head RPC and never runs git directly', async () => { - const expectedRef = `refs/orca/merge-requests/${ORIGIN_COMPONENT}/77` - const fetchGitLabMergeRequestHead = vi.fn(async () => expectedRef) - - const localRef = await fetchGitLabMergeRequestHeadRef( - { path: '/repo', connectionId: 'conn-1' }, - { fetchGitLabMergeRequestHead } as unknown as SshGitProvider, - 'origin', - 77 - ) - - expect(fetchGitLabMergeRequestHead).toHaveBeenCalledWith('/repo', 'origin', 77) - expect(localRef).toBe(expectedRef) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('rejects a connected GitLab MR-head fetch without an SSH provider', async () => { + it('rejects a GitLab MR-head fetch for an SSH repo', async () => { await expect( - fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: 'conn-1' }, null, 'origin', 77) + fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: 'conn-1' }, 'origin', 77) ).rejects.toThrow('SSH Git provider is not available') expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) it('rejects invalid MR iids and option-shaped remotes before running git', async () => { await expect( - fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, null, 'origin', 0) + fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 0) ).rejects.toThrow('Invalid merge request iid') await expect( - fetchGitLabMergeRequestHeadRef( - { path: '/repo', connectionId: null }, - null, - '--upload-pack=x', - 42 - ) + fetchGitLabMergeRequestHeadRef({ path: '/repo', connectionId: null }, '--upload-pack=x', 42) ).rejects.toThrow('must not start with "-"') expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) diff --git a/src/main/gitlab/mr-head-tracking-ref.ts b/src/main/gitlab/mr-head-tracking-ref.ts index e68a8235a9a..9f5f6170f77 100644 --- a/src/main/gitlab/mr-head-tracking-ref.ts +++ b/src/main/gitlab/mr-head-tracking-ref.ts @@ -6,20 +6,19 @@ import { } from '../../shared/review-head-tracking-ref' import { gitExecFileAsync } from '../git/runner' import { getReviewHeadRemoteComponent } from '../git/review-head-remote-identity' -import type { SshGitProvider } from '../providers/ssh-git-provider' +import { + SSH_GIT_RECONNECT_MESSAGE, + SshHostNotServedError +} from '../providers/ssh-host-not-served-error' type LocalGitExecOptions = { cwd: string wslDistro?: string } -// Why: the relay's read-only git.exec channel rejects `fetch`, so SSH repos -// must use the dedicated git.fetchGitLabMergeRequestHeadRef RPC. Mirrors -// fetchGitHubPullRequestHeadRef so both providers pin the durable head ref -// the same way. +// Why: mirrors fetchGitHubPullRequestHeadRef so both forges pin the durable head ref the same way. export async function fetchGitLabMergeRequestHeadRef( repo: { path: string; connectionId?: string | null }, - sshGitProvider: SshGitProvider | null | undefined, remote: string, mrIid: number, options: { localGitExecOptions?: LocalGitExecOptions } = {} @@ -30,22 +29,16 @@ export async function fetchGitLabMergeRequestHeadRef( if (!isSafeReviewHeadFetchRemote(remote)) { throw new Error('Merge request fetch remote must not start with "-".') } - if (!repo.connectionId) { - const localGitExecOptions = options.localGitExecOptions ?? { cwd: repo.path } - const remoteComponent = await getReviewHeadRemoteComponent(remote, localGitExecOptions) - // Why: return the same path the fetch wrote so callers don't re-resolve identity. - const localRef = gitlabMergeRequestHeadLocalRef(remoteComponent, mrIid) - await gitExecFileAsync( - ['fetch', '--no-tags', remote, `+refs/merge-requests/${mrIid}/head:${localRef}`], - { - ...localGitExecOptions, - timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS - } - ) - return localRef + if (repo.connectionId) { + throw new SshHostNotServedError(repo.connectionId, SSH_GIT_RECONNECT_MESSAGE) } - if (!sshGitProvider) { - throw new Error('SSH Git provider is not available. Reconnect to this target and try again.') - } - return sshGitProvider.fetchGitLabMergeRequestHead(repo.path, remote, mrIid) + const localGitExecOptions = options.localGitExecOptions ?? { cwd: repo.path } + const remoteComponent = await getReviewHeadRemoteComponent(remote, localGitExecOptions) + // Why: return the same path the fetch wrote so callers don't re-resolve identity. + const localRef = gitlabMergeRequestHeadLocalRef(remoteComponent, mrIid) + await gitExecFileAsync( + ['fetch', '--no-tags', remote, `+refs/merge-requests/${mrIid}/head:${localRef}`], + { ...localGitExecOptions, timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS } + ) + return localRef } diff --git a/src/main/ipc/filesystem-branch-compare-diff.test.ts b/src/main/ipc/filesystem-branch-compare-diff.test.ts index 47782408b9c..86be671848b 100644 --- a/src/main/ipc/filesystem-branch-compare-diff.test.ts +++ b/src/main/ipc/filesystem-branch-compare-diff.test.ts @@ -22,10 +22,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -39,10 +35,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock diff --git a/src/main/ipc/filesystem-commit-message-generation.test.ts b/src/main/ipc/filesystem-commit-message-generation.test.ts index c37f33bc091..63d600449e6 100644 --- a/src/main/ipc/filesystem-commit-message-generation.test.ts +++ b/src/main/ipc/filesystem-commit-message-generation.test.ts @@ -11,7 +11,6 @@ import { generateCommitMessageFromContextMock, cancelGenerateCommitMessageLocalMock, cancelGeneratePullRequestFieldsLocalMock, - getSshGitProviderMock, resetFilesystemIpcMocks } from './filesystem-test-harness' @@ -25,10 +24,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -42,10 +37,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock @@ -65,6 +56,7 @@ vi.mock( import { registerFilesystemHandlers } from './filesystem' import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' +import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error' describe('registerFilesystemHandlers', () => { beforeEach(() => { @@ -334,36 +326,6 @@ describe('registerFilesystemHandlers', () => { ) }) - it('enriches the SSH commit context from host meta using the remote path', async () => { - const context = { branch: 'main', stagedSummary: 'A\tremote.txt', stagedPatch: '+remote' } - const params = { agentId: 'custom', model: '', customAgentCommand: 'agent' } - const worktreeId = 'repo-1::/remote/repo' - resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) - getSshGitProviderMock.mockReturnValue({ - getStagedCommitContext: vi.fn().mockResolvedValue(context), - executeCommitMessagePlan: vi.fn() - }) - generateCommitMessageFromContextMock.mockResolvedValue({ success: true, message: 'Add file' }) - const linkedStore = { - ...store, - getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 77 } : undefined) - } - - registerFilesystemHandlers(linkedStore as never) - - await handlers.get('git:generateCommitMessage')!(null, { - worktreePath: '/remote/repo', - worktreeId, - connectionId: 'conn-1' - }) - - expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith( - { ...context, linkedIssue: 77 }, - params, - expect.objectContaining({ kind: 'remote' }) - ) - }) - it('returns a sanitized error when local agent account preparation fails', async () => { const context = { branch: 'feature/ai', @@ -484,71 +446,25 @@ describe('registerFilesystemHandlers', () => { } }) - it('generates an SSH commit message using remote staged context and relay execution', async () => { - const context = { - branch: 'main', - stagedSummary: 'A\tremote.txt', - stagedPatch: '+remote' - } - const params = { agentId: 'custom', model: '', customAgentCommand: 'agent' } - const executeCommitMessagePlan = vi.fn() - const prepareForCodexLaunch = vi.fn(() => '/managed/codex-home') - const prepareForClaudeLaunch = vi.fn() - resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) - getSshGitProviderMock.mockReturnValue({ - getStagedCommitContext: vi.fn().mockResolvedValue(context), - executeCommitMessagePlan - }) - generateCommitMessageFromContextMock.mockResolvedValue({ - success: true, - message: 'Add remote file' + it('refuses SSH commit generation as not served without reading local staged changes', async () => { + resolveCommitMessageSettingsMock.mockReturnValue({ + ok: true, + params: { agentId: 'custom', model: '', customAgentCommand: 'agent' } }) - const resolveBaseEnvironment = vi.fn(async () => ({})) - registerFilesystemHandlers(store as never, { - prepareForCodexLaunch, - prepareForClaudeLaunch, - resolveBaseEnvironment - }) + registerFilesystemHandlers(store as never) await expect( handlers.get('git:generateCommitMessage')!(null, { worktreePath: '/remote/repo', connectionId: 'conn-1' }) - ).resolves.toEqual({ success: true, message: 'Add remote file' }) - - expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith( - context, - params, - expect.objectContaining({ - kind: 'remote', - cwd: '/remote/repo', - missingBinaryLocation: 'remote PATH' - }) - ) - const target = generateCommitMessageFromContextMock.mock.calls[0]?.[2] - await target.execute( - { binary: 'agent', args: [], stdinPayload: null, label: 'agent' }, - '/cwd', - 1, - 'commit-message' - ) - expect(executeCommitMessagePlan).toHaveBeenCalledWith( - { binary: 'agent', args: [], stdinPayload: null, label: 'agent' }, - '/cwd', - 1, - 'commit-message' - ) - expect(prepareForCodexLaunch).not.toHaveBeenCalled() - expect(prepareForClaudeLaunch).not.toHaveBeenCalled() - expect(resolveBaseEnvironment).not.toHaveBeenCalled() + ).resolves.toEqual({ success: false, error: SSH_HOST_NOT_SERVED_MESSAGE }) + expect(getStagedCommitContextMock).not.toHaveBeenCalled() + expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled() }) - it('routes SSH generation cancellations to separate provider operations', async () => { - const cancelGenerateCommitMessage = vi.fn().mockResolvedValue(undefined) - getSshGitProviderMock.mockReturnValue({ cancelGenerateCommitMessage }) - + it('ignores SSH generation cancellations instead of cancelling local work', async () => { registerFilesystemHandlers(store as never) await handlers.get('git:cancelGenerateCommitMessage')!(null, { @@ -560,12 +476,6 @@ describe('registerFilesystemHandlers', () => { connectionId: 'conn-1' }) - expect(cancelGenerateCommitMessage).toHaveBeenNthCalledWith(1, '/remote/repo', 'commit-message') - expect(cancelGenerateCommitMessage).toHaveBeenNthCalledWith( - 2, - '/remote/repo', - 'pull-request-fields' - ) expect(cancelGenerateCommitMessageLocalMock).not.toHaveBeenCalled() expect(cancelGeneratePullRequestFieldsLocalMock).not.toHaveBeenCalled() }) @@ -605,25 +515,4 @@ describe('registerFilesystemHandlers', () => { expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled() }) - - it('sanitizes SSH staged-context read failures before returning to the renderer', async () => { - resolveCommitMessageSettingsMock.mockReturnValue({ - ok: true, - params: { agentId: 'codex', model: 'gpt-5.4-mini' } - }) - getSshGitProviderMock.mockReturnValue({ - getStagedCommitContext: vi.fn().mockRejectedValue(new Error('fatal: /remote/secret failed')) - }) - - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('git:generateCommitMessage')!(null, { - worktreePath: '/remote/repo', - connectionId: 'conn-1' - }) - ).resolves.toEqual({ success: false, error: 'Failed to read staged changes.' }) - - expect(generateCommitMessageFromContextMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/ipc/filesystem-commit-message-model-discovery.test.ts b/src/main/ipc/filesystem-commit-message-model-discovery.test.ts index a4c4a17d7f8..fc356913ce2 100644 --- a/src/main/ipc/filesystem-commit-message-model-discovery.test.ts +++ b/src/main/ipc/filesystem-commit-message-model-discovery.test.ts @@ -7,8 +7,6 @@ import { WORKSPACE_DIR, WORKTREE_FEATURE_PATH, discoverCommitMessageModelsLocalMock, - discoverCommitMessageModelsRemoteMock, - getSshGitProviderMock, resetFilesystemIpcMocks } from './filesystem-test-harness' @@ -22,10 +20,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -39,10 +33,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock @@ -267,54 +257,16 @@ describe('registerFilesystemHandlers', () => { }) }) - it('routes SSH model discovery through the remote git provider', async () => { - discoverCommitMessageModelsRemoteMock.mockResolvedValue({ - success: true, - capability: { - id: 'cursor', - label: 'Cursor', - modelSource: 'dynamic', - defaultModelId: 'auto', - models: [{ id: 'auto', label: 'Auto' }] - }, - models: [{ id: 'auto', label: 'Auto' }], - defaultModelId: 'auto' - }) - const executeCommitMessagePlan = vi.fn() - getSshGitProviderMock.mockReturnValue({ executeCommitMessagePlan }) - const storeWithOverride = { - ...store, - getSettings: () => ({ - workspaceDir: WORKSPACE_DIR, - agentCmdOverrides: { cursor: 'npx cursor-agent' } + it('reports no git provider for SSH model discovery and never discovers locally', async () => { + registerFilesystemHandlers(store as never) + + await expect( + handlers.get('git:discoverCommitMessageModels')!(null, { + agentId: 'cursor', + worktreePath: '/remote/repo', + connectionId: 'conn-1' }) - } - - registerFilesystemHandlers(storeWithOverride as never) - - await handlers.get('git:discoverCommitMessageModels')!(null, { - agentId: 'cursor', - worktreePath: '/remote/repo', - connectionId: 'conn-1' - }) - - expect(discoverCommitMessageModelsRemoteMock).toHaveBeenCalledWith( - 'cursor', - '/remote/repo', - expect.any(Function), - 'npx cursor-agent' - ) - const execute = discoverCommitMessageModelsRemoteMock.mock.calls[0]?.[2] as ( - plan: unknown, - cwd: string, - timeoutMs: number - ) => Promise - await execute({ binary: 'cursor-agent', args: ['--list-models'] }, '/remote/repo', 60_000) - expect(executeCommitMessagePlan).toHaveBeenCalledWith( - { binary: 'cursor-agent', args: ['--list-models'] }, - '/remote/repo', - 60_000 - ) + ).resolves.toEqual({ success: false, error: 'No git provider for connection "conn-1"' }) expect(discoverCommitMessageModelsLocalMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/ipc/filesystem-conflict-operation-routing.test.ts b/src/main/ipc/filesystem-conflict-operation-routing.test.ts index 0ebdd270eec..b79868408b3 100644 --- a/src/main/ipc/filesystem-conflict-operation-routing.test.ts +++ b/src/main/ipc/filesystem-conflict-operation-routing.test.ts @@ -5,7 +5,6 @@ import { REPO_PATH, WORKTREE_FEATURE_PATH, detectConflictOperationMock, - getSshGitProviderMock, resetFilesystemIpcMocks } from './filesystem-test-harness' @@ -21,10 +20,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -38,10 +33,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock('./local-worktree-runtime-options', () => ({ getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock, getLocalGitOptionsForRepo: vi.fn(() => ({})), @@ -92,7 +83,6 @@ describe('git:conflictOperation ssh routing', () => { }) it('reports an SSH host as not served instead of falling back to this machine', async () => { - getSshGitProviderMock.mockReturnValue({ detectConflictOperation: vi.fn() }) registerFilesystemHandlers(store as never) await expect( diff --git a/src/main/ipc/filesystem-document-folder-writes.test.ts b/src/main/ipc/filesystem-document-folder-writes.test.ts index 6e8e50ad205..bca599738b0 100644 --- a/src/main/ipc/filesystem-document-folder-writes.test.ts +++ b/src/main/ipc/filesystem-document-folder-writes.test.ts @@ -4,10 +4,6 @@ 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 @@ -302,11 +298,9 @@ describe('a project file opened by its full path', () => { }) 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', { + it('is refused without touching the local document, whatever access it declares', async () => { + await expect( + call('fs:rename', { oldPath: note, newPath: join(base, 'note.md'), access: documentFolder(note), @@ -314,11 +308,8 @@ describe('an SSH rename', () => { expectedSshTargetId: 'ssh-1', expectedSshConnectionGeneration: 0 }) - } finally { - unregisterSshFilesystemProvider('ssh-1') - } + ).rejects.toThrow('Remote connection dropped') - expect(renameNoClobber).toHaveBeenCalledWith(note, join(base, 'note.md')) expect(await readdir(docFolder)).toEqual(['note.md']) }) }) diff --git a/src/main/ipc/filesystem-download-folder.ts b/src/main/ipc/filesystem-download-folder.ts deleted file mode 100644 index 60e915956ef..00000000000 --- a/src/main/ipc/filesystem-download-folder.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { BrowserWindow, dialog, ipcMain } from 'electron' -import { randomUUID } from 'node:crypto' -import { rm, stat } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { getRuntimePathBasename } from '../../shared/cross-platform-path' -import { sanitizeLocalDownloadFilename } from '../local-download-filename' -import { promoteLocalDownloadedFolder } from '../local-downloaded-folder-promotion' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { isENOENT } from './filesystem-path-containment' - -type DownloadFolderResult = { canceled: true } | { canceled: false; destinationPath: string } - -function validateRequiredString(value: unknown, label: string): string { - if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`${label} is required`) - } - return value -} - -function createSiblingTransferPath(destinationPath: string, suffix: string): string { - // Why: promotion uses rename/no-clobber operations that must stay on the - // destination volume, so transfer paths intentionally remain siblings. - return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) -} - -async function assertDownloadFolderDestinationAvailable(destinationPath: string): Promise { - try { - await stat(destinationPath) - } catch (error) { - if (isENOENT(error)) { - return - } - throw error - } - throw new Error('Destination folder already exists') -} - -async function cleanupLocalTransferDirectory(dirPath: string): Promise { - try { - await rm(dirPath, { recursive: true, force: true }) - } catch (error) { - // Why: cleanup must not mask the transfer error, but a leaked recursive - // download tree needs enough visibility to diagnose and remove it. - console.warn(`[filesystem] Failed to remove temporary folder download '${dirPath}'`, error) - } -} - -// Why: keep folder-download IPC out of filesystem.ts — that module is already large. -export function registerFilesystemDownloadFolderHandlers(): void { - ipcMain.handle( - 'fs:downloadFolder', - async ( - event, - args: { dirPath?: string; connectionId?: string } - ): Promise => { - const dirPath = validateRequiredString(args?.dirPath, 'dirPath') - const connectionId = validateRequiredString(args?.connectionId, 'connectionId') - const provider = requireSshFilesystemProvider(connectionId) - if (!provider.downloadFolder) { - throw new Error( - 'Remote folder download is unavailable. Reconnect the SSH target and retry.' - ) - } - const abortController = new AbortController() - const abortOnSenderDestroyed = (): void => { - abortController.abort(new Error('Folder download canceled because the window closed')) - } - event.sender.once('destroyed', abortOnSenderDestroyed) - if (event.sender.isDestroyed()) { - abortOnSenderDestroyed() - } - try { - abortController.signal.throwIfAborted() - const remoteBasename = getRuntimePathBasename(dirPath) - const destinationBasename = sanitizeLocalDownloadFilename(remoteBasename) - const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined - // Why: after the local capability/abort checks, open the picker before - // remote tree validation so SSH latency does not delay click feedback. - const dialogOptions: Electron.OpenDialogOptions = { - properties: ['openDirectory', 'createDirectory'] - } - const dialogResult = parentWindow - ? await dialog.showOpenDialog(parentWindow, dialogOptions) - : await dialog.showOpenDialog(dialogOptions) - const destinationParent = dialogResult.filePaths?.[0] - if (dialogResult.canceled || !destinationParent) { - return { canceled: true } - } - abortController.signal.throwIfAborted() - - const destinationPath = join(destinationParent, destinationBasename) - await assertDownloadFolderDestinationAvailable(destinationPath) - - const tempPath = createSiblingTransferPath(destinationPath, 'download') - try { - await provider.downloadFolder(dirPath, tempPath, { signal: abortController.signal }) - abortController.signal.throwIfAborted() - await promoteLocalDownloadedFolder(tempPath, destinationPath, abortController.signal) - return { canceled: false, destinationPath } - } finally { - await cleanupLocalTransferDirectory(tempPath) - } - } finally { - event.sender.removeListener('destroyed', abortOnSenderDestroyed) - } - } - ) -} diff --git a/src/main/ipc/filesystem-download-lifetime.test.ts b/src/main/ipc/filesystem-download-lifetime.test.ts index 037c3329be3..97a8f662c6c 100644 --- a/src/main/ipc/filesystem-download-lifetime.test.ts +++ b/src/main/ipc/filesystem-download-lifetime.test.ts @@ -20,10 +20,6 @@ vi.mock('electron', () => ({ } })) vi.mock('node:fs/promises', () => ({ ...mocks, writeFile: vi.fn() })) -vi.mock('./filesystem-download-folder', () => ({ - registerFilesystemDownloadFolderHandlers: vi.fn() -})) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ requireSshFilesystemProvider: vi.fn() })) import { createFilesystemHandlerContext } from './filesystem/filesystem-handler-context' import { registerFilesystemDownloadHandlers } from './filesystem/filesystem-download-handlers' diff --git a/src/main/ipc/filesystem-download-transfers.test.ts b/src/main/ipc/filesystem-download-transfers.test.ts index f8e79bc559c..1e56e9d79c8 100644 --- a/src/main/ipc/filesystem-download-transfers.test.ts +++ b/src/main/ipc/filesystem-download-transfers.test.ts @@ -11,8 +11,7 @@ import { openMock, renameMock, rmMock, - getSshFilesystemProviderMock, - promoteLocalDownloadedFolderMock, + writeFileMock, resetFilesystemIpcMocks } from './filesystem-test-harness' @@ -26,10 +25,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -43,10 +38,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock @@ -113,142 +104,52 @@ describe('registerFilesystemHandlers', () => { expect(showSaveDialogMock).not.toHaveBeenCalled() }) - it('rejects remote download directories before opening a dialog', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), - downloadFile: vi.fn() - } - getSshFilesystemProviderMock.mockReturnValue(provider) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:downloadFile')!( - { sender: {} }, - { - filePath: '/remote/src', - connectionId: 'ssh-1' - } - ) - ).rejects.toThrow('Cannot download a directory') - - expect(showSaveDialogMock).not.toHaveBeenCalled() - expect(provider.downloadFile).not.toHaveBeenCalled() - }) - - it('returns canceled remote downloads without transferring', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }), - downloadFile: vi.fn() - } - getSshFilesystemProviderMock.mockReturnValue(provider) - showSaveDialogMock.mockResolvedValue({ canceled: true }) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:downloadFile')!( - { sender: {} }, - { - filePath: '/remote/report.pdf', - connectionId: 'ssh-1' - } - ) - ).resolves.toEqual({ canceled: true }) - - expect(showSaveDialogMock).toHaveBeenCalledWith({ defaultPath: 'report.pdf' }) - expect(statMock).not.toHaveBeenCalled() - expect(provider.downloadFile).not.toHaveBeenCalled() - }) - - it('parents the remote download save dialog and sanitizes reserved filename suggestions', async () => { + it('parents the save dialog and sanitizes reserved filename suggestions', async () => { const parentWindow = { id: 7 } const sender = { id: 42 } - const provider = { - stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }), - downloadFile: vi.fn() - } - getSshFilesystemProviderMock.mockReturnValue(provider) fromWebContentsMock.mockReturnValue(parentWindow) showSaveDialogMock.mockResolvedValue({ canceled: true }) registerFilesystemHandlers(store as never) - await handlers.get('fs:downloadFile')!( - { sender }, - { - filePath: 'C:\\repo\\CON.txt', - connectionId: 'ssh-1' - } - ) + await expect( + handlers.get('fs:saveDownloadedFile')!({ sender }, { suggestedName: 'CON.txt', content: '' }) + ).resolves.toEqual({ canceled: true }) expect(fromWebContentsMock).toHaveBeenCalledWith(sender) expect(showSaveDialogMock).toHaveBeenCalledWith(parentWindow, { defaultPath: 'download' }) + expect(writeFileMock).not.toHaveBeenCalled() }) - it('rejects remote downloads when raw provider transfer is unavailable', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:downloadFile')!( - { sender: {} }, - { - filePath: '/remote/file.txt', - connectionId: 'ssh-1' - } - ) - ).rejects.toThrow('Remote file download is unavailable. Reconnect the SSH target and retry.') - - expect(showSaveDialogMock).not.toHaveBeenCalled() - }) - - it('rejects selected local directories before transferring a remote download', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }), - downloadFile: vi.fn() - } - getSshFilesystemProviderMock.mockReturnValue(provider) + it('rejects selected local directories before writing a download', async () => { showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' }) statMock.mockResolvedValue({ isDirectory: () => true }) registerFilesystemHandlers(store as never) await expect( - handlers.get('fs:downloadFile')!( + handlers.get('fs:saveDownloadedFile')!( { sender: {} }, - { - filePath: '/remote/report.pdf', - connectionId: 'ssh-1' - } + { suggestedName: 'report.pdf', content: 'hello' } ) ).rejects.toThrow('Cannot download to a directory') - expect(provider.downloadFile).not.toHaveBeenCalled() + expect(writeFileMock).not.toHaveBeenCalled() }) - it('downloads to a temp sibling then promotes a new destination', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }), - downloadFile: vi.fn().mockResolvedValue(undefined) - } - getSshFilesystemProviderMock.mockReturnValue(provider) + it('writes to a temp sibling then promotes a new destination', async () => { showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' }) statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + writeFileMock.mockResolvedValue(undefined) registerFilesystemHandlers(store as never) await expect( - handlers.get('fs:downloadFile')!( + handlers.get('fs:saveDownloadedFile')!( { sender: {} }, - { - filePath: '/remote/report.pdf', - connectionId: 'ssh-1' - } + { suggestedName: 'report.pdf', content: 'hello' } ) ).resolves.toEqual({ canceled: false, destinationPath: '/downloads/report.pdf' }) - const tempPath = provider.downloadFile.mock.calls[0][1] + const tempPath = writeFileMock.mock.calls[0][0] expect(path.dirname(tempPath)).toBe(path.normalize('/downloads')) - expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath) expect(renameMock).toHaveBeenCalledWith(tempPath, '/downloads/report.pdf') expect(rmMock).not.toHaveBeenCalledWith(tempPath, expect.anything()) }) @@ -316,93 +217,65 @@ describe('registerFilesystemHandlers', () => { expect(renameMock).not.toHaveBeenCalled() }) - it('cleans up the temp sibling when remote download transfer fails', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }), - downloadFile: vi.fn().mockRejectedValue(new Error('transfer failed')) - } - getSshFilesystemProviderMock.mockReturnValue(provider) + it('cleans up the temp sibling when writing a download fails', async () => { showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' }) statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + writeFileMock.mockRejectedValue(new Error('write failed')) registerFilesystemHandlers(store as never) await expect( - handlers.get('fs:downloadFile')!( + handlers.get('fs:saveDownloadedFile')!( { sender: {} }, - { - filePath: '/remote/report.pdf', - connectionId: 'ssh-1' - } + { suggestedName: 'report.pdf', content: 'hello' } ) - ).rejects.toThrow('transfer failed') + ).rejects.toThrow('write failed') - const tempPath = provider.downloadFile.mock.calls[0][1] + const tempPath = writeFileMock.mock.calls[0][0] expect(renameMock).not.toHaveBeenCalled() expect(rmMock).toHaveBeenCalledWith(tempPath, { force: true }) }) it('fails rather than overwriting a destination that appears after the dialog', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }), - downloadFile: vi.fn().mockResolvedValue(undefined) - } - getSshFilesystemProviderMock.mockReturnValue(provider) showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' }) statMock .mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' })) .mockResolvedValueOnce({ isDirectory: () => false }) + writeFileMock.mockResolvedValue(undefined) registerFilesystemHandlers(store as never) await expect( - handlers.get('fs:downloadFile')!( + handlers.get('fs:saveDownloadedFile')!( { sender: {} }, - { - filePath: '/remote/report.pdf', - connectionId: 'ssh-1' - } + { suggestedName: 'report.pdf', content: 'hello' } ) ).rejects.toThrow('Destination file appeared before download completed') - const tempPath = provider.downloadFile.mock.calls[0][1] + const tempPath = writeFileMock.mock.calls[0][0] expect(renameMock).not.toHaveBeenCalled() expect(rmMock).toHaveBeenCalledWith(tempPath, { force: true }) }) it('uses a backup swap when overwriting an existing destination', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 10, type: 'file', mtime: 123 }), - downloadFile: vi.fn().mockResolvedValue(undefined) - } - getSshFilesystemProviderMock.mockReturnValue(provider) showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: '/downloads/report.pdf' }) statMock.mockResolvedValue({ isDirectory: () => false }) + writeFileMock.mockResolvedValue(undefined) registerFilesystemHandlers(store as never) await expect( - handlers.get('fs:downloadFile')!( + handlers.get('fs:saveDownloadedFile')!( { sender: {} }, - { - filePath: '/remote/report.pdf', - connectionId: 'ssh-1' - } + { suggestedName: 'report.pdf', content: 'hello' } ) ).resolves.toEqual({ canceled: false, destinationPath: '/downloads/report.pdf' }) - const tempPath = provider.downloadFile.mock.calls[0][1] + const tempPath = writeFileMock.mock.calls[0][0] const backupPath = renameMock.mock.calls[0][1] expect(renameMock.mock.calls[0]).toEqual(['/downloads/report.pdf', backupPath]) expect(renameMock.mock.calls[1]).toEqual([tempPath, '/downloads/report.pdf']) expect(rmMock).toHaveBeenCalledWith(backupPath, { force: true }) }) - it('downloads remote folders into a temporary sibling before promotion', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), - downloadFolder: vi.fn().mockResolvedValue(undefined) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] }) - statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + it('refuses remote folder downloads before opening a picker', async () => { registerFilesystemHandlers(store as never) await expect( @@ -410,208 +283,10 @@ describe('registerFilesystemHandlers', () => { dirPath: '/remote/src', connectionId: 'ssh-1' }) - ).resolves.toEqual({ canceled: false, destinationPath: path.join('/downloads', 'src') }) - - const tempPath = provider.downloadFolder.mock.calls[0][1] - expect(path.dirname(tempPath)).toBe(path.normalize('/downloads')) - expect(showOpenDialogMock).toHaveBeenCalledWith({ - properties: ['openDirectory', 'createDirectory'] - }) - expect(provider.downloadFolder).toHaveBeenCalledWith( - '/remote/src', - tempPath, - expect.objectContaining({ signal: expect.anything() }) + ).rejects.toThrow( + 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' ) - expect(folderDownloadSender.listenerCount('destroyed')).toBe(0) - expect(promoteLocalDownloadedFolderMock).toHaveBeenCalledWith( - tempPath, - path.join('/downloads', 'src'), - expect.anything() - ) - }) - - it('returns canceled remote folder downloads without transferring', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), - downloadFolder: vi.fn() - } - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockResolvedValue({ canceled: true, filePaths: [] }) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/src', - connectionId: 'ssh-1' - }) - ).resolves.toEqual({ canceled: true }) - - expect(provider.downloadFolder).not.toHaveBeenCalled() - }) - - it('aborts before opening the folder picker when the renderer is already destroyed', async () => { - const provider = { - downloadFolder: vi.fn() - } - getSshFilesystemProviderMock.mockReturnValue(provider) - folderDownloadSender.isDestroyed.mockReturnValue(true) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/src', - connectionId: 'ssh-1' - }) - ).rejects.toThrow('window closed') expect(showOpenDialogMock).not.toHaveBeenCalled() - expect(provider.downloadFolder).not.toHaveBeenCalled() - expect(folderDownloadSender.listenerCount('destroyed')).toBe(0) - }) - - it('opens the folder picker before SSH folder validation', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), - downloadFolder: vi.fn().mockResolvedValue(undefined) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockImplementation(async () => { - expect(provider.downloadFolder).not.toHaveBeenCalled() - return { canceled: false, filePaths: ['/downloads'] } - }) - statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/src', - connectionId: 'ssh-1' - }) - ).resolves.toEqual({ canceled: false, destinationPath: path.join('/downloads', 'src') }) - - expect(provider.downloadFolder).toHaveBeenCalledTimes(1) - }) - - it('rejects remote folder downloads when the destination folder already exists', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), - downloadFolder: vi.fn() - } - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] }) - statMock.mockResolvedValue({ isDirectory: () => true }) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/src', - connectionId: 'ssh-1' - }) - ).rejects.toThrow('Destination folder already exists') - - expect(provider.downloadFolder).not.toHaveBeenCalled() - expect(promoteLocalDownloadedFolderMock).not.toHaveBeenCalled() - }) - - it('rejects remote folder downloads when the SSH provider cannot transfer folders', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] }) - statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/src', - connectionId: 'ssh-1' - }) - ).rejects.toThrow('Remote folder download is unavailable') - - expect(showOpenDialogMock).not.toHaveBeenCalled() - expect(promoteLocalDownloadedFolderMock).not.toHaveBeenCalled() - }) - - it('cleans up a temporary remote folder download when transfer fails', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), - downloadFolder: vi.fn().mockRejectedValue(new Error('transfer failed')) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] }) - statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/src', - connectionId: 'ssh-1' - }) - ).rejects.toThrow('transfer failed') - - const tempPath = provider.downloadFolder.mock.calls[0][1] - expect(promoteLocalDownloadedFolderMock).not.toHaveBeenCalled() - expect(rmMock).toHaveBeenCalledWith(tempPath, { recursive: true, force: true }) - }) - - it('logs a recursive temporary-folder cleanup failure without masking the transfer error', async () => { - const provider = { - downloadFolder: vi.fn().mockRejectedValue(new Error('transfer failed')) - } - const cleanupError = new Error('cleanup denied') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] }) - statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - rmMock.mockRejectedValueOnce(cleanupError) - registerFilesystemHandlers(store as never) - - try { - await expect( - handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/src', - connectionId: 'ssh-1' - }) - ).rejects.toThrow('transfer failed') - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to remove temporary folder download'), - cleanupError - ) - } finally { - warn.mockRestore() - } - }) - - it('aborts and cleans up a remote folder download when its renderer closes', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), - downloadFolder: vi.fn( - (_source: string, _destination: string, options?: { signal?: AbortSignal }) => - new Promise((_resolve, reject) => { - options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { - once: true - }) - }) - ) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: ['/downloads'] }) - statMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - registerFilesystemHandlers(store as never) - - const result = handlers.get('fs:downloadFolder')!(folderDownloadEvent, { - dirPath: '/remote/src', - connectionId: 'ssh-1' - }) - await vi.waitFor(() => expect(provider.downloadFolder).toHaveBeenCalledTimes(1)) - folderDownloadSender.emit('destroyed') - - await expect(result).rejects.toThrow('window closed') - const tempPath = provider.downloadFolder.mock.calls[0][1] - expect(promoteLocalDownloadedFolderMock).not.toHaveBeenCalled() - expect(rmMock).toHaveBeenCalledWith(tempPath, { recursive: true, force: true }) - expect(folderDownloadSender.listenerCount('destroyed')).toBe(0) }) }) diff --git a/src/main/ipc/filesystem-git-commit-dispatch.test.ts b/src/main/ipc/filesystem-git-commit-dispatch.test.ts index cecc7c089f7..d09d8d96e00 100644 --- a/src/main/ipc/filesystem-git-commit-dispatch.test.ts +++ b/src/main/ipc/filesystem-git-commit-dispatch.test.ts @@ -6,7 +6,6 @@ import { store, WORKTREE_FEATURE_PATH, commitChangesMock, - getSshGitProviderMock, resolveCommitMessageSettingsMock, generatePullRequestFieldsFromContextMock, getPullRequestDraftContextMock, @@ -25,10 +24,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -42,10 +37,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock @@ -65,6 +56,7 @@ vi.mock( import { registerFilesystemHandlers } from './filesystem' import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' +import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error' describe('registerFilesystemHandlers', () => { beforeEach(() => { @@ -93,7 +85,6 @@ describe('registerFilesystemHandlers', () => { it('refuses ssh git:commit as not served instead of running local commitChanges', async () => { const sshCommitMock = vi.fn().mockResolvedValue({ success: true }) - getSshGitProviderMock.mockReturnValue({ commit: sshCommitMock }) registerFilesystemHandlers(store as never) @@ -113,7 +104,6 @@ describe('registerFilesystemHandlers', () => { it('rejects git:remoteCommitUrl with a short hash before SSH dispatch', async () => { const sshRemoteCommitUrlMock = vi.fn() - getSshGitProviderMock.mockReturnValue({ getRemoteCommitUrl: sshRemoteCommitUrlMock }) registerFilesystemHandlers(store as never) @@ -143,7 +133,6 @@ describe('registerFilesystemHandlers', () => { it('rejects git:commit with whitespace-only message before SSH dispatch', async () => { const sshCommitMock = vi.fn().mockResolvedValue({ success: true }) - getSshGitProviderMock.mockReturnValue({ commit: sshCommitMock }) registerFilesystemHandlers(store as never) @@ -224,31 +213,18 @@ describe('registerFilesystemHandlers pull request field generation', () => { ) }) - it('enriches the SSH pull-request context from host meta using the remote path', async () => { - const worktreeId = 'repo-1::/remote/repo' - getSshGitProviderMock.mockReturnValue({ - exec: vi.fn(), - executeCommitMessagePlan: vi.fn() - }) - const linkedStore = { - ...store, - getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 77 } : undefined) - } + it('refuses SSH pull-request generation as not served without preparing a local branch', async () => { + registerFilesystemHandlers(store as never) - registerFilesystemHandlers(linkedStore as never) - - await handlers.get('git:generatePullRequestFields')!(null, { - ...PULL_REQUEST_ARGS, - worktreePath: '/remote/repo', - worktreeId, - connectionId: 'conn-1' - }) - - expect(generatePullRequestFieldsFromContextMock).toHaveBeenCalledWith( - { ...PULL_REQUEST_CONTEXT, linkedIssue: 77 }, - params, - expect.objectContaining({ kind: 'remote' }) - ) + await expect( + handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: '/remote/repo', + connectionId: 'conn-1' + }) + ).resolves.toEqual({ success: false, error: SSH_HOST_NOT_SERVED_MESSAGE }) + expect(getPullRequestDraftContextMock).not.toHaveBeenCalled() + expect(loadPullRequestLinkedIssueMock).not.toHaveBeenCalled() }) it('ignores a pull-request worktree id that does not own the requested path', async () => { @@ -271,15 +247,10 @@ describe('registerFilesystemHandlers pull request field generation', () => { ) }) - it('forwards PR-context command limits through local and SSH adapters', async () => { + it('forwards PR-context command limits through the local adapter', async () => { const localGitExec = vi .spyOn(gitRunner, 'gitExecFileAsync') .mockResolvedValue({ stdout: '', stderr: '' }) - const sshExec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - getSshGitProviderMock.mockReturnValue({ - exec: sshExec, - executeCommitMessagePlan: vi.fn() - }) getPullRequestDraftContextMock.mockImplementation(async (execute) => { await execute(['show-ref', '--verify'], { maxBuffer: 123, timeoutMs: 456 }) await execute(['legacy-probe'], { timeout: 789 }) @@ -303,19 +274,6 @@ describe('registerFilesystemHandlers pull request field generation', () => { cwd: WORKTREE_FEATURE_PATH, timeout: 789 }) - - await handlers.get('git:generatePullRequestFields')!(null, { - ...PULL_REQUEST_ARGS, - worktreePath: '/remote/repo', - connectionId: 'conn-1' - }) - - expect(sshExec).toHaveBeenCalledWith(['show-ref', '--verify'], '/remote/repo', { - timeoutMs: 456 - }) - expect(sshExec).toHaveBeenCalledWith(['legacy-probe'], '/remote/repo', { - timeoutMs: 789 - }) } finally { localGitExec.mockRestore() } diff --git a/src/main/ipc/filesystem-git-status-staging.test.ts b/src/main/ipc/filesystem-git-status-staging.test.ts index af7a8e7d0d5..dea7d29d00a 100644 --- a/src/main/ipc/filesystem-git-status-staging.test.ts +++ b/src/main/ipc/filesystem-git-status-staging.test.ts @@ -18,7 +18,6 @@ import { discardChangesMock, checkIgnoredPathsMock, listWorktreesMock, - getSshGitProviderMock, resetFilesystemIpcMocks } from './filesystem-test-harness' @@ -32,10 +31,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -49,10 +44,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock @@ -338,7 +329,6 @@ describe('registerFilesystemHandlers', () => { }) it('refuses SSH git IPC with the not-served error and never runs it locally', async () => { - getSshGitProviderMock.mockReturnValue({ getStatus: vi.fn(), abortMerge: vi.fn() }) registerFilesystemHandlers(store as never) for (const channel of ['git:status', 'git:abortMerge', 'git:abortRebase']) { diff --git a/src/main/ipc/filesystem-import-ssh-directory.ts b/src/main/ipc/filesystem-import-ssh-directory.ts deleted file mode 100644 index d4dc794a7cc..00000000000 --- a/src/main/ipc/filesystem-import-ssh-directory.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { lstat, readdir, realpath } from 'node:fs/promises' -import { isAbsolute, join, relative, sep } from 'node:path' -import type { FileUploadSession, IFilesystemProvider } from '../providers/types' -import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform' - -export async function captureLocalUploadRoot( - sourcePath: string, - sourceStat: Awaited> -): Promise { - const rootRealPath = await realpath(sourcePath) - const rootRealStat = await lstat(rootRealPath) - if ( - statIdentityPartChanged(sourceStat.ino, rootRealStat.ino) || - statIdentityPartChanged(sourceStat.dev, rootRealStat.dev) || - !rootRealStat.isDirectory() - ) { - throw new Error(`Upload source changed while being inspected: ${sourcePath}`) - } - return rootRealPath -} - -export async function preScanSshImportDirectory( - dirPath: string, - remotePathFlavor: RemotePathFlavor -): Promise { - const entries = await readdir(dirPath, { withFileTypes: true }) - for (const entry of entries) { - assertSafeRemotePathSegment(entry.name, remotePathFlavor) - if (entry.isSymbolicLink()) { - return true - } - if (entry.isDirectory()) { - const childPath = join(dirPath, entry.name) - if (await preScanSshImportDirectory(childPath, remotePathFlavor)) { - return true - } - } - } - return false -} - -export async function uploadSshImportDirectory( - provider: IFilesystemProvider, - uploadSession: FileUploadSession, - localDir: string, - remoteDir: string, - rootRealPath: string, - remotePathFlavor: RemotePathFlavor, - assertCurrent?: () => void -): Promise { - await assertLocalUploadPathInsideRoot(rootRealPath, localDir) - const entries = await readdir(localDir, { withFileTypes: true }) - for (const entry of entries) { - assertSafeRemotePathSegment(entry.name, remotePathFlavor) - const localPath = join(localDir, entry.name) - const remotePath = `${remoteDir}/${entry.name}` - await assertLocalUploadPathInsideRoot(rootRealPath, localPath) - const statResult = await lstat(localPath) - - // Why: skip symlinks and special files even after the up-front pre-scan; - // this closes the TOCTOU gap if one is created during upload. - if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) { - continue - } - - if (statResult.isDirectory()) { - assertCurrent?.() - await provider.createDirNoClobber(remotePath) - await uploadSshImportDirectory( - provider, - uploadSession, - localPath, - remotePath, - rootRealPath, - remotePathFlavor, - assertCurrent - ) - continue - } - assertCurrent?.() - await uploadSession.uploadFile(localPath, remotePath, { exclusive: true }) - } -} - -function statIdentityPartChanged( - left: number | bigint | undefined, - right: number | bigint | undefined -): boolean { - const leftKnown = left !== undefined && left !== 0 && left !== 0n - const rightKnown = right !== undefined && right !== 0 && right !== 0n - return leftKnown && rightKnown && left !== right -} - -async function assertLocalUploadPathInsideRoot( - rootRealPath: string, - candidatePath: string -): Promise { - const candidateRealPath = await realpath(candidatePath) - const relativeToRoot = relative(rootRealPath, candidateRealPath) - if ( - relativeToRoot !== '' && - (relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot)) - ) { - throw new Error(`Upload source escapes selected directory: ${candidatePath}`) - } -} diff --git a/src/main/ipc/filesystem-import-ssh-ops.test.ts b/src/main/ipc/filesystem-import-ssh-ops.test.ts deleted file mode 100644 index afe6519dfc8..00000000000 --- a/src/main/ipc/filesystem-import-ssh-ops.test.ts +++ /dev/null @@ -1,458 +0,0 @@ -import path from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FileUploadSession, IFilesystemProvider } from '../providers/types' -import type * as SshTargetRegistry from '../ssh/ssh-target-registry' - -const handlers = new Map Promise>() -const { - handleMock, - lstatMock, - mkdirMock, - realpathMock, - copyFileMock, - readdirMock, - getConnMgrMock -} = vi.hoisted(() => ({ - handleMock: vi.fn(), - lstatMock: vi.fn(), - mkdirMock: vi.fn(), - realpathMock: vi.fn(), - copyFileMock: vi.fn(), - readdirMock: vi.fn(), - getConnMgrMock: vi.fn() -})) - -vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) -vi.mock('fs/promises', () => ({ - lstat: lstatMock, - mkdir: mkdirMock, - rename: vi.fn(), - writeFile: vi.fn(), - realpath: realpathMock, - copyFile: copyFileMock, - readdir: readdirMock -})) -vi.mock('../ssh/ssh-target-registry', async () => { - const actual = await vi.importActual('../ssh/ssh-target-registry') - return { ...actual, getSshConnectionManager: getConnMgrMock } -}) - -import { registerFilesystemMutationHandlers } from './filesystem-mutations' -import { - advanceSshConnectionGeneration, - resetSshConnectionGenerations -} from '../ssh/ssh-connection-generation' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' - -const store = { - getRepos: () => [ - { - id: 'r1', - path: path.resolve('/workspace/repo'), - displayName: 'repo', - badgeColor: '#000', - addedAt: 0 - } - ], - getSettings: () => ({ workspaceDir: path.resolve('/workspace') }) -} -const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - -function createProvider(uploadSession: FileUploadSession): IFilesystemProvider { - return { - readDir: vi.fn(), - readFile: vi.fn(), - downloadFile: vi.fn(), - openFileUploadSession: vi.fn().mockResolvedValue(uploadSession), - writeFile: vi.fn().mockResolvedValue(undefined), - writeFileBase64: vi.fn(), - writeFileBase64Chunk: vi.fn().mockResolvedValue(undefined), - stat: vi.fn().mockRejectedValue(enoent()), - deletePath: vi.fn().mockResolvedValue(undefined), - createFile: vi.fn(), - createDir: vi.fn().mockResolvedValue(undefined), - createDirNoClobber: vi.fn().mockResolvedValue(undefined), - rename: vi.fn(), - renameNoClobber: vi.fn(), - copy: vi.fn(), - realpath: vi.fn(), - search: vi.fn(), - listFiles: vi.fn(), - watch: vi.fn() - } as unknown as IFilesystemProvider -} - -describe('fs:importExternalPaths — SSH operations', () => { - const destDir = '/home/user/project/src' - const connId = 'ssh-conn-1' - let provider: IFilesystemProvider - let uploadSession: FileUploadSession - const makeConn = () => ({ - getState: () => ({ status: 'connected' }), - sftp: vi.fn() - }) - const mockDir = (p: string): void => { - const rp = path.resolve(p) - lstatMock.mockImplementation(async (x: string) => { - if (x === rp) { - return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } - } - throw enoent() - }) - } - const mockFile = (p: string): void => { - const rp = path.resolve(p) - lstatMock.mockImplementation(async (x: string) => { - if (x === rp) { - return { - size: 12, - ino: 1, - dev: 1, - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - } - throw enoent() - }) - } - const invoke = (args: Record) => - handlers.get('fs:importExternalPaths')!(null, { - ...args, - ...(typeof args.connectionId === 'string' - ? { - expectedSshTargetId: args.expectedSshTargetId ?? args.connectionId, - expectedSshConnectionGeneration: args.expectedSshConnectionGeneration ?? 0 - } - : {}) - }) as Promise<{ - results: Record[] - }> - - beforeEach(() => { - handlers.clear() - ;[ - handleMock, - lstatMock, - mkdirMock, - realpathMock, - copyFileMock, - readdirMock, - getConnMgrMock - ].forEach((m) => m.mockReset()) - handleMock.mockImplementation((ch: string, h: never) => { - handlers.set(ch, h) - }) - realpathMock.mockImplementation(async (p: string) => p) - lstatMock.mockRejectedValue(enoent()) - readdirMock.mockResolvedValue([]) - getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) - uploadSession = { - uploadFile: vi.fn().mockResolvedValue(undefined), - close: vi.fn() - } - provider = createProvider(uploadSession) - registerSshFilesystemProvider(connId, provider) - registerFilesystemMutationHandlers(store as never) - }) - - afterEach(() => { - unregisterSshFilesystemProvider(connId) - resetSshConnectionGenerations() - }) - - it('rejects a staged upload when a restarted HUB reaches the same target counter', async () => { - resetSshConnectionGenerations(71) - const stagedGeneration = advanceSshConnectionGeneration(connId) - const sourcePath = path.resolve('/tmp/dropped/restart.txt') - lstatMock.mockImplementation(async (candidate: string) => { - if (candidate !== sourcePath) { - throw enoent() - } - resetSshConnectionGenerations(72) - advanceSshConnectionGeneration(connId) - return { - size: 12, - ino: 1, - dev: 1, - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - }) - - const { results } = await invoke({ - sourcePaths: [sourcePath], - destDir, - connectionId: connId, - expectedSshTargetId: connId, - expectedSshConnectionGeneration: stagedGeneration - }) - - expect(results[0]).toMatchObject({ - status: 'failed', - reason: 'SSH connection changed; refresh and try again' - }) - expect(uploadSession.uploadFile).not.toHaveBeenCalled() - }) - - it('deconflicts file names via provider stat', async () => { - mockFile('/tmp/dropped/logo.png') - vi.mocked(provider.stat).mockImplementation(async (p: string) => { - if (p === `${destDir}/logo.png`) { - return { type: 'file', size: 1, mtime: 1 } - } - throw enoent() - }) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/logo.png'], - destDir, - connectionId: connId - }) - expect(results[0]).toMatchObject({ - status: 'imported', - destPath: `${destDir}/logo copy.png`, - renamed: true - }) - }) - - it('rejects symlink sources', async () => { - const rp = path.resolve('/tmp/dropped/link.txt') - lstatMock.mockImplementation(async (p: string) => { - if (p === rp) { - return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => true } - } - throw enoent() - }) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/link.txt'], - destDir, - connectionId: connId - }) - expect(results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' }) - }) - - it('handles partial failure with correct per-item results', async () => { - const sources = ['/tmp/dropped/good.txt', '/tmp/dropped/bad.txt', '/tmp/dropped/ok.txt'] - const resolved = new Set(sources.map((s) => path.resolve(s))) - lstatMock.mockImplementation(async (p: string) => { - if (resolved.has(p)) { - return { - size: 12, - ino: 1, - dev: 1, - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - } - throw enoent() - }) - vi.mocked(uploadSession.uploadFile).mockImplementation(async (_localPath, remotePath) => { - if (remotePath.endsWith('/bad.txt')) { - throw new Error('permission denied') - } - }) - - const { results } = await invoke({ sourcePaths: sources, destDir, connectionId: connId }) - expect(results).toHaveLength(3) - expect(results[0]).toMatchObject({ status: 'imported' }) - expect(results[1]).toMatchObject({ status: 'failed', reason: 'permission denied' }) - expect(results[2]).toMatchObject({ status: 'imported' }) - expect(provider.deletePath).not.toHaveBeenCalled() - }) - - it('does not delete a file another client created during an exclusive-upload race', async () => { - mockFile('/tmp/dropped/report.txt') - vi.mocked(uploadSession.uploadFile).mockRejectedValue( - Object.assign(new Error('EEXIST: destination already exists'), { code: 'EEXIST' }) - ) - - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/report.txt'], - destDir, - connectionId: connId - }) - - expect(results[0]).toMatchObject({ - status: 'failed', - reason: 'EEXIST: destination already exists' - }) - // Why: a failed exclusive create does not prove Orca owns the destination. - expect(provider.deletePath).not.toHaveBeenCalled() - }) - - it('uploads directories via provider createDirNoClobber and binary writes', async () => { - const root = path.resolve('/tmp/dropped/assets') - const child = path.join(root, 'logo.png') - lstatMock.mockImplementation(async (p: string) => { - if (p === root) { - return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } - } - if (p === child) { - return { - size: 3, - ino: 1, - dev: 1, - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - } - throw enoent() - }) - readdirMock.mockImplementation(async (p: string) => - p === root - ? [ - { - name: 'logo.png', - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - ] - : [] - ) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/assets'], - destDir, - connectionId: connId - }) - - expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' }) - expect(provider.createDirNoClobber).toHaveBeenCalledWith(`${destDir}/assets`) - expect(uploadSession.uploadFile).toHaveBeenCalledWith(child, `${destDir}/assets/logo.png`, { - exclusive: true - }) - }) - - it('reports per-item failure when deconfliction throws', async () => { - mockFile('/tmp/dropped/file.txt') - vi.mocked(provider.stat).mockRejectedValue(new Error('Remote connection not found')) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/file.txt'], - destDir, - connectionId: connId - }) - expect(results[0]).toMatchObject({ status: 'failed', reason: 'Remote connection not found' }) - }) - - it('reports failure when creating a directory rejects', async () => { - mockDir('/tmp/dropped/mydir') - vi.mocked(provider.createDirNoClobber).mockRejectedValue(new Error('permission denied')) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/mydir'], - destDir, - connectionId: connId - }) - expect(results[0]).toMatchObject({ status: 'failed', reason: 'permission denied' }) - }) - - it('removes a created SSH directory import root when nested upload fails', async () => { - const root = path.resolve('/tmp/dropped/assets') - const child = path.join(root, 'logo.png') - lstatMock.mockImplementation(async (p: string) => { - if (p === root) { - return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } - } - if (p === child) { - return { - size: 3, - ino: 1, - dev: 1, - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - } - throw enoent() - }) - readdirMock.mockImplementation(async (p: string) => - p === root - ? [ - { - name: 'logo.png', - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - ] - : [] - ) - vi.mocked(uploadSession.uploadFile).mockRejectedValue(new Error('disk full')) - - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/assets'], - destDir, - connectionId: connId - }) - - expect(results[0]).toMatchObject({ status: 'failed', reason: 'disk full' }) - expect(provider.createDirNoClobber).toHaveBeenCalledWith(`${destDir}/assets`) - expect(provider.deletePath).toHaveBeenCalledWith(`${destDir}/assets`, true) - }) - - it('deconflicts directory names via provider stat', async () => { - mockDir('/tmp/dropped/assets') - vi.mocked(provider.stat).mockImplementation(async (p: string) => { - if (p === `${destDir}/assets`) { - return { type: 'directory', size: 1, mtime: 1 } - } - throw enoent() - }) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/assets'], - destDir, - connectionId: connId - }) - expect(results[0]).toMatchObject({ - status: 'imported', - destPath: `${destDir}/assets copy`, - renamed: true - }) - }) - - it('rejects directory containing nested symlinks', async () => { - mockDir('/tmp/dropped/project') - const rd = path.resolve('/tmp/dropped/project') - readdirMock.mockImplementation(async (p: string) => { - if (p === rd) { - return [ - { - name: 'l.txt', - isFile: () => false, - isDirectory: () => false, - isSymbolicLink: () => true - } - ] - } - return [] - }) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/project'], - destDir, - connectionId: connId - }) - expect(results[0]).toMatchObject({ status: 'skipped', reason: 'symlink' }) - expect(uploadSession.uploadFile).not.toHaveBeenCalled() - }) - - it('reports skipped when source lstat returns EACCES', async () => { - const rp = path.resolve('/tmp/dropped/secret.txt') - lstatMock.mockImplementation(async (p: string) => { - if (p === rp) { - throw Object.assign(new Error('EACCES'), { code: 'EACCES' }) - } - throw enoent() - }) - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/secret.txt'], - destDir, - connectionId: connId - }) - expect(results[0]).toMatchObject({ status: 'skipped', reason: 'permission-denied' }) - }) -}) diff --git a/src/main/ipc/filesystem-import-ssh-path-safety.test.ts b/src/main/ipc/filesystem-import-ssh-path-safety.test.ts deleted file mode 100644 index 6e1841facee..00000000000 --- a/src/main/ipc/filesystem-import-ssh-path-safety.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import path from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { FileUploadSession, IFilesystemProvider } from '../providers/types' -import type * as SshTargetRegistry from '../ssh/ssh-target-registry' - -const { getConnMgrMock, lstatMock, providerMock, readdirMock, realpathMock } = vi.hoisted(() => ({ - getConnMgrMock: vi.fn(), - lstatMock: vi.fn(), - providerMock: vi.fn(), - readdirMock: vi.fn(), - realpathMock: vi.fn() -})) - -vi.mock('node:fs/promises', () => ({ - lstat: lstatMock, - readdir: readdirMock, - realpath: realpathMock -})) -vi.mock('./filesystem-path-containment', () => ({ - isENOENT: (error: NodeJS.ErrnoException) => error.code === 'ENOENT' -})) -vi.mock('../ssh/ssh-target-registry', async () => { - const actual = await vi.importActual('../ssh/ssh-target-registry') - return { ...actual, getSshConnectionManager: getConnMgrMock } -}) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - requireSshFilesystemProvider: providerMock -})) - -import { importExternalPathsSsh } from './filesystem-import-ssh' - -function createProvider(uploadSession: FileUploadSession): IFilesystemProvider { - const missing = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - return { - stat: vi.fn().mockRejectedValue(missing), - createDirNoClobber: vi.fn().mockResolvedValue(undefined), - deletePath: vi.fn().mockResolvedValue(undefined), - openFileUploadSession: vi.fn().mockResolvedValue(uploadSession) - } as unknown as IFilesystemProvider -} - -describe('SSH import remote path safety', () => { - const connectionId = 'ssh-windows' - const destDir = 'C:/Users/me/project/.orca/drops' - let provider: IFilesystemProvider - let uploadSession: FileUploadSession - - beforeEach(() => { - vi.clearAllMocks() - getConnMgrMock.mockReturnValue({ - getConnection: () => ({ getState: () => ({ status: 'connected' }) }) - }) - uploadSession = { - uploadFile: vi.fn().mockResolvedValue(undefined), - close: vi.fn() - } - provider = createProvider(uploadSession) - providerMock.mockReturnValue(provider) - readdirMock.mockResolvedValue([]) - realpathMock.mockImplementation(async (value: string) => value) - }) - - it.runIf(process.platform !== 'win32')( - 'rejects a POSIX filename containing Windows traversal before remote stat', - async () => { - const sourcePath = '/tmp/..\\..\\.ssh\\orca_drop' - - const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) - - expect(results[0]).toMatchObject({ - status: 'failed', - reason: expect.stringContaining('Unsafe') - }) - expect(lstatMock).not.toHaveBeenCalled() - expect(provider.stat).not.toHaveBeenCalled() - expect(provider.createDirNoClobber).not.toHaveBeenCalled() - expect(uploadSession.uploadFile).not.toHaveBeenCalled() - expect(provider.deletePath).not.toHaveBeenCalled() - } - ) - - it.each(['report.txt:orca', 'NUL', 'trailing.', 'question?.txt'])( - 'rejects Windows-special top-level name %j before remote stat', - async (name) => { - const { results } = await importExternalPathsSsh([`/tmp/${name}`], destDir, connectionId) - - expect(results[0]).toMatchObject({ - status: 'failed', - reason: expect.stringContaining('Unsafe') - }) - expect(lstatMock).not.toHaveBeenCalled() - expect(provider.stat).not.toHaveBeenCalled() - expect(uploadSession.uploadFile).not.toHaveBeenCalled() - } - ) - - it('rejects a nested Windows traversal name before creating the import root', async () => { - const sourcePath = path.resolve('/tmp/assets') - lstatMock.mockResolvedValue({ - isFile: () => false, - isDirectory: () => true, - isSymbolicLink: () => false - }) - readdirMock.mockResolvedValue([ - { - name: '..\\..\\.ssh\\orca_drop', - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - ]) - - const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) - - expect(results[0]).toMatchObject({ - status: 'failed', - reason: expect.stringContaining('Unsafe') - }) - expect(provider.stat).not.toHaveBeenCalled() - expect(provider.createDirNoClobber).not.toHaveBeenCalled() - expect(uploadSession.uploadFile).not.toHaveBeenCalled() - expect(provider.deletePath).not.toHaveBeenCalled() - }) - - it('keeps exclusive creation for safe file names', async () => { - const sourcePath = path.resolve('/tmp/report.txt') - lstatMock.mockResolvedValue({ - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - }) - - const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) - - expect(results[0]).toMatchObject({ status: 'imported', destPath: `${destDir}/report.txt` }) - expect(uploadSession.uploadFile).toHaveBeenCalledWith(sourcePath, `${destDir}/report.txt`, { - exclusive: true - }) - }) - - it.runIf(process.platform !== 'win32')( - 'preserves backslashes in valid POSIX names for POSIX remotes', - async () => { - const sourcePath = path.resolve('/tmp/notes\\2026.txt') - const posixDestDir = '/home/me/project' - lstatMock.mockResolvedValue({ - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - }) - - const { results } = await importExternalPathsSsh([sourcePath], posixDestDir, connectionId) - - expect(results[0]).toMatchObject({ - status: 'imported', - destPath: `${posixDestDir}/notes\\2026.txt` - }) - expect(uploadSession.uploadFile).toHaveBeenCalledWith( - sourcePath, - `${posixDestDir}/notes\\2026.txt`, - { exclusive: true } - ) - } - ) - - it('streams an N-file directory through guarded per-file uploads', async () => { - const sourcePath = path.resolve('/tmp/assets') - const names = ['one.txt', 'two.txt', 'three.txt'] - const entries = names.map((name) => ({ - name, - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - })) - lstatMock.mockImplementation(async (value: string) => - value === sourcePath - ? { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } - : { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } - ) - readdirMock.mockResolvedValue(entries) - - const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) - - expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' }) - expect(uploadSession.uploadFile).toHaveBeenCalledTimes(3) - for (const name of names) { - expect(uploadSession.uploadFile).toHaveBeenCalledWith( - path.join(sourcePath, name), - `${destDir}/assets/${name}`, - { exclusive: true } - ) - } - }) - - it('skips special entries without failing the directory import', async () => { - const sourcePath = path.resolve('/tmp/assets') - const regularPath = path.join(sourcePath, 'report.txt') - const socketPath = path.join(sourcePath, 'server.sock') - const entries = [ - { - name: 'report.txt', - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - }, - { - name: 'server.sock', - isFile: () => false, - isDirectory: () => false, - isSymbolicLink: () => false - } - ] - lstatMock.mockImplementation(async (value: string) => { - if (value === sourcePath) { - return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false } - } - if (value === regularPath) { - return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false } - } - expect(value).toBe(socketPath) - return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => false } - }) - readdirMock.mockResolvedValue(entries) - - const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) - - expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' }) - expect(uploadSession.uploadFile).toHaveBeenCalledOnce() - expect(uploadSession.uploadFile).toHaveBeenCalledWith( - regularPath, - `${destDir}/assets/report.txt`, - { exclusive: true } - ) - }) - - it('rejects a selected root replaced after its realpath is captured', async () => { - const sourcePath = path.resolve('/tmp/assets') - lstatMock.mockResolvedValue({ - isFile: () => false, - isDirectory: () => true, - isSymbolicLink: () => false - }) - realpathMock.mockResolvedValueOnce(sourcePath).mockResolvedValueOnce('/private/replacement') - - const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) - - expect(results[0]).toMatchObject({ - status: 'failed', - reason: expect.stringContaining('escapes selected directory') - }) - expect(uploadSession.uploadFile).not.toHaveBeenCalled() - expect(provider.deletePath).toHaveBeenCalledWith(`${destDir}/assets`, true) - }) - - it('rejects a selected root replaced while its realpath is captured', async () => { - const sourcePath = path.resolve('/tmp/assets') - const directoryStat = (ino: number) => ({ - dev: 1, - ino, - isFile: () => false, - isDirectory: () => true, - isSymbolicLink: () => false - }) - lstatMock.mockResolvedValueOnce(directoryStat(1)).mockResolvedValueOnce(directoryStat(2)) - realpathMock.mockResolvedValue('/private/replacement') - - const { results } = await importExternalPathsSsh([sourcePath], destDir, connectionId) - - expect(results[0]).toMatchObject({ - status: 'failed', - reason: expect.stringContaining('changed while being inspected') - }) - expect(provider.createDirNoClobber).not.toHaveBeenCalled() - expect(uploadSession.uploadFile).not.toHaveBeenCalled() - expect(provider.deletePath).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/ipc/filesystem-import-ssh-remote-existence.ts b/src/main/ipc/filesystem-import-ssh-remote-existence.ts deleted file mode 100644 index e853f291f80..00000000000 --- a/src/main/ipc/filesystem-import-ssh-remote-existence.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { IFilesystemProvider } from '../providers/types' - -/** Whether a remote path exists; only a definite "missing" answer reads as false. */ -export async function remotePathExists( - provider: IFilesystemProvider, - remotePath: string -): Promise { - try { - await provider.stat(remotePath) - return true - } catch (error) { - if (isRemoteMissingError(error)) { - return false - } - throw error - } -} - -function isRemoteMissingError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false - } - return ( - ('code' in error && error.code === 'ENOENT') || - /\b(ENOENT|ENOTDIR)\b|no such file or directory|cannot find (?:the )?(?:file|path)|(?:file|path) not found/i.test( - error.message - ) - ) -} diff --git a/src/main/ipc/filesystem-import-ssh.test.ts b/src/main/ipc/filesystem-import-ssh.test.ts deleted file mode 100644 index 80c801cfd56..00000000000 --- a/src/main/ipc/filesystem-import-ssh.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import path from 'node:path' -import { constants } from 'node:fs' -import { Readable, Writable } from 'node:stream' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { FileUploadSession, IFilesystemProvider } from '../providers/types' -import type * as SshTargetRegistry from '../ssh/ssh-target-registry' - -const handlers = new Map Promise>() -const { - handleMock, - lstatMock, - mkdirMock, - realpathMock, - copyFileMock, - openMock, - readdirMock, - unlinkMock, - getConnMgrMock -} = vi.hoisted(() => ({ - handleMock: vi.fn(), - lstatMock: vi.fn(), - mkdirMock: vi.fn(), - realpathMock: vi.fn(), - copyFileMock: vi.fn(), - openMock: vi.fn(), - readdirMock: vi.fn(), - unlinkMock: vi.fn(), - getConnMgrMock: vi.fn() -})) - -vi.mock('electron', () => ({ - app: { getPath: () => '/orca-test-user-data' }, - ipcMain: { handle: handleMock } -})) -vi.mock('fs/promises', () => ({ - lstat: lstatMock, - mkdir: mkdirMock, - rename: vi.fn(), - writeFile: vi.fn(), - realpath: realpathMock, - copyFile: copyFileMock, - open: openMock, - readdir: readdirMock, - unlink: unlinkMock, - rm: vi.fn() -})) -vi.mock('../ssh/ssh-target-registry', async () => { - const actual = await vi.importActual('../ssh/ssh-target-registry') - return { ...actual, getSshConnectionManager: getConnMgrMock } -}) - -import { registerFilesystemMutationHandlers } from './filesystem-mutations' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' -import { resetSshConnectionGenerations } from '../ssh/ssh-connection-generation' - -const store = { - getRepos: () => [ - { - id: 'r1', - path: path.resolve('/workspace/repo'), - displayName: 'repo', - badgeColor: '#000', - addedAt: 0 - } - ], - getSettings: () => ({ workspaceDir: path.resolve('/workspace') }) -} -const enoent = (): Error => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) - -function createProvider(uploadSession: FileUploadSession): IFilesystemProvider { - return { - readDir: vi.fn(), - readFile: vi.fn(), - downloadFile: vi.fn(), - openFileUploadSession: vi.fn().mockResolvedValue(uploadSession), - writeFile: vi.fn().mockResolvedValue(undefined), - writeFileBase64: vi.fn(), - writeFileBase64Chunk: vi.fn().mockResolvedValue(undefined), - stat: vi.fn().mockRejectedValue(enoent()), - deletePath: vi.fn().mockResolvedValue(undefined), - createFile: vi.fn(), - createDir: vi.fn().mockResolvedValue(undefined), - createDirNoClobber: vi.fn().mockResolvedValue(undefined), - rename: vi.fn(), - renameNoClobber: vi.fn(), - copy: vi.fn(), - realpath: vi.fn(), - search: vi.fn(), - listFiles: vi.fn(), - watch: vi.fn() - } as unknown as IFilesystemProvider -} - -describe('fs:importExternalPaths — SSH routing & connection', () => { - const destDir = '/home/user/project/src' - const connId = 'ssh-conn-1' - let provider: IFilesystemProvider - let uploadSession: FileUploadSession - - const makeConn = (status = 'connected') => ({ - getState: () => ({ status }), - sftp: vi.fn() - }) - const mockFile = (p: string): void => { - const rp = path.resolve(p) - lstatMock.mockImplementation(async (x: string) => { - if (x === rp) { - return { - size: 12, - ino: 1, - dev: 1, - isFile: () => true, - isDirectory: () => false, - isSymbolicLink: () => false - } - } - throw enoent() - }) - } - const invoke = (args: Record) => - handlers.get('fs:importExternalPaths')!( - null, - typeof args.connectionId === 'string' - ? { - ...args, - expectedSshTargetId: args.connectionId, - expectedSshConnectionGeneration: 0 - } - : args - ) as Promise<{ - results: Record[] - }> - - beforeEach(() => { - handlers.clear() - resetSshConnectionGenerations() - ;[ - handleMock, - lstatMock, - mkdirMock, - realpathMock, - copyFileMock, - openMock, - readdirMock, - unlinkMock, - getConnMgrMock - ].forEach((m) => m.mockReset()) - handleMock.mockImplementation((ch: string, h: never) => { - handlers.set(ch, h) - }) - realpathMock.mockImplementation(async (p: string) => p) - lstatMock.mockRejectedValue(enoent()) - openMock.mockImplementation(async (_p: string, flags: unknown) => { - if (flags === 'wx') { - return { - createWriteStream: () => - new Writable({ - write(_chunk, _encoding, callback) { - callback() - } - }), - close: vi.fn().mockResolvedValue(undefined) - } - } - return { - stat: vi.fn().mockResolvedValue({ - size: 12, - ino: 1, - dev: 1, - isFile: () => true - }), - createReadStream: () => Readable.from([Buffer.from('file-content')]), - close: vi.fn().mockResolvedValue(undefined) - } - }) - unlinkMock.mockResolvedValue(undefined) - uploadSession = { - uploadFile: vi.fn().mockResolvedValue(undefined), - close: vi.fn() - } - provider = createProvider(uploadSession) - registerSshFilesystemProvider(connId, provider) - registerFilesystemMutationHandlers(store as never) - }) - - afterEach(() => { - unregisterSshFilesystemProvider(connId) - }) - - it('routes SSH imports through the filesystem provider when connectionId is present', async () => { - getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) - mockFile('/tmp/dropped/file.txt') - - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/file.txt'], - destDir, - connectionId: connId - }) - - expect(results[0]).toMatchObject({ status: 'imported', kind: 'file' }) - expect(uploadSession.uploadFile).toHaveBeenCalledWith( - path.resolve('/tmp/dropped/file.txt'), - `${destDir}/file.txt`, - { exclusive: true } - ) - expect(uploadSession.close).toHaveBeenCalledOnce() - expect(copyFileMock).not.toHaveBeenCalled() - }) - - it('falls back to local import when connectionId is absent', async () => { - mockFile('/tmp/dropped/file.txt') - const { results } = await invoke({ - sourcePaths: ['/tmp/dropped/file.txt'], - destDir: path.resolve('/workspace/repo/src') - }) - expect(results[0]).toMatchObject({ status: 'imported' }) - expect(openMock).toHaveBeenCalledWith( - path.resolve('/tmp/dropped/file.txt'), - constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) - ) - }) - - it('returns empty results without opening SFTP or requiring a provider', async () => { - unregisterSshFilesystemProvider(connId) - const conn = makeConn() - getConnMgrMock.mockReturnValue({ getConnection: () => conn }) - const { results } = await invoke({ sourcePaths: [], destDir, connectionId: connId }) - expect(results).toHaveLength(0) - expect(conn.sftp).not.toHaveBeenCalled() - }) - - it('throws when connectionId has no matching connection', async () => { - getConnMgrMock.mockReturnValue({ getConnection: () => null }) - await expect( - invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId }) - ).rejects.toThrow('No SSH connection') - }) - - it('throws when connection is reconnecting', async () => { - getConnMgrMock.mockReturnValue({ getConnection: () => makeConn('reconnecting') }) - await expect( - invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId }) - ).rejects.toThrow('reconnecting') - }) - - it('throws when connection is not active', async () => { - getConnMgrMock.mockReturnValue({ getConnection: () => makeConn('disconnected') }) - await expect( - invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId }) - ).rejects.toThrow('not active') - }) - - it('throws when the SSH filesystem provider is unavailable', async () => { - unregisterSshFilesystemProvider(connId) - getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) - await expect( - invoke({ sourcePaths: ['/tmp/x'], destDir, connectionId: connId }) - ).rejects.toThrow('Remote connection dropped') - }) - - it('uploads terminal-drop staging marker through provider writes', async () => { - getConnMgrMock.mockReturnValue({ getConnection: () => makeConn() }) - mockFile('/tmp/dropped/file.txt') - - await invoke({ - sourcePaths: ['/tmp/dropped/file.txt'], - destDir: '/home/user/project/.orca/drops', - connectionId: connId, - ensureDir: true - }) - - expect(provider.createDir).toHaveBeenCalledWith('/home/user/project/.orca') - expect(provider.writeFile).toHaveBeenCalledWith( - '/home/user/project/.orca/.gitignore', - '*\n!.gitignore\n' - ) - expect(provider.createDir).toHaveBeenCalledWith('/home/user/project/.orca/drops') - }) -}) diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts deleted file mode 100644 index c44144460a5..00000000000 --- a/src/main/ipc/filesystem-import-ssh.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { basename, posix, resolve } from 'node:path' -import { classifyImportSource } from './filesystem-import-source-stat' -import { deconflictCopyName } from '../../shared/import-copy-name' -import { getSshConnectionManager } from '../ssh/ssh-target-registry' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import type { FileUploadSession, IFilesystemProvider } from '../providers/types' -import type { ImportItemResult } from '../../shared/filesystem-import-result-types' -import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform' -import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' -import { remotePathExists } from './filesystem-import-ssh-remote-existence' -import { - captureLocalUploadRoot, - preScanSshImportDirectory, - uploadSshImportDirectory -} from './filesystem-import-ssh-directory' -import { errorMessage } from '../../shared/error-message' - -// Why: the SSH import path uses SshFilesystemProvider instead of direct SFTP so -// system-SSH transports (ProxyCommand/ProxyJump/FIDO2) get the same workflows. -export async function importExternalPathsSsh( - sourcePaths: string[], - destDir: string, - connectionId: string, - options?: { ensureDir?: boolean; assertCurrent?: () => void } -): Promise<{ results: ImportItemResult[] }> { - if (sourcePaths.length === 0) { - return { results: [] } - } - - const connManager = getSshConnectionManager() - const conn = connManager?.getConnection(connectionId) - if (!conn) { - throw new Error(`No SSH connection for "${connectionId}"`) - } - - const state = conn.getState() - if (state.status !== 'connected') { - if (state.status === 'reconnecting') { - throw new Error('SSH connection is reconnecting — please try again in a moment') - } - throw new Error('SSH connection is not active — please reconnect and try again') - } - - const provider = requireSshFilesystemProvider(connectionId) - - if (options?.ensureDir) { - // Why: terminal-drop staging needs `${worktree}/.orca/drops` to exist - // before the first upload. .orca/ is reserved as Orca-owned remote state; - // see docs/terminal-drop-ssh.md. - await ensureDropStagingDir(provider, destDir, options.assertCurrent) - } - - const results: ImportItemResult[] = [] - const reservedNames = new Set() - if (!provider.openFileUploadSession) { - throw new Error('Remote file upload is unavailable. Reconnect the SSH target and retry.') - } - options?.assertCurrent?.() - const uploadSession = await provider.openFileUploadSession() - // Why: filename legality follows the remote filesystem, not the client's OS. - const remotePathFlavor: RemotePathFlavor = isWindowsAbsolutePathLike(destDir) - ? 'windows' - : 'posix' - try { - for (const sourcePath of sourcePaths) { - const result = await importOneSourceSsh( - provider, - uploadSession, - sourcePath, - destDir, - reservedNames, - remotePathFlavor, - options?.assertCurrent - ) - results.push(result) - if (result.status === 'imported') { - // Why: destPath is a remote POSIX path (e.g. /home/user/foo/bar.txt). - // Node's basename() uses the OS separator, which on Windows would - // return the entire string instead of just the filename. - reservedNames.add(posix.basename(result.destPath)) - } - } - } finally { - uploadSession.close() - } - - return { results } -} - -async function importOneSourceSsh( - provider: IFilesystemProvider, - uploadSession: FileUploadSession, - sourcePath: string, - destDir: string, - reservedNames: Set, - remotePathFlavor: RemotePathFlavor, - assertCurrent?: () => void -): Promise { - const resolvedSource = resolve(sourcePath) - - const originalName = basename(resolvedSource) - try { - assertSafeRemotePathSegment(originalName, remotePathFlavor) - } catch (error) { - return { - sourcePath, - status: 'failed', - reason: errorMessage(error) - } - } - - const classified = await classifyImportSource(sourcePath, resolvedSource) - if ('rejection' in classified) { - return classified.rejection - } - const sourceStat = classified.stat - const isDir = sourceStat.isDirectory() - - let createdDestDir: string | null = null - try { - const rootRealPath = isDir ? await captureLocalUploadRoot(resolvedSource, sourceStat) : null - if (isDir && (await preScanSshImportDirectory(resolvedSource, remotePathFlavor))) { - return { sourcePath, status: 'skipped', reason: 'symlink' } - } - - // Why: local inspection can outlive a HUB SSH session; revalidate before the first remote write. - assertCurrent?.() - const finalName = await deconflictCopyName(originalName, async (n) => { - assertCurrent?.() - return (await remotePathExists(provider, `${destDir}/${n}`)) || reservedNames.has(n) - }) - const destPath = `${destDir}/${finalName}` - const renamed = finalName !== originalName - - if (isDir) { - assertCurrent?.() - await provider.createDirNoClobber(destPath) - createdDestDir = destPath - await uploadSshImportDirectory( - provider, - uploadSession, - resolvedSource, - destPath, - rootRealPath!, - remotePathFlavor, - assertCurrent - ) - } else { - assertCurrent?.() - await uploadSession.uploadFile(resolvedSource, destPath, { exclusive: true }) - } - - return { - sourcePath, - status: 'imported', - destPath, - kind: isDir ? 'directory' : 'file', - renamed - } - } catch (error) { - if (createdDestDir) { - // Why: local directory imports roll back partial output; SSH imports - // should not leave the no-clobber root after a nested upload failure. - try { - assertCurrent?.() - await provider.deletePath(createdDestDir, true) - } catch { - // Best effort; a replacement session must never inherit cleanup from the retired owner. - } - } - return { - sourcePath, - status: 'failed', - reason: errorMessage(error) - } - } -} - -async function ensureDropStagingDir( - provider: IFilesystemProvider, - destDir: string, - assertCurrent?: () => void -): Promise { - const parent = posix.dirname(destDir) - assertCurrent?.() - await provider.createDir(parent) - const gitignorePath = `${parent}/.gitignore` - assertCurrent?.() - if (!(await remotePathExists(provider, gitignorePath))) { - assertCurrent?.() - await provider.writeFile(gitignorePath, '*\n!.gitignore\n') - } - assertCurrent?.() - await provider.createDir(destDir) -} diff --git a/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts b/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts index 04c697d08dc..e936c6385c0 100644 --- a/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts +++ b/src/main/ipc/filesystem-list-files-handler-name-filter.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' import { handlers, store, resetFilesystemIpcMocks } from './filesystem-test-harness' @@ -13,10 +14,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -30,10 +27,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock @@ -96,4 +89,53 @@ describe('fs:listFiles local name filter', () => { ).resolves.toEqual([]) expect(listQuickOpenFilesMock).not.toHaveBeenCalled() }) + + it('returns no files for an SSH target and never lists locally', async () => { + registerHandlers() + + await expect( + handlers.get('fs:listFiles')!(null, { rootPath: '/home/user/repo', connectionId: 'conn-1' }) + ).resolves.toEqual([]) + expect(listQuickOpenFilesMock).not.toHaveBeenCalled() + }) + + // Why #7721: a workspace switch must stop the previous workspace's full-tree scan. + it.each(['cancel', 'did-navigate', 'render-process-gone', 'destroyed'])( + 'aborts an in-flight file listing on %s (#7721)', + async (eventName) => { + let capturedSignal: AbortSignal | undefined + listQuickOpenFilesMock.mockImplementation( + (_rootPath: string, _store: unknown, _exclude: unknown, signal?: AbortSignal) => + new Promise((_resolve, reject) => { + capturedSignal = signal + signal?.addEventListener('abort', () => reject(new Error('listing cancelled')), { + once: true + }) + }) + ) + registerHandlers() + + // Why: cancellation keys are scoped to the issuing webContents. + const senderEvent = { sender: Object.assign(new EventEmitter(), { id: 7 }) } + const pending = Promise.resolve( + handlers.get('fs:listFiles')!(senderEvent, { rootPath: '/repo', requestToken: 'token-1' }) + ) + + await Promise.resolve() + expect(capturedSignal?.aborted).toBe(false) + if (eventName === 'cancel') { + await handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'token-1' }) + } else { + senderEvent.sender.emit(eventName) + } + expect(capturedSignal?.aborted).toBe(true) + await expect(pending).rejects.toThrow('listing cancelled') + expect(senderEvent.sender.eventNames()).toEqual([]) + + // Unknown or already-settled tokens are a no-op, not an error. + expect(() => + handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'unknown' }) + ).not.toThrow() + } + ) }) diff --git a/src/main/ipc/filesystem-markdown-document-listing.test.ts b/src/main/ipc/filesystem-markdown-document-listing.test.ts index 7430465ecbd..34e412d4de1 100644 --- a/src/main/ipc/filesystem-markdown-document-listing.test.ts +++ b/src/main/ipc/filesystem-markdown-document-listing.test.ts @@ -1,13 +1,13 @@ import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as MarkdownDocumentsModule from './markdown-documents' +import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error' import { handlers, store, WORKTREE_FEATURE_PATH, readdirMock, realpathMock, - getSshFilesystemProviderMock, resetFilesystemIpcMocks } from './filesystem-test-harness' @@ -34,10 +34,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -51,10 +47,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock @@ -161,14 +153,7 @@ describe('registerFilesystemHandlers', () => { ).rejects.toThrow('Access denied') }) - it('lists remote markdown documents through the SSH filesystem provider', async () => { - const provider = { - listFiles: vi - .fn() - .mockResolvedValue(['README.md', 'docs/guide.mdx', '../outside.md', 'src/app.ts']) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - + it('refuses SSH markdown listing as not served and never lists locally', async () => { registerFilesystemHandlers(store as never) await expect( @@ -176,58 +161,8 @@ describe('registerFilesystemHandlers', () => { rootPath: '/home/user/project', connectionId: 'ssh-1' }) - ).resolves.toEqual([ - { - filePath: '/home/user/project/docs/guide.mdx', - relativePath: 'docs/guide.mdx', - basename: 'guide.mdx', - name: 'guide' - }, - { - filePath: '/home/user/project/README.md', - relativePath: 'README.md', - basename: 'README.md', - name: 'README' - } - ]) + ).rejects.toThrow(SSH_HOST_NOT_SERVED_MESSAGE) expect(listMarkdownDocumentsMock).not.toHaveBeenCalled() expect(localOptionsMock).not.toHaveBeenCalled() }) - - it('keeps late Markdown documents from legacy providers with large source inventories', async () => { - const paths = Array.from({ length: 25_002 }, (_, index) => `src/file-${index}.ts`) - paths.push('docs/late.md') - const provider = { listFiles: vi.fn().mockResolvedValue(paths) } - getSshFilesystemProviderMock.mockReturnValue(provider) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the store reads used by filesystem handler registration. - registerFilesystemHandlers(store as never) - await expect( - handlers.get('fs:listMarkdownDocuments')!(null, { - rootPath: '/repo', - connectionId: 'legacy' - }) - ).resolves.toEqual([ - { - filePath: '/repo/docs/late.md', - relativePath: 'docs/late.md', - basename: 'late.md', - name: 'late' - } - ]) - expect(provider.listFiles).toHaveBeenCalledWith('/repo') - }) - - it('still bounds legacy source inventories before constructing Markdown metadata', async () => { - getSshFilesystemProviderMock.mockReturnValue({ - listFiles: vi.fn().mockResolvedValue([`${'x'.repeat(65_537)}.ts`, 'README.md']) - }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture supplies the store reads used by filesystem handler registration. - registerFilesystemHandlers(store as never) - await expect( - handlers.get('fs:listMarkdownDocuments')!(null, { - rootPath: '/repo', - connectionId: 'legacy' - }) - ).rejects.toThrow('File inventory is too large') - }) }) diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts index 36db8630dc5..199e029e178 100644 --- a/src/main/ipc/filesystem-mutations.test.ts +++ b/src/main/ipc/filesystem-mutations.test.ts @@ -29,10 +29,6 @@ vi.mock('fs/promises', () => ({ })) import { registerFilesystemMutationHandlers } from './filesystem-mutations' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' import { resetSshConnectionGenerations, setSshConnectionGeneration @@ -346,107 +342,40 @@ describe('registerFilesystemMutationHandlers', () => { expect(renameMock).toHaveBeenCalledWith(oldPath, newPath) }) - it('routes rename through the SSH no-clobber filesystem provider when a connection is present', async () => { - const renameNoClobber = vi.fn().mockResolvedValue(undefined) - registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) + it('rejects direct SSH rename without target-bound generation provenance', async () => { + await expect( + handlers.get('fs:rename')!(null, { + oldPath: '/home/me/repo/old.ts', + newPath: '/home/me/repo/new.ts', + connectionId: 'ssh-1' + }) + ).rejects.toThrow('SSH connection changed') + }) - try { - await handlers.get('fs:rename')!(null, { + it('rejects equal-generation provenance for another direct SSH target', async () => { + await expect( + handlers.get('fs:rename')!(null, { + oldPath: '/home/me/repo/old.ts', + newPath: '/home/me/repo/new.ts', + connectionId: 'ssh-b', + expectedSshTargetId: 'ssh-a', + expectedSshConnectionGeneration: 0 + }) + ).rejects.toThrow('SSH connection changed') + }) + + it('rejects stale generation provenance for a direct SSH target', async () => { + setSshConnectionGeneration('ssh-1', 8) + + await expect( + handlers.get('fs:rename')!(null, { oldPath: '/home/me/repo/old.ts', newPath: '/home/me/repo/new.ts', connectionId: 'ssh-1', expectedSshTargetId: 'ssh-1', - expectedSshConnectionGeneration: 0 + expectedSshConnectionGeneration: 7 }) - } finally { - unregisterSshFilesystemProvider('ssh-1') - } - - expect(renameNoClobber).toHaveBeenCalledWith('/home/me/repo/old.ts', '/home/me/repo/new.ts') - expect(renameMock).not.toHaveBeenCalled() - }) - - it('propagates SSH no-clobber rename failures', async () => { - const renameNoClobber = vi.fn().mockRejectedValue(new Error('destination exists')) - registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) - - try { - await expect( - handlers.get('fs:rename')!(null, { - oldPath: '/home/me/repo/old.ts', - newPath: '/home/me/repo/new.ts', - connectionId: 'ssh-1', - expectedSshTargetId: 'ssh-1', - expectedSshConnectionGeneration: 0 - }) - ).rejects.toThrow('destination exists') - } finally { - unregisterSshFilesystemProvider('ssh-1') - } - - expect(renameMock).not.toHaveBeenCalled() - }) - - it('rejects direct SSH rename without target-bound generation provenance', async () => { - const renameNoClobber = vi.fn().mockResolvedValue(undefined) - registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) - - try { - await expect( - handlers.get('fs:rename')!(null, { - oldPath: '/home/me/repo/old.ts', - newPath: '/home/me/repo/new.ts', - connectionId: 'ssh-1' - }) - ).rejects.toThrow('SSH connection changed') - } finally { - unregisterSshFilesystemProvider('ssh-1') - } - - expect(renameNoClobber).not.toHaveBeenCalled() - }) - - it('rejects equal-generation provenance for another direct SSH target', async () => { - const renameNoClobber = vi.fn().mockResolvedValue(undefined) - registerSshFilesystemProvider('ssh-b', { renameNoClobber } as never) - - try { - await expect( - handlers.get('fs:rename')!(null, { - oldPath: '/home/me/repo/old.ts', - newPath: '/home/me/repo/new.ts', - connectionId: 'ssh-b', - expectedSshTargetId: 'ssh-a', - expectedSshConnectionGeneration: 0 - }) - ).rejects.toThrow('SSH connection changed') - } finally { - unregisterSshFilesystemProvider('ssh-b') - } - - expect(renameNoClobber).not.toHaveBeenCalled() - }) - - it('rejects stale generation provenance for a direct SSH target', async () => { - const renameNoClobber = vi.fn().mockResolvedValue(undefined) - registerSshFilesystemProvider('ssh-1', { renameNoClobber } as never) - setSshConnectionGeneration('ssh-1', 8) - - try { - await expect( - handlers.get('fs:rename')!(null, { - oldPath: '/home/me/repo/old.ts', - newPath: '/home/me/repo/new.ts', - connectionId: 'ssh-1', - expectedSshTargetId: 'ssh-1', - expectedSshConnectionGeneration: 7 - }) - ).rejects.toThrow('SSH connection changed') - } finally { - unregisterSshFilesystemProvider('ssh-1') - } - - expect(renameNoClobber).not.toHaveBeenCalled() + ).rejects.toThrow('SSH connection changed') }) it('rejects stale SSH provenance when a direct mutation resolves local', async () => { @@ -513,26 +442,6 @@ describe('registerFilesystemMutationHandlers', () => { expect(copyFileMock).toHaveBeenCalledWith(sourcePath, destinationPath, expect.any(Number)) }) - it('routes copy through the SSH filesystem provider when a connection is present', async () => { - const copy = vi.fn().mockResolvedValue(undefined) - registerSshFilesystemProvider('ssh-1', { copy } as never) - - try { - await handlers.get('fs:copy')!(null, { - sourcePath: '/home/me/repo/source.ts', - destinationPath: '/home/me/repo/source copy.ts', - connectionId: 'ssh-1', - expectedSshTargetId: 'ssh-1', - expectedSshConnectionGeneration: 0 - }) - } finally { - unregisterSshFilesystemProvider('ssh-1') - } - - expect(copy).toHaveBeenCalledWith('/home/me/repo/source.ts', '/home/me/repo/source copy.ts') - expect(copyFileMock).not.toHaveBeenCalled() - }) - // ── Edge cases ───────────────────────────────────────────────── it('propagates non-ENOENT lstat errors in assertNotExists', async () => { diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index e96650a7442..112dff10bad 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -9,16 +9,14 @@ import { resolveLocalRequestPath } from './local-file-access-resolution' import type { LocalFileAccess } from '../../shared/local-file-access' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution' -import { importExternalPathsSsh } from './filesystem-import-ssh' import type { SshMutationExpectation } from '../../shared/ssh-types' import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename' import { assertNotExists, rethrowWithUserMessage } from './filesystem-create-path-guards' import type { ImportItemResult, - ImportSkipReason, ResolveDroppedPathsResult, StagedExternalImportSource } from '../../shared/filesystem-import-result-types' @@ -51,8 +49,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { args.expectedExecutionHostId ) if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.createFile(args.filePath) + throw new SshHostNotServedError(args.connectionId) } const filePath = await resolveDesktopAuthorizedPath(args.filePath, store) await mkdir(dirname(filePath), { recursive: true }) @@ -78,8 +75,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { args.expectedExecutionHostId ) if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.createDir(args.dirPath) + throw new SshHostNotServedError(args.connectionId) } const dirPath = await resolveDesktopAuthorizedPath(args.dirPath, store) await assertNotExists(dirPath) @@ -108,8 +104,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { args.expectedExecutionHostId ) if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.renameNoClobber(args.oldPath, args.newPath) + throw new SshHostNotServedError(args.connectionId) } // Why: rename() operates on directory entries, not file contents. If // oldPath is a symlink, we must rename the link itself rather than @@ -145,8 +140,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { args.expectedExecutionHostId ) if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.copy(args.sourcePath, args.destinationPath) + throw new SshHostNotServedError(args.connectionId) } const sourcePath = await resolveDesktopAuthorizedPath(args.sourcePath, store, { preserveSymlink: true @@ -180,22 +174,11 @@ export function registerFilesystemMutationHandlers(store: Store): void { args.expectedExecutionHostId ) if (args.connectionId) { - return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId, { - ensureDir: args.ensureDir, - assertCurrent: () => - assertSshMutationExpectation( - args.connectionId, - args.expectedSshTargetId, - args.expectedSshConnectionGeneration, - args.expectedExecutionHostId - ) - }) + throw new SshHostNotServedError(args.connectionId) } // Why: destDir must be authorized before any copy work begins. If the // 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). // An image inserted into a document the user opened lands in that document's own folder. const resolvedDest = await resolveLocalRequestPath( args.destDir, @@ -275,11 +258,9 @@ export function registerFilesystemMutationHandlers(store: Store): void { ) // Why: terminal drag-and-drop resolver. Local worktrees pass paths through - // unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees - // upload each path into `${worktreePath}/.orca/drops/` and return remote - // paths the remote agent can read. Kept as a separate IPC from - // fs:importExternalPaths because terminal semantics differ from the - // explorer's "copy into user-picked destDir". See docs/terminal-drop-ssh.md. + // unchanged (reference-in-place; preserves zero-latency drop). Kept as a + // separate IPC from fs:importExternalPaths because terminal semantics differ + // from the explorer's "copy into user-picked destDir". ipcMain.handle( 'fs:resolveDroppedPathsForAgent', async ( @@ -305,32 +286,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { failed: [] } } - const worktreePath = args.worktreePath.replace(/\/+$/, '') - const destDir = `${worktreePath}/.orca/drops` - const { results } = await importExternalPathsSsh(args.paths, destDir, args.connectionId, { - ensureDir: true, - assertCurrent: () => - assertSshMutationExpectation( - args.connectionId, - args.expectedSshTargetId, - args.expectedSshConnectionGeneration, - args.expectedExecutionHostId - ) - }) - const resolvedPaths: string[] = [] - const skipped: { sourcePath: string; reason: ImportSkipReason }[] = [] - const failed: { sourcePath: string; reason: string }[] = [] - // Iterate in input order so injected paths align with the user's drop order. - for (const r of results) { - if (r.status === 'imported') { - resolvedPaths.push(r.destPath) - } else if (r.status === 'skipped') { - skipped.push({ sourcePath: r.sourcePath, reason: r.reason }) - } else { - failed.push({ sourcePath: r.sourcePath, reason: r.reason }) - } - } - return { resolvedPaths, skipped, failed } + throw new SshHostNotServedError(args.connectionId) } ) } diff --git a/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts b/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts index 6d62844dc0a..0d36c3da182 100644 --- a/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts +++ b/src/main/ipc/filesystem-pull-request-linked-issue-lifetime.test.ts @@ -9,7 +9,6 @@ import { getPullRequestDraftContextMock, resolveHostedReviewBodyForGenerationMock, loadPullRequestLinkedIssueMock, - getSshGitProviderMock, resetFilesystemIpcMocks } from './filesystem-test-harness' @@ -25,10 +24,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -42,10 +37,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock @@ -72,7 +63,7 @@ vi.mock('../source-control/pull-request-linked-issue', async () => { import { registerFilesystemHandlers } from './filesystem' import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' -describe.each(['local', 'SSH'])('PR linked-issue lifetime on %s', (host) => { +describe('PR linked-issue lifetime on a local worktree', () => { const draftContext = { base: 'main', branch: 'feature/ai', @@ -89,8 +80,7 @@ describe.each(['local', 'SSH'])('PR linked-issue lifetime on %s', (host) => { title: '', body: '', draft: false, - worktreePath: host === 'SSH' ? '/remote/repo' : WORKTREE_FEATURE_PATH, - ...(host === 'SSH' ? { connectionId: 'conn-1' } : {}) + worktreePath: WORKTREE_FEATURE_PATH } beforeEach(() => { @@ -103,10 +93,6 @@ describe.each(['local', 'SSH'])('PR linked-issue lifetime on %s', (host) => { }) resolveHostedReviewBodyForGenerationMock.mockResolvedValue('') getPullRequestDraftContextMock.mockResolvedValue(draftContext) - getSshGitProviderMock.mockReturnValue({ - exec: vi.fn(), - executeCommitMessagePlan: vi.fn() - }) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing IPC harness provides every Store member reached by this handler. registerFilesystemHandlers(store as never) }) diff --git a/src/main/ipc/filesystem-test-harness.ts b/src/main/ipc/filesystem-test-harness.ts index dd76daf2959..e326ac21b06 100644 --- a/src/main/ipc/filesystem-test-harness.ts +++ b/src/main/ipc/filesystem-test-harness.ts @@ -48,17 +48,14 @@ export const resolveCommitMessageSettingsMock: IpcMock = vi.fn() export const generateCommitMessageFromContextMock: IpcMock = vi.fn() export const generatePullRequestFieldsFromContextMock: IpcMock = vi.fn() export const discoverCommitMessageModelsLocalMock: IpcMock = vi.fn() -export const discoverCommitMessageModelsRemoteMock: IpcMock = vi.fn() export const cancelGenerateCommitMessageLocalMock: IpcMock = vi.fn() export const cancelGeneratePullRequestFieldsLocalMock: IpcMock = vi.fn() export const getPullRequestDraftContextMock: IpcMock = vi.fn() export const resolveHostedReviewBodyForGenerationMock: IpcMock = vi.fn() export const loadPullRequestLinkedIssueMock: IpcMock = vi.fn() export const getSshFilesystemProviderMock: IpcMock = vi.fn() -export const getSshGitProviderMock: IpcMock = vi.fn() export const tryDeleteWslUncPathMock: IpcMock = vi.fn() export const recordCrashBreadcrumbMock: IpcMock = vi.fn() -export const promoteLocalDownloadedFolderMock: IpcMock = vi.fn() export const electronMock = { app: { getPath: () => '/orca-test-user-data' }, @@ -84,10 +81,6 @@ export const wslUncDeleteMock = { tryDeleteWslUncPath: tryDeleteWslUncPathMock } export const crashBreadcrumbMock = { recordCrashBreadcrumb: recordCrashBreadcrumbMock } -export const folderPromotionMock = { - promoteLocalDownloadedFolder: promoteLocalDownloadedFolderMock -} - export const gitStatusModuleMock = { commitChanges: commitChangesMock, getStatus: getStatusMock, @@ -115,25 +108,8 @@ export const gitWorktreeMock = { listWorktreesStrict: listWorktreesMock } -const PROVIDER_UNAVAILABLE_MESSAGE = - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' - export const sshFilesystemDispatchMock = { - getSshFilesystemProvider: getSshFilesystemProviderMock, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: PROVIDER_UNAVAILABLE_MESSAGE, - requireSshFilesystemProvider: (connectionId: string) => { - const provider = getSshFilesystemProviderMock(connectionId) - if (!provider) { - throw new Error(PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider - } -} - -export const sshGitDispatchMock = { - getSshGitProvider: getSshGitProviderMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: PROVIDER_UNAVAILABLE_MESSAGE, - sshGitProviderMissingError: () => new Error(PROVIDER_UNAVAILABLE_MESSAGE) + getSshFilesystemProvider: getSshFilesystemProviderMock } export const textGenerationModuleMock = { @@ -141,7 +117,6 @@ export const textGenerationModuleMock = { generateCommitMessageFromContext: generateCommitMessageFromContextMock, generatePullRequestFieldsFromContext: generatePullRequestFieldsFromContextMock, discoverCommitMessageModelsLocal: discoverCommitMessageModelsLocalMock, - discoverCommitMessageModelsRemote: discoverCommitMessageModelsRemoteMock, cancelGenerateCommitMessageLocal: cancelGenerateCommitMessageLocalMock, cancelGeneratePullRequestFieldsLocal: cancelGeneratePullRequestFieldsLocalMock } @@ -229,12 +204,10 @@ const ALL_MOCKS = [ fsPromisesMock, wslUncDeleteMock, crashBreadcrumbMock, - folderPromotionMock, gitStatusModuleMock, gitIgnoredPathsMock, gitWorktreeMock, sshFilesystemDispatchMock, - sshGitDispatchMock, textGenerationModuleMock, pullRequestContextMock, pullRequestTemplateMock, @@ -298,12 +271,14 @@ export function resetFilesystemIpcMocks(): void { trashItemMock.mockResolvedValue(undefined) // Default: not a WSL UNC path, so deletePath falls through to shell.trashItem. tryDeleteWslUncPathMock.mockResolvedValue(false) - promoteLocalDownloadedFolderMock.mockResolvedValue(undefined) showSaveDialogMock.mockResolvedValue({ canceled: true }) showOpenDialogMock.mockResolvedValue({ canceled: true, filePaths: [] }) fromWebContentsMock.mockReturnValue(null) - getSshGitProviderMock.mockReturnValue(null) - statMock.mockResolvedValue({ size: 10, isDirectory: () => false, mtimeMs: 123 }) + statMock.mockResolvedValue({ + size: 10, + isDirectory: () => false, + mtimeMs: 123 + }) renameMock.mockResolvedValue(undefined) rmMock.mockResolvedValue(undefined) openMock.mockResolvedValue(localFileHandleMock(Buffer.from('a'.repeat(10)))) diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 21dc691cbcc..dfd1f4cea82 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -1,4 +1,3 @@ -import { EventEmitter } from 'node:events' import path from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -15,7 +14,6 @@ import { realpathMock, lstatMock, listWorktreesMock, - getSshFilesystemProviderMock, tryDeleteWslUncPathMock, recordCrashBreadcrumbMock, resetFilesystemIpcMocks, @@ -33,10 +31,6 @@ vi.mock( '../crash-reporting/crash-breadcrumb-store', async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock ) -vi.mock( - '../local-downloaded-folder-promotion', - async () => (await import('./filesystem-test-harness')).folderPromotionMock -) vi.mock( '../git/status', async () => (await import('./filesystem-test-harness')).gitStatusModuleMock @@ -50,10 +44,6 @@ vi.mock( '../providers/ssh-filesystem-dispatch', async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock ) -vi.mock( - '../providers/ssh-git-dispatch', - async () => (await import('./filesystem-test-harness')).sshGitDispatchMock -) vi.mock( '../text-generation/commit-message-text-generation', async () => (await import('./filesystem-test-harness')).textGenerationModuleMock @@ -92,30 +82,6 @@ describe('registerFilesystemHandlers', () => { 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({ - readDir: vi.fn().mockResolvedValue([ - { name: '100 - b.txt', isDirectory: false, isSymlink: false }, - { name: '9 - c.txt', isDirectory: false, isSymlink: false }, - { name: '10 - dir', isDirectory: true, isSymlink: false }, - { name: '99 - a.txt', isDirectory: false, isSymlink: false } - ]) - }) - registerFilesystemHandlers(store as never) - - const result = (await handlers.get('fs:readDir')!(null, { - dirPath: '/remote/repo', - connectionId: 'ssh-1' - })) as { name: string }[] - expect(result.map((e) => e.name)).toEqual([ - '10 - dir', - '9 - c.txt', - '99 - a.txt', - '100 - b.txt' - ]) - }) - it('returns an actionable reconnect error when the SSH filesystem provider is unavailable', async () => { registerFilesystemHandlers(store as never) @@ -156,7 +122,6 @@ describe('registerFilesystemHandlers', () => { it('records a breadcrumb tagged ssh-provider when the SSH provider is gone', async () => { registerFilesystemHandlers(store as never) - getSshFilesystemProviderMock.mockReturnValue(undefined) await expect( handlers.get('fs:readDir')!(null, { dirPath: '/remote/repo', connectionId: 'ssh-1' }) @@ -284,24 +249,6 @@ describe('registerFilesystemHandlers', () => { expect(statMock).toHaveBeenCalledWith(targetPath) }) - it('returns false from pathExists when an SSH provider reports a missing path', async () => { - const provider = { - stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:pathExists')!(null, { - filePath: '/remote/repo/untitled-7.md', - connectionId: 'ssh-1' - }) - ).resolves.toBe(false) - - expect(provider.stat).toHaveBeenCalledWith('/remote/repo/untitled-7.md') - }) - it('allows deletePath when a registered worktree parent resolves to a macOS canonical alias', async () => { const aliasWorktreePath = path.resolve('/var/folders/orca/worktrees/feature') const canonicalWorktreePath = path.resolve('/private/var/folders/orca/worktrees/feature') @@ -544,163 +491,4 @@ describe('registerFilesystemHandlers', () => { isBinary: true }) }) - - // Why: the original SSH Quick Open bug had two halves — relay-side policy - // drift AND the main dispatcher silently dropping excludePaths before the - // provider saw them. This test guards the second half: regardless of - // relay behavior, a new linked worktree under the root must be forwarded - // so the remote scan can prune it. See docs/design/share-quick-open-file-listing.md. - it('fs:listFiles forwards excludePaths to the SSH filesystem provider', async () => { - const listFilesMock = vi.fn().mockResolvedValue([]) - getSshFilesystemProviderMock.mockReturnValue({ listFiles: listFilesMock }) - - registerFilesystemHandlers(store as never) - - await handlers.get('fs:listFiles')!(null, { - rootPath: '/home/user/repo', - connectionId: 'conn-1', - excludePaths: ['/home/user/repo/worktrees/feature'] - }) - - expect(listFilesMock).toHaveBeenCalledWith('/home/user/repo', { - excludePaths: ['/home/user/repo/worktrees/feature'] - }) - }) - - it.each([1, 2, 3, 4])( - 'keeps plain SSH queries on version %s hosts beyond the prefix limit', - async (version) => { - const listFilesMock = vi.fn().mockResolvedValue(['src/target.ts']) - const supportsQuickOpenSearch = vi.fn( - async (options: { minimumVersion?: number }) => version >= (options.minimumVersion ?? 3) - ) - const inventory = [...Array.from({ length: 33 }, (_, i) => `file${i}.ts`), 'src/target.ts'] - listFilesMock.mockImplementation(async (_root, options) => - options.searchQuery - ? inventory.filter((file) => file.includes(options.searchQuery)) - : inventory.slice(0, options.maxResults) - ) - getSshFilesystemProviderMock.mockReturnValue({ - listFiles: listFilesMock, - supportsQuickOpenSearch - }) - - registerFilesystemHandlers(store as never) - const request = { - rootPath: '/home/user/repo', - connectionId: 'conn-1', - maxResults: 33, - searchQuery: 'target' - } - await expect(handlers.get('fs:listFiles')!(null, request)).resolves.toEqual(['src/target.ts']) - expect(listFilesMock).toHaveBeenCalledOnce() - expect(supportsQuickOpenSearch).toHaveBeenCalledWith({ signal: undefined, minimumVersion: 1 }) - - expect(listFilesMock).toHaveBeenCalledWith('/home/user/repo', { - excludePaths: undefined, - maxResults: 33, - searchQuery: 'target' - }) - for (const options of [ - { includeIgnored: false }, - { followSymlinks: true }, - { candidatePaths: ['src/target.ts'] }, - { searchQuery: 'tar get' }, - { searchQuery: 'tar-get' }, - { searchQuery: 'tar_get' } - ]) { - listFilesMock.mockClear() - const operation = handlers.get('fs:listFiles')!(null, { ...request, ...options }) - const minimum = 'candidatePaths' in options ? 3 : 'searchQuery' in options ? 1 : 2 - if (version < minimum) { - await expect(operation).rejects.toThrow('Update the remote host') - expect(listFilesMock).not.toHaveBeenCalled() - } else { - await operation - expect(listFilesMock).toHaveBeenCalledOnce() - expect(listFilesMock.mock.calls[0][1]).toMatchObject({ ...options, maxResults: 33 }) - } - } - } - ) - - it.each(['target', 'tar get'])( - 'retains bounded legacy SSH behavior for %s without capabilities', - async (query) => { - const listFilesMock = vi.fn().mockResolvedValue(['src/target.ts', 'src/index.ts']) - const supportsQuickOpenSearchMock = vi.fn().mockResolvedValue(false) - getSshFilesystemProviderMock.mockReturnValue({ - listFiles: listFilesMock, - supportsQuickOpenSearch: supportsQuickOpenSearchMock - }) - - registerFilesystemHandlers(store as never) - - await expect( - handlers.get('fs:listFiles')!(null, { - rootPath: '/home/user/repo', - connectionId: 'conn-1', - maxResults: 2, - searchQuery: query - }) - ).resolves.toEqual(['src/target.ts']) - - expect(supportsQuickOpenSearchMock).toHaveBeenCalled() - expect(listFilesMock).toHaveBeenCalledWith('/home/user/repo', { - excludePaths: undefined, - maxResults: 33 - }) - } - ) - - // Why #7721: without a cancel path, every workspace switch left the previous - // workspace's full-tree SSH scan running, stacking scans on the relay until - // interactive fs.readDir/fs.stat starved past their 30s timeout. - it.each(['cancel', 'did-navigate', 'render-process-gone', 'destroyed'])( - 'aborts an in-flight SSH file listing on %s (#7721)', - async (eventName) => { - let capturedSignal: AbortSignal | undefined - const listFilesMock = vi.fn( - (_rootPath: string, options: { signal?: AbortSignal }) => - new Promise((_resolve, reject) => { - capturedSignal = options.signal - options.signal?.addEventListener( - 'abort', - () => reject(new Error('listing cancelled')), - { - once: true - } - ) - }) - ) - getSshFilesystemProviderMock.mockReturnValue({ listFiles: listFilesMock }) - - registerFilesystemHandlers(store as never) - - // Why: cancellation keys are scoped to the issuing webContents, so the - // cancel must come from the same sender as the listing request. - const senderEvent = { sender: Object.assign(new EventEmitter(), { id: 7 }) } - const pending = handlers.get('fs:listFiles')!(senderEvent, { - rootPath: '/home/user/repo', - connectionId: 'conn-1', - requestToken: 'token-1' - }) as Promise - - await Promise.resolve() - expect(capturedSignal?.aborted).toBe(false) - if (eventName === 'cancel') { - await handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'token-1' }) - } else { - senderEvent.sender.emit(eventName) - } - expect(capturedSignal?.aborted).toBe(true) - await expect(pending).rejects.toThrow('listing cancelled') - expect(senderEvent.sender.eventNames()).toEqual([]) - - // Unknown or already-settled tokens are a no-op, not an error. - expect(() => - handlers.get('fs:cancelListFiles')!(senderEvent, { requestToken: 'unknown' }) - ).not.toThrow() - } - ) }) diff --git a/src/main/ipc/filesystem/filesystem-chunk-read-handler.ts b/src/main/ipc/filesystem/filesystem-chunk-read-handler.ts index e04ccec5e78..36428096a03 100644 --- a/src/main/ipc/filesystem/filesystem-chunk-read-handler.ts +++ b/src/main/ipc/filesystem/filesystem-chunk-read-handler.ts @@ -1,8 +1,7 @@ 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 { SshHostNotServedError } from '../../providers/ssh-host-not-served-error' import { resolveLocalFileRequestPath } from '../local-file-access-resolution' import type { LocalFileAccess } from '../../../shared/local-file-access' import type { FilesystemHandlerContext } from './filesystem-handler-context' @@ -22,18 +21,7 @@ export function registerFilesystemChunkReadHandler({ store }: FilesystemHandlerC ) => { 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 - ) + throw new SshHostNotServedError(args.connectionId) } const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store) return readLocalFileRange(filePath, args.offset, args.length) diff --git a/src/main/ipc/filesystem/filesystem-content-search-handler.ts b/src/main/ipc/filesystem/filesystem-content-search-handler.ts index 0ed7ad4d26e..bf1bf044236 100644 --- a/src/main/ipc/filesystem/filesystem-content-search-handler.ts +++ b/src/main/ipc/filesystem/filesystem-content-search-handler.ts @@ -2,7 +2,7 @@ import { ipcMain } from 'electron' import type { SearchOptions, SearchResult } from '../../../shared/code-search-types' import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { parseWslPath } from '../../wsl' -import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error' import { resolveDesktopAuthorizedPath } from '../local-file-access-resolution' import { createSenderScopedRequestCancellations } from '../sender-scoped-request-cancellation' import { stopBundledRipgrep } from '../../ripgrep/bundled-ripgrep-stop' @@ -29,8 +29,7 @@ export function registerFilesystemContentSearchHandler(context: FilesystemHandle try { throwIfSignalAborted(signal) if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return await provider.search(options, { signal }) + throw new SshHostNotServedError(args.connectionId) } const rootPath = await waitForPromiseWithSignal( resolveDesktopAuthorizedPath(args.rootPath, store), diff --git a/src/main/ipc/filesystem/filesystem-download-handlers.ts b/src/main/ipc/filesystem/filesystem-download-handlers.ts index b542b235a48..ffd95e42e47 100644 --- a/src/main/ipc/filesystem/filesystem-download-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-download-handlers.ts @@ -1,10 +1,8 @@ import { BrowserWindow, dialog, ipcMain } from 'electron' import { randomUUID } from 'node:crypto' import { open, writeFile } from 'node:fs/promises' -import { getRuntimePathBasename } from '../../../shared/cross-platform-path' -import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error' import { sanitizeLocalDownloadFilename } from '../../local-download-filename' -import { registerFilesystemDownloadFolderHandlers } from '../filesystem-download-folder' import { abortWhenRendererGone } from '../renderer-lifetime-abort' import type { FilesystemHandlerContext } from './filesystem-handler-context' import { @@ -29,49 +27,22 @@ export function registerFilesystemDownloadHandlers(context: FilesystemHandlerCon ipcMain.handle( 'fs:downloadFile', - async ( - event, - args: { filePath?: string; connectionId?: string } - ): Promise => { - const filePath = validateRequiredString(args?.filePath, 'filePath') + async (_event, args: { filePath?: string; connectionId?: string }): Promise => { + validateRequiredString(args?.filePath, 'filePath') const connectionId = validateRequiredString(args?.connectionId, 'connectionId') - const provider = requireSshFilesystemProvider(connectionId) - const remoteStat = await provider.stat(filePath) - if (remoteStat.type === 'directory') { - throw new Error('Cannot download a directory') - } - if (!provider.downloadFile) { - throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') - } - - const remoteBasename = getRuntimePathBasename(filePath) - const defaultPath = sanitizeLocalDownloadFilename(remoteBasename) - const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined - const dialogResult = parentWindow - ? await dialog.showSaveDialog(parentWindow, { defaultPath }) - : await dialog.showSaveDialog({ defaultPath }) - if (dialogResult.canceled || !dialogResult.filePath) { - return { canceled: true } - } - - const destinationPath = dialogResult.filePath - const { existed } = await inspectDownloadDestination(destinationPath) - const tempPath = createSiblingTransferPath(destinationPath, 'download') - let promoted = false - try { - await provider.downloadFile(filePath, tempPath) - await promoteDownloadedFile(tempPath, destinationPath, existed) - promoted = true - return { canceled: false, destinationPath } - } finally { - if (!promoted) { - await cleanupLocalTransferPath(tempPath) - } - } + // Why: only SSH targets downloaded through here, and this process serves none. + throw new SshHostNotServedError(connectionId) } ) - registerFilesystemDownloadFolderHandlers() + ipcMain.handle( + 'fs:downloadFolder', + async (_event, args: { dirPath?: string; connectionId?: string }): Promise => { + validateRequiredString(args?.dirPath, 'dirPath') + const connectionId = validateRequiredString(args?.connectionId, 'connectionId') + throw new SshHostNotServedError(connectionId) + } + ) ipcMain.handle( 'fs:saveDownloadedFile', diff --git a/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts index ae029dc2f41..448ea9f9f5b 100644 --- a/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts @@ -9,10 +9,7 @@ import { } from '../../text-generation/commit-message-text-generation' import { getCommitMessageModelDiscoveryHostKey } from '../../../shared/commit-message-host-key' import { getStagedCommitContext } from '../../git/status' -import { - getSshGitProvider, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE -} from '../../providers/ssh-git-dispatch' +import { SSH_HOST_NOT_SERVED_MESSAGE } from '../../providers/ssh-host-not-served-error' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' import { withLinkedIssueDraftContext } from '../../../shared/source-control-ai-action-variables' @@ -65,37 +62,7 @@ export function registerFilesystemGitCommitGenerationHandlers( return { success: false, error: resolvedSettings.error } } if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return { - success: false, - error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE - } - } - let context - try { - context = await provider.getStagedCommitContext(args.worktreePath) - } catch (error) { - console.error('[filesystem] Failed to read remote staged commit context:', error) - return { - success: false, - error: 'Failed to read staged changes.' - } - } - if (!context) { - return { success: false, error: 'No staged changes to summarize.' } - } - context = withLinkedIssueDraftContext( - context, - resolveSourceControlAiLinkedIssue(store, args) - ) - return generateCommitMessageFromContext(context, resolvedSettings.params, { - kind: 'remote', - cwd: args.worktreePath, - execute: (plan, cwd, timeoutMs, operation) => - provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), - missingBinaryLocation: 'remote PATH' - }) + return { success: false, error: SSH_HOST_NOT_SERVED_MESSAGE } } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -143,11 +110,6 @@ export function registerFilesystemGitCommitGenerationHandlers( 'git:cancelGenerateCommitMessage', async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return - } - await provider.cancelGenerateCommitMessage(args.worktreePath, 'commit-message') return } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) diff --git a/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts index 8de07853057..2cf4215ec90 100644 --- a/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts @@ -3,12 +3,10 @@ import type { TuiAgent } from '../../../shared/tui-agent' import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/commit-message-agent-environment' import { discoverCommitMessageModelsLocal, - discoverCommitMessageModelsRemote, type DiscoverCommitMessageModelsResult } from '../../text-generation/commit-message-text-generation' import { prepareLocalCommitMessageAgentEnv } from '../../text-generation/commit-message-agent-environment' import { parseWslPath } from '../../wsl' -import { getSshGitProvider } from '../../providers/ssh-git-dispatch' import { resolveModelDiscoveryLocalPath, getLocalAgentRuntimeTarget @@ -32,19 +30,10 @@ export function registerFilesystemGitModelDiscoveryHandlers( if (!args.worktreePath) { return { success: false, error: 'Missing worktree path for remote model discovery.' } } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return { - success: false, - error: `No git provider for connection "${args.connectionId}"` - } + return { + success: false, + error: `No git provider for connection "${args.connectionId}"` } - return discoverCommitMessageModelsRemote( - agentId as TuiAgent, - args.worktreePath, - (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), - agentCommandOverride - ) } let localRuntimeTarget: CommitMessageAgentRuntimeTarget = { runtime: 'host' } let localDiscoveryOptions: Parameters[3] diff --git a/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts index 6cefc50f3eb..07afcce01e0 100644 --- a/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts @@ -11,14 +11,10 @@ import { import { getCommitMessageModelDiscoveryHostKey } from '../../../shared/commit-message-host-key' import { getPullRequestDraftContext } from '../../text-generation/pull-request-context' import { prepareLocalCommitMessageAgentEnv } from '../../text-generation/commit-message-agent-environment' -import { - getSshGitProvider, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE -} from '../../providers/ssh-git-dispatch' +import { SSH_HOST_NOT_SERVED_MESSAGE } from '../../providers/ssh-host-not-served-error' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' import { gitExecFileAsync } from '../../git/runner' -import { execSshReviewDraft } from '../../providers/ssh-review-draft-context' import { withLinkedIssueDraftContext } from '../../../shared/source-control-ai-action-variables' import { resolveSourceControlAiLinkedIssueMeta } from '../source-control-ai-linked-issue' import { resolveHostedReviewBodyForGeneration } from '../../source-control/pull-request-template' @@ -76,64 +72,7 @@ export function registerFilesystemGitPullRequestGenerationHandlers( return { success: false, error: resolvedSettings.error } } if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return { - success: false, - error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE - } - } - const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args) - const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ - meta: issueMeta, - provider: args.provider, - repoPath: args.worktreePath, - connectionId: args.connectionId - }) - // Preparation can return before this lookup settles; retain its error for the later await. - void linkedIssueDetailsPromise.catch(() => undefined) - let context: Awaited> - try { - const currentBody = await resolveHostedReviewBodyForGeneration({ - body: args.body, - repoPath: args.worktreePath, - connectionId: args.connectionId, - provider: args.provider, - useTemplate: args.useTemplate - }) - context = await getPullRequestDraftContext( - (argv, commandOptions) => - execSshReviewDraft(provider, argv, args.worktreePath, commandOptions), - { - base: args.base, - currentTitle: args.title, - currentBody, - currentDraft: args.draft - } - ) - } catch (error) { - return { - success: false, - error: - error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' - } - } - if (!context) { - return { success: false, error: 'No branch changes to summarize.' } - } - const linkedIssueDetails = await linkedIssueDetailsPromise - context = { - ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), - ...(args.provider ? { provider: args.provider } : {}), - ...(linkedIssueDetails ? { linkedIssueDetails } : {}) - } - return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { - kind: 'remote', - cwd: args.worktreePath, - execute: (plan, cwd, timeoutMs, operation) => - provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), - missingBinaryLocation: 'remote PATH' - }) + return { success: false, error: SSH_HOST_NOT_SERVED_MESSAGE } } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) @@ -147,7 +86,6 @@ export function registerFilesystemGitPullRequestGenerationHandlers( meta: issueMeta, provider: args.provider, repoPath: worktreePath, - connectionId: args.connectionId, localGitOptions: gitOptions }) // Preparation can return before this lookup settles; retain its error for the later await. @@ -157,7 +95,6 @@ export function registerFilesystemGitPullRequestGenerationHandlers( const currentBody = await resolveHostedReviewBodyForGeneration({ body: args.body, repoPath: worktreePath, - connectionId: args.connectionId, provider: args.provider, useTemplate: args.useTemplate }) @@ -213,11 +150,6 @@ export function registerFilesystemGitPullRequestGenerationHandlers( 'git:cancelGeneratePullRequestFields', async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return - } - await provider.cancelGenerateCommitMessage(args.worktreePath, 'pull-request-fields') return } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index 1c4b2cadd3c..cc34f6f6f16 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -1,4 +1,3 @@ -import { listFilesystemMarkdownDocuments } from '../../providers/filesystem-markdown-listing' import { classifyFilesystemDirectoryEntries } from '../filesystem-symlink-directory-entries' import { markdownDocumentsFromRelativePaths } from '../../../shared/markdown-document-paths' import { @@ -10,7 +9,7 @@ import { ipcMain } from 'electron' 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 { SshHostNotServedError } from '../../providers/ssh-host-not-served-error' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' import type { LocalFileAccess } from '../../../shared/local-file-access' import { @@ -46,11 +45,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext try { if (args.connectionId) { throwSite = 'ssh-provider' - const provider = requireSshFilesystemProvider(args.connectionId) - // Why: re-sort locally — the remote relay may be an older build with lexicographic ordering. - return sortDirEntries( - await provider.readDir(args.dirPath, { followSymlinks: args.followSymlinks }) - ) + throw new SshHostNotServedError(args.connectionId) } const dirPath = await resolveDesktopAuthorizedPath(args.dirPath, store) throwSite = 'readdir' @@ -89,12 +84,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext } ): Promise => { if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - const media = readMediaPreview(event, args, store) - if (media) { - return media - } - return provider.readFile(args.filePath) + throw new SshHostNotServedError(args.connectionId) } const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store) const media = readMediaPreview(event, args, store) @@ -114,8 +104,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext args: { rootPath: string; connectionId?: string } ): Promise => { if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return listFilesystemMarkdownDocuments(provider, args.rootPath) + throw new SshHostNotServedError(args.connectionId) } const isFolderRoot = store .getFolderWorkspaces?.() @@ -143,9 +132,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext 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 } + throw new SshHostNotServedError(args.connectionId) } const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store) const stats = await stat(filePath) @@ -160,17 +147,14 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext args: { filePaths: string[]; connectionId?: string } ): Promise => { validatePathExistenceBatch(args.filePaths) - const provider = args.connectionId ? requireSshFilesystemProvider(args.connectionId) : null - if (provider?.pathsExist) { - return provider.pathsExist(args.filePaths) + if (args.connectionId) { + throw new SshHostNotServedError(args.connectionId) } return Promise.all( args.filePaths.map((filePath) => capturePathExistence(async () => { try { - await (provider - ? provider.stat(filePath) - : stat(await resolveDesktopAuthorizedPath(filePath, store))) + await stat(await resolveDesktopAuthorizedPath(filePath, store)) return true } catch (error) { if (isENOENT(error)) { @@ -192,9 +176,7 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext ): Promise => { try { if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - await provider.stat(args.filePath) - return true + throw new SshHostNotServedError(args.connectionId) } const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store) await stat(filePath) diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts index f7233757a7c..f6c18e30b87 100644 --- a/src/main/ipc/filesystem/filesystem-search-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -1,19 +1,13 @@ -import { resolveSshQuickOpenDiscoveryOptions } from '../../providers/ssh-quick-open-discovery-options' import { ipcMain } from 'electron' -import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { listQuickOpenFiles } from '../filesystem-list-files' import { isFileNameFilterQueryTooLarge, pathMatchesFileNameFilterTokens, splitFileNameFilterTokens } from '../../../shared/file-name-filter-tokens' -import { QuickOpenPathRanker } from '../../../shared/quick-open-path-search' import type { FilesystemHandlerContext } from './filesystem-handler-context' import { registerFilesystemContentSearchHandler } from './filesystem-content-search-handler' -// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. -const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 - export function registerFilesystemSearchHandlers(context: FilesystemHandlerContext): void { registerFilesystemContentSearchHandler(context) const { store } = context @@ -39,58 +33,9 @@ export function registerFilesystemSearchHandlers(context: FilesystemHandlerConte ): Promise => { const controller = listFilesCancellations.begin(event, args.requestToken) try { + // Why: an SSH target is not served here; quick-open shows "No matching files", as it did when disconnected. if (args.connectionId) { - const provider = getSshFilesystemProvider(args.connectionId) - // Why: no provider (cold start / disconnected) → return [] so quick-open shows "No matching files" instead of an error. - if (!provider) { - return [] - } - const discovery = await resolveSshQuickOpenDiscoveryOptions( - provider, - args, - controller?.signal - ) - if ( - args.candidatePaths !== undefined && - !(await provider.supportsQuickOpenSearch?.({ - signal: controller?.signal, - minimumVersion: 3 - })) - ) { - throw new Error('Update the remote host to validate Quick Open recent files.') - } - // Why: forward excludePaths or nested linked worktrees get double-scanned over SSH, causing timeout-induced partial results. - if ( - args.searchQuery !== undefined && - provider.supportsQuickOpenSearch && - !(await provider.supportsQuickOpenSearch({ - signal: controller?.signal, - minimumVersion: 1 - })) - ) { - const legacyFiles = await provider.listFiles(args.rootPath, { - excludePaths: args.excludePaths, - ...discovery, - maxResults: QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT, - signal: controller?.signal - }) - const ranker = new QuickOpenPathRanker( - args.searchQuery, - args.maxResults ?? QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT - ) - for (const file of legacyFiles) { - ranker.consider(file) - } - return ranker.result().paths - } - return await provider.listFiles(args.rootPath, { - candidatePaths: args.candidatePaths, - excludePaths: args.excludePaths, - ...discovery, - ...(args.maxResults === undefined ? {} : { maxResults: args.maxResults }), - ...(args.searchQuery === undefined ? {} : { searchQuery: args.searchQuery }), - signal: controller?.signal - }) + return [] } if (args.nameFilter !== undefined && isFileNameFilterQueryTooLarge(args.nameFilter)) { return [] diff --git a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts index f0fad64322f..5e1c9666cd8 100644 --- a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts @@ -12,7 +12,6 @@ vi.mock('../registered-worktree-roots-cache', () => ({ resolveRegisteredWorktreePath: async (path: string) => path })) vi.mock('../filesystem-auth', () => ({ resolveAuthorizedPath: vi.fn() })) -vi.mock('../../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() })) import { getRepoForSourceControlAi } from './filesystem-source-control-ai-targets' const repo: Repo = { diff --git a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts index eaa3f245dd9..6a9e5dcd940 100644 --- a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts @@ -7,7 +7,6 @@ import { import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/commit-message-agent-environment' import type { CommitMessageGenerationTarget } from '../../text-generation/commit-message-text-generation' import { resolve } from 'node:path' -import { getSshGitProvider } from '../../providers/ssh-git-dispatch' import { listRepoWorktreeGraph } from '../../repo-worktrees' import { resolveAuthorizedPath } from '../filesystem-auth' import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' @@ -91,28 +90,11 @@ async function localRepoOwnsWorktree( } } -async function remoteRepoOwnsWorktree( - store: Store, - repo: Repo, - worktreePath: string, - connectionId: string -): Promise { - const comparableWorktreePath = comparableRemotePath(worktreePath) - if (comparableRemotePath(repo.path) === comparableWorktreePath) { - return true - } - const provider = getSshGitProvider(connectionId) - if (!provider) { - return hasRegisteredRemoteWorktreeMetaForRepo(store, repo.id, worktreePath) - } - try { - const worktrees = await provider.listWorktrees(repo.path) - return worktrees.some( - (worktree) => comparableRemotePath(worktree.path) === comparableWorktreePath - ) - } catch { - return false - } +function remoteRepoOwnsWorktree(store: Store, repo: Repo, worktreePath: string): boolean { + return ( + comparableRemotePath(repo.path) === comparableRemotePath(worktreePath) || + hasRegisteredRemoteWorktreeMetaForRepo(store, repo.id, worktreePath) + ) } export async function getRepoForSourceControlAi( @@ -131,9 +113,7 @@ export async function getRepoForSourceControlAi( return null } // Why: one SSH connection can host several repos; repo-scoped AI overrides apply only when the worktree belongs to that repo. - return (await remoteRepoOwnsWorktree(store, repo, args.worktreePath, args.connectionId)) - ? repo - : null + return remoteRepoOwnsWorktree(store, repo, args.worktreePath) ? repo : null } if (repo.connectionId) { return null diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index 6aa3502adb7..ee9646ac3e6 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -2,7 +2,7 @@ import { ipcMain, shell } from 'electron' import { lstat, writeFile } from 'node:fs/promises' import type { SshMutationExpectation } from '../../../shared/ssh-types' import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generation' -import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error' import { tryDeleteWslUncPath } from '../../wsl-unc-delete' import type { LocalFileAccess } from '../../../shared/local-file-access' import { @@ -36,10 +36,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex args.expectedExecutionHostId ) if (args.connectionId) { - return requireSshFilesystemProvider(args.connectionId).writeFile( - args.filePath, - args.content - ) + throw new SshHostNotServedError(args.connectionId) } const filePath = await resolveLocalWriteRequestPath(args.filePath, args.access, store) try { @@ -74,10 +71,7 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex args.expectedExecutionHostId ) if (args.connectionId) { - return requireSshFilesystemProvider(args.connectionId).deletePath( - args.targetPath, - args.recursive - ) + throw new SshHostNotServedError(args.connectionId) } // 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 resolveDesktopAuthorizedPath(args.targetPath, store, { diff --git a/src/main/ipc/git-status-upstream-ref-watch-request.test.ts b/src/main/ipc/git-status-upstream-ref-watch-request.test.ts index 62c3644f52c..5d3de958c5b 100644 --- a/src/main/ipc/git-status-upstream-ref-watch-request.test.ts +++ b/src/main/ipc/git-status-upstream-ref-watch-request.test.ts @@ -3,8 +3,6 @@ import type { Store } from '../persistence' const mocks = vi.hoisted(() => ({ gitExec: vi.fn(), - getProvider: vi.fn(), - getProviderGeneration: vi.fn(), resolveRegisteredPath: vi.fn(), getLocalRepo: vi.fn(), getLocalOptions: vi.fn(), @@ -12,11 +10,6 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('../git/runner', () => ({ gitExecFileAsync: mocks.gitExec })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: mocks.getProvider, - getSshGitProviderGeneration: mocks.getProviderGeneration, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable' -})) vi.mock('./registered-worktree-roots-cache', () => ({ resolveRegisteredWorktreePath: mocks.resolveRegisteredPath })) @@ -34,7 +27,6 @@ import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' describe('applyGitStatusUpstreamRefWatchRequest', () => { beforeEach(() => { vi.clearAllMocks() - mocks.getProviderGeneration.mockReturnValue(0) mocks.resolveRegisteredPath.mockResolvedValue('/resolved/repo') mocks.getLocalOptions.mockReturnValue({}) mocks.setWatch.mockImplementation( @@ -68,11 +60,7 @@ describe('applyGitStatusUpstreamRefWatchRequest', () => { ) }) - it('keeps the SSH provider generation but refuses to resolve on an SSH host', async () => { - const exec = vi.fn() - mocks.getProvider.mockReturnValue({ exec }) - mocks.getProviderGeneration.mockReturnValue(7) - + it('binds the SSH watch but refuses to resolve on an SSH host', async () => { await expect( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the SSH refusal throws before any store read. applyGitStatusUpstreamRefWatchRequest({} as Store, { @@ -86,10 +74,9 @@ describe('applyGitStatusUpstreamRefWatchRequest', () => { ).rejects.toThrow(SshHostNotServedError) expect(mocks.setWatch).toHaveBeenCalledWith( - expect.objectContaining({ connectionId: 'ssh-1', providerGeneration: 7 }), + expect.objectContaining({ connectionId: 'ssh-1' }), expect.any(Function) ) - expect(exec).not.toHaveBeenCalled() expect(mocks.gitExec).not.toHaveBeenCalled() }) }) diff --git a/src/main/ipc/git-status-upstream-ref-watch-request.ts b/src/main/ipc/git-status-upstream-ref-watch-request.ts index d3811c827d4..5901128f42e 100644 --- a/src/main/ipc/git-status-upstream-ref-watch-request.ts +++ b/src/main/ipc/git-status-upstream-ref-watch-request.ts @@ -1,7 +1,6 @@ import type { Store } from '../persistence' import { resolveGitStatusUpstreamRef } from '../git/status-upstream-ref' import { gitExecFileAsync } from '../git/runner' -import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' import { getLocalGitOptionsForRepo, @@ -12,10 +11,7 @@ import type { GitStatusRefBindingRequest } from './worktree-git-status-ref-watch import { requireReachableGitRoute } from '../providers/execution-host-provider-dispatch' import { getConnectionExecutionHostId } from '../../shared/execution-host' -export type GitStatusUpstreamRefWatchRequest = Omit< - GitStatusRefBindingRequest, - 'providerGeneration' -> +export type GitStatusUpstreamRefWatchRequest = GitStatusRefBindingRequest const UPSTREAM_REF_RESOLUTION_TIMEOUT_MS = 15_000 @@ -27,34 +23,28 @@ export function applyGitStatusUpstreamRefWatchRequest( store: Store, args: GitStatusUpstreamRefWatchRequest ): Promise { - const providerGeneration = args.connectionId - ? getSshGitProviderGeneration(args.connectionId) - : undefined - return setWorktreeGitStatusRefWatch( - { ...args, ...(providerGeneration !== undefined ? { providerGeneration } : {}) }, - async (bindingSignal) => { - if (!args.branch || !args.upstreamName) { - return undefined - } - const signal = boundedSignal(bindingSignal) - requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) - const gitOptions = getLocalGitOptionsForRepo(store, repo) - return resolveGitStatusUpstreamRef( - (gitArgs, cwd, requestSignal) => - gitExecFileAsync(gitArgs, { - cwd, - signal: requestSignal, - timeout: UPSTREAM_REF_RESOLUTION_TIMEOUT_MS, - ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}) - }), - worktreePath, - args.branch, - args.upstreamName, - signal - ) + return setWorktreeGitStatusRefWatch(args, async (bindingSignal) => { + if (!args.branch || !args.upstreamName) { + return undefined } - ) + const signal = boundedSignal(bindingSignal) + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) + + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) + const gitOptions = getLocalGitOptionsForRepo(store, repo) + return resolveGitStatusUpstreamRef( + (gitArgs, cwd, requestSignal) => + gitExecFileAsync(gitArgs, { + cwd, + signal: requestSignal, + timeout: UPSTREAM_REF_RESOLUTION_TIMEOUT_MS, + ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}) + }), + worktreePath, + args.branch, + args.upstreamName, + signal + ) + }) } diff --git a/src/main/ipc/hooks/register-worktree-hook-check-handler.ts b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts index 443f3b108db..2ef043fda4b 100644 --- a/src/main/ipc/hooks/register-worktree-hook-check-handler.ts +++ b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts @@ -2,10 +2,7 @@ import { getStoredRepoSshConnectionId } from '../../repo-execution-host' import { ipcMain } from 'electron' import type { ExecutionHostId } from '../../../shared/execution-host' import { isFolderRepo } from '../../../shared/repo-kind' -import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' -import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths' -import { parseOrcaYaml, hasHooksFile, loadHooks, hasUnrecognizedOrcaYamlKeys } from '../../hooks' -import { isENOENT } from '../filesystem-path-containment' +import { hasHooksFile, loadHooks, hasUnrecognizedOrcaYamlKeys } from '../../hooks' import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' @@ -30,27 +27,13 @@ export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): v return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false } } - const connectionId = getStoredRepoSshConnectionId(repo) - if (connectionId) { - const fsProvider = getSshFilesystemProvider(connectionId) - if (!fsProvider) { - return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false } - } - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - return { - status: 'ok', - hasHooks: !result.isBinary, - hooks: result.isBinary ? null : parseOrcaYaml(result.content), - mayNeedUpdate: false - } - } catch (error) { - return { - status: isENOENT(error) ? 'ok' : 'error', - hasHooks: false, - hooks: null, - mayNeedUpdate: false - } + if (getStoredRepoSshConnectionId(repo)) { + // Why: this process serves no SSH filesystem. + return { + status: 'error', + hasHooks: false, + hooks: null, + mayNeedUpdate: false } } diff --git a/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts index ca1bfbcfb78..e5d840f73a0 100644 --- a/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts +++ b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts @@ -4,14 +4,7 @@ import { ipcMain } from 'electron' import type { ExecutionHostId } from '../../../shared/execution-host' import { isFolderRepo } from '../../../shared/repo-kind' import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths' -import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' -import { isENOENT } from '../filesystem-path-containment' -import { parseOrcaYaml } from '../../hooks' -import { - isIssueCommandIgnoredByGit, - readIssueCommand, - writeIssueCommand -} from '../../issue-command-file' +import { readIssueCommand, writeIssueCommand } from '../../issue-command-file' import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' @@ -35,52 +28,14 @@ export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): v } const connectionId = getStoredRepoSshConnectionId(repo) if (connectionId) { - const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(connectionId) - if (!fsProvider) { - return { - status: 'error', - localContent: null, - sharedContent: null, - effectiveContent: null, - localFilePath: issueCommandPath, - source: 'none' as const - } - } - - let status: 'ok' | 'error' = 'ok' - let localContent: string | null = null - let sharedContent: string | null = null - try { - const result = await fsProvider.readFile(issueCommandPath) - localContent = result.isBinary ? null : result.content.trim() || null - } catch (error) { - if (!isENOENT(error)) { - status = 'error' - } - } - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - sharedContent = result.isBinary - ? null - : parseOrcaYaml(result.content)?.issueCommand?.trim() || null - } catch (error) { - if (!isENOENT(error)) { - status = 'error' - } - } - const effectiveContent = localContent ?? sharedContent + // Why: this process serves no SSH filesystem. return { - status: localContent ? 'ok' : status, - localContent, - sharedContent, - effectiveContent, - localFilePath: issueCommandPath, - source: localContent - ? ('local' as const) - : sharedContent - ? ('shared' as const) - : ('none' as const) + status: 'error', + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: joinWorktreeRelativePath(repo.path, '.orca/issue-command'), + source: 'none' as const } } return readIssueCommand(repo.path) @@ -94,44 +49,8 @@ export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): v if (!repo || isFolderRepo(repo)) { return } - const connectionId = getStoredRepoSshConnectionId(repo) - if (connectionId) { - const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(connectionId) - if (!fsProvider) { - throw new Error( - 'Remote filesystem unavailable. Reconnect the SSH target before retrying.' - ) - } - const trimmed = args.content.trim() - if (!trimmed) { - await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => { - if (!isENOENT(error)) { - throw error - } - }) - return - } - await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) - if (await isIssueCommandIgnoredByGit(repo.path, connectionId)) { - await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) - return - } - const gitignorePath = joinWorktreeRelativePath(repo.path, '.gitignore') - try { - const result = await fsProvider.readFile(gitignorePath) - if (!result.isBinary && !/^\.orca\/?$/m.test(result.content)) { - const separator = result.content.endsWith('\n') ? '' : '\n' - await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`) - } - } catch (error) { - if (!isENOENT(error)) { - throw error - } - await fsProvider.writeFile(gitignorePath, '.orca\n') - } - await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) - return + if (getStoredRepoSshConnectionId(repo)) { + throw new Error('Remote filesystem unavailable. Reconnect the SSH target before retrying.') } await writeIssueCommand(repo.path, args.content, () => getLocalProjectWorktreeGitOptions(store, repo) diff --git a/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts b/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts index 6df6e79dbb8..9f5cb4b36c6 100644 --- a/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts +++ b/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts @@ -4,7 +4,6 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import { isFolderRepo } from '../../../shared/repo-kind' import { inspectSetupScriptImportCandidates } from '../../../shared/setup-script-imports' import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths' -import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { readFile, stat } from 'node:fs/promises' import { isENOENT } from '../filesystem-path-containment' import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' @@ -17,27 +16,14 @@ export function registerWorktreeHookInspectionHandler(context: WorktreeIpcContex 'hooks:inspectSetupScriptImports', async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) - if (!repo || isFolderRepo(repo)) { + // Why: this process serves no SSH filesystem. + if (!repo || isFolderRepo(repo) || getStoredRepoSshConnectionId(repo)) { return [] } return inspectSetupScriptImportCandidates( async (relativePath) => { const filePath = joinWorktreeRelativePath(repo.path, relativePath) - const connectionId = getStoredRepoSshConnectionId(repo) - if (connectionId) { - const fsProvider = getSshFilesystemProvider(connectionId) - if (!fsProvider) { - return null - } - try { - const result = await fsProvider.readFile(filePath) - return result.isBinary ? null : result.content - } catch { - return null - } - } - try { return await readFile(filePath, 'utf-8') } catch (error) { @@ -50,20 +36,6 @@ export function registerWorktreeHookInspectionHandler(context: WorktreeIpcContex { fileExists: async (relativePath) => { const filePath = joinWorktreeRelativePath(repo.path, relativePath) - const connectionId = getStoredRepoSshConnectionId(repo) - if (connectionId) { - const fsProvider = getSshFilesystemProvider(connectionId) - if (!fsProvider) { - return false - } - try { - const fileStat = await fsProvider.stat(filePath) - return fileStat.type !== 'directory' - } catch { - return false - } - } - try { const fileStat = await stat(filePath) return !fileStat.isDirectory() diff --git a/src/main/ipc/hooks/worktree-hook-execution-host.test.ts b/src/main/ipc/hooks/worktree-hook-execution-host.test.ts index d38ac9b3b4a..c4a705f6b3b 100644 --- a/src/main/ipc/hooks/worktree-hook-execution-host.test.ts +++ b/src/main/ipc/hooks/worktree-hook-execution-host.test.ts @@ -10,13 +10,7 @@ const mocks = vi.hoisted(() => ({ localRead: vi.fn(), localWrite: vi.fn(), localFileRead: vi.fn(), - localStat: vi.fn(), - remoteRead: vi.fn(), - remoteWrite: vi.fn(), - remoteStat: vi.fn(), - remoteMkdir: vi.fn(), - provider: vi.fn(), - ignored: vi.fn() + localStat: vi.fn() })) vi.mock('electron', () => ({ ipcMain: { @@ -33,16 +27,15 @@ vi.mock('../../hooks', () => ({ })) vi.mock('../../issue-command-file', () => ({ readIssueCommand: mocks.localRead, - writeIssueCommand: mocks.localWrite, - isIssueCommandIgnoredByGit: mocks.ignored + writeIssueCommand: mocks.localWrite })) vi.mock('../../project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: () => ({}) })) -vi.mock('../../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: mocks.provider +vi.mock('node:fs/promises', () => ({ + readFile: mocks.localFileRead, + stat: mocks.localStat })) -vi.mock('node:fs/promises', () => ({ readFile: mocks.localFileRead, stat: mocks.localStat })) vi.mock('../../../shared/setup-script-imports', () => ({ inspectSetupScriptImportCandidates: async ( read: (path: string) => Promise, @@ -67,22 +60,11 @@ import { registerWorktreeHookInspectionHandler } from './register-worktree-hook- beforeEach(() => { vi.clearAllMocks() mocks.handlers.clear() - mocks.remoteRead.mockResolvedValue({ content: 'remote command', isBinary: false }) - mocks.remoteWrite.mockResolvedValue(undefined) - mocks.remoteMkdir.mockResolvedValue(undefined) - mocks.remoteStat.mockResolvedValue({ type: 'file' }) - mocks.ignored.mockResolvedValue(true) mocks.localRead.mockResolvedValue({ localContent: 'wrong host' }) mocks.localWrite.mockResolvedValue(undefined) mocks.localFileRead.mockResolvedValue('wrong host') mocks.localStat.mockResolvedValue({ isDirectory: () => false }) mocks.hasHooks.mockReturnValue(false) - mocks.provider.mockReturnValue({ - readFile: mocks.remoteRead, - writeFile: mocks.remoteWrite, - createDir: mocks.remoteMkdir, - stat: mocks.remoteStat - }) }) const owners = [ @@ -151,21 +133,14 @@ const localCalls = [ mocks.effectiveHooks ] +// Why: this process serves no SSH filesystem, so an SSH owner is neither read remotely nor locally. function expectRoute(target: string | null): void { if (target) { - expect(mocks.provider).toHaveBeenCalledWith(target) - for (const [connectionId] of mocks.provider.mock.calls) { - expect(connectionId).toBe(target) - } for (const method of localCalls) { expect(method).not.toHaveBeenCalled() } } else { - expect(mocks.provider).not.toHaveBeenCalled() expect(localCalls.some((method) => method.mock.calls.length > 0)).toBe(true) - expect(mocks.remoteRead).not.toHaveBeenCalled() - expect(mocks.remoteWrite).not.toHaveBeenCalled() - expect(mocks.remoteStat).not.toHaveBeenCalled() } } @@ -173,19 +148,17 @@ describe.each(owners)('desktop hooks: $label', ({ fields, target }) => { it.each(channels)('routes %s to the stored owner', async (channel) => { const repo = makeRepo(fields) register(repo) - await mocks.handlers.get(channel)!(null, { - repoId: repo.id, - hostId: getRepoExecutionHostId(repo), - content: 'save' - }) + const result = Promise.resolve( + mocks.handlers.get(channel)!(null, { + repoId: repo.id, + hostId: getRepoExecutionHostId(repo), + content: 'save' + }) + ) + await (channel === 'hooks:writeIssueCommand' && target + ? expect(result).rejects.toThrow('Remote filesystem unavailable') + : result) expectRoute(target) - if (channel === 'hooks:writeIssueCommand' && target) { - expect(mocks.ignored).toHaveBeenCalledWith(repo.path, target) - expect(mocks.remoteWrite).toHaveBeenCalledWith( - '/remote/fixture/.orca/issue-command', - 'save\n' - ) - } }) }) @@ -210,16 +183,7 @@ describe.each(owners)('runtime hooks: $label', ({ fields, target }) => { } else { await issue.write(repo.id, 'save') } - if (target) { - // The runtime serves no SSH filesystem: an SSH owner is neither read remotely nor locally. - expect(mocks.provider).not.toHaveBeenCalled() - expect(mocks.remoteWrite).not.toHaveBeenCalled() - for (const method of localCalls) { - expect(method).not.toHaveBeenCalled() - } - } else { - expectRoute(target) - } + expectRoute(target) } ) }) @@ -227,7 +191,6 @@ describe.each(owners)('runtime hooks: $label', ({ fields, target }) => { it('refuses an unreachable canonical SSH write without touching local files', async () => { const repo = makeRepo({ executionHostId: 'ssh:host-a' }) register(repo) - mocks.provider.mockReturnValue(null) await expect( mocks.handlers.get('hooks:writeIssueCommand')!(null, { repoId: repo.id, @@ -246,7 +209,6 @@ it.each(channels)('keeps folder repositories out of %s', async (channel) => { hostId: 'ssh:host-a', content: 'save' }) - expect(mocks.provider).not.toHaveBeenCalled() expect(mocks.hasHooks).not.toHaveBeenCalled() expect(mocks.localRead).not.toHaveBeenCalled() expect(mocks.localWrite).not.toHaveBeenCalled() diff --git a/src/main/ipc/mobile-pairing-copy-link.test.ts b/src/main/ipc/mobile-pairing-copy-link.test.ts deleted file mode 100644 index bc942b89544..00000000000 --- a/src/main/ipc/mobile-pairing-copy-link.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -const { handlers } = vi.hoisted(() => ({ - handlers: new Map unknown>() -})) - -vi.mock('electron', () => ({ - app: { isPackaged: false }, - ipcMain: { - handle: (channel: string, handler: (...args: unknown[]) => unknown) => - handlers.set(channel, handler) - }, - shell: { openExternal: vi.fn() } -})) - -import { registerMobileHandlers } from './mobile' - -describe('mobile pairing copy link', () => { - it('copies a verified https link while the QR keeps the custom scheme older phones scan', async () => { - const createMobilePairingOffer = vi.fn().mockResolvedValue({ - available: true, - pairingUrl: 'orca://pair?code=secret-code', - endpoint: 'ws://192.168.1.24:6768', - deviceId: 'mobile-1', - connectionMode: 'local-only' - }) - const encodePairingQr = vi - .fn() - .mockResolvedValue({ ok: true, qrDataUrl: 'data:image/png;base64,qr', qrSize: 58 }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: getPairingQR with an explicit address calls only createMobilePairingOffer. - registerMobileHandlers({ createMobilePairingOffer } as never, { encodePairingQr }) - - await expect( - handlers.get('mobile:getPairingQR')?.(null, { address: '192.168.1.24' }) - ).resolves.toMatchObject({ pairingUrl: 'https://www.onorca.dev/pair#secret-code' }) - expect(encodePairingQr).toHaveBeenCalledWith('orca://pair?code=secret-code') - }) -}) diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index a16f1449e5d..c148abe95a1 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -346,7 +346,7 @@ describe('registerMobileHandlers', () => { await expect(handlers.get('mobile:getPairingQR')?.(null, {})).resolves.toMatchObject({ available: true, qrSize: 58, - pairingUrl: 'https://www.onorca.dev/pair#mobile', + pairingUrl: 'orca://pair#mobile', endpoint: 'ws://100.102.47.57:6768', deviceId: 'mobile-1', connectionMode: 'automatic' @@ -426,7 +426,7 @@ describe('registerMobileHandlers', () => { qrDataUrl: null, qrSize: null, qrError: 'encoding_failed', - pairingUrl: 'https://www.onorca.dev/pair#copy-me', + pairingUrl: 'orca://pair?code=copy-me', endpoint: 'wss://pair.example/oversized', deviceId: 'mobile-large', connectionMode: 'local-only' diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index e1a20f76666..ccd3a6742ef 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -2,7 +2,6 @@ import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address' -import { toVerifiedPairingLink } from '../../shared/pairing' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' import type { DeviceEntry } from '../runtime/device-registry' import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance' @@ -135,9 +134,7 @@ export function registerMobileHandlers( qrDataUrl: qr.ok ? qr.qrDataUrl : null, qrSize: qr.ok ? qr.qrSize : null, ...(!qr.ok ? { qrError: qr.reason } : {}), - // Why: the QR keeps the custom scheme older phones' scanners read, but a copied link can be - // tapped, and an `orca://` tap goes to whichever app claimed the scheme. - pairingUrl: toVerifiedPairingLink(offer.pairingUrl) ?? offer.pairingUrl, + pairingUrl: offer.pairingUrl, // Why: with nothing advertised the offer's endpoint is the loopback fallback, which points at // whichever device scans the QR — never this host. Report no endpoint so the UI omits it // instead of printing an address the phone can't reach. diff --git a/src/main/ipc/orcad-delta-move-handlers.test.ts b/src/main/ipc/orcad-delta-move-handlers.test.ts index 26f0dc386e7..47bc074b325 100644 --- a/src/main/ipc/orcad-delta-move-handlers.test.ts +++ b/src/main/ipc/orcad-delta-move-handlers.test.ts @@ -32,9 +32,12 @@ vi.mock('../ssh/orcad-migration-delta-move', () => ({ vi.mock('../ssh/orcad-runtime-conversion-wiring', () => ({ orcadMigrationDestinationFor: () => ({}) })) -vi.mock('../ssh/orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: vi.fn() })) -vi.mock('../ssh/ssh-target-registry', () => ({ hasRegisteredDirectSshAuthority: () => false })) -vi.mock('./ssh-session-teardown', () => ({ disconnectRegisteredSshTarget: vi.fn() })) +vi.mock('../ssh/orcad-managed-tunnel', () => ({ + ensureOrcadManagedTunnel: vi.fn() +})) +vi.mock('./ssh-session-teardown', () => ({ + disconnectRegisteredSshTarget: vi.fn() +})) vi.mock('./runtime-environment-managed-tunnel', () => ({ publishResolvedChangedHostStatus: mocks.publish })) diff --git a/src/main/ipc/orcad-delta-move-handlers.ts b/src/main/ipc/orcad-delta-move-handlers.ts index b2a3353e22a..fa9abfb85db 100644 --- a/src/main/ipc/orcad-delta-move-handlers.ts +++ b/src/main/ipc/orcad-delta-move-handlers.ts @@ -11,9 +11,7 @@ import { keepOrcadServerVersion, runOrcadDeltaMove } from '../ssh/orcad-migratio import { planOrcadDeltaMove } from '../ssh/orcad-migration-delta-plan' import { censusHostRelayTerminalsFor } from '../ssh/ssh-host-relay-census-for-target' import { orcadMigrationDestinationFor } from '../ssh/orcad-runtime-conversion-wiring' -import { hasRegisteredDirectSshAuthority } from '../ssh/ssh-target-registry' import { requiredString } from './orcad-runtime-lifecycle-handlers' -import { disconnectRegisteredSshTarget } from './ssh-session-teardown' import { publishResolvedChangedHostStatus } from './runtime-environment-managed-tunnel' import { runTargetLifecycle } from './ssh-target-lifecycle-queue' @@ -46,11 +44,6 @@ export function registerOrcadDeltaMoveHandlers(getUserDataPath: () => string): v environment, destination: orcadMigrationDestinationFor(environment), censusHost: censusHostRelayTerminalsFor(target), - releaseDirectSession: async (targetId) => { - if (hasRegisteredDirectSshAuthority(targetId)) { - await disconnectRegisteredSshTarget(targetId) - } - }, ensureTunnel: async () => { await ensureOrcadManagedTunnel(userDataPath, environment.id) }, diff --git a/src/main/ipc/pty-launch-parity-caller.test-cases.ts b/src/main/ipc/pty-launch-parity-caller.test-cases.ts new file mode 100644 index 00000000000..a10592bed8e --- /dev/null +++ b/src/main/ipc/pty-launch-parity-caller.test-cases.ts @@ -0,0 +1,148 @@ +// Test-only: host-lane parity cases whose caller already built the launch (row 8, run verbatim), +// or sends a paired host its intent (row 7) or a model pick (row 4). +import { POSIX_PATH, WIN_PATH } from '../../shared/launch-parity-window-cases.test-fixture' +import { + AGENT_PHONE, + CMD, + CMD_EXE, + HOST_LEAF_ID, + HOST_TAB_ID, + repo, + ZSH, + type HostLaunchCall, + type HostLaunchCase, + type HostProviderFacts +} from './pty-launch-parity-host.test-cases' + +// A window-built codex startup quoted for a Windows cmd.exe client; the host must not re-quote it. +const CLIENT_CODEX = 'codex "--dangerously-bypass-approvals-and-sandbox"' +const CALLER_ENV = { CUSTOM_FLAG: '1' } +const NEW_WORKSPACE = { + agent_kind: 'codex', + launch_source: 'new_workspace_composer', + request_kind: 'new' +} as const +export const OPENCODE_MODEL = 'opencode/fledge-alpha-free' +const rpc = ( + method: 'terminal.create' | 'terminal.createAgentSession', + params: Record +): HostLaunchCall => ({ kind: 'rpc', method, params }) +const POSIX_CLAUDE = "claude '--dangerously-skip-permissions'" +const CODEX = "codex '--dangerously-bypass-approvals-and-sandbox'" + +// Row 7: the terminal.createAgentSession params a paired desktop's new tab sends a current host. +// main today: command, env, launchConfig and startupCommandDelivery never reach the host; agentArgs +// is the window's default args. +const PAIRED_TAB_PATH = '/worktree' +const PAIRED_TAB = { + worktree: `id:repo-1::${PAIRED_TAB_PATH}`, + viewMode: 'terminal', + presentation: 'background' +} as const +const PAIRED_TAB_CASES: [string, Record, HostProviderFacts][] = [ + [ + 'claude draft continuation, initial cwd', + { + ...PAIRED_TAB, + agent: 'claude', + prompt: "review Bob's change", + promptDelivery: 'draft', + agentArgs: '--dangerously-skip-permissions', + startupCwd: `${PAIRED_TAB_PATH}/packages/app` + }, + { + command: `${POSIX_CLAUDE} --prefill 'review Bob'"'"'s change'`, + cwd: `${PAIRED_TAB_PATH}/packages/app` + } + ], + [ + 'codex auto-submit', + { + ...PAIRED_TAB, + agent: 'codex', + prompt: 'fix it', + promptDelivery: 'auto-submit', + agentArgs: '--dangerously-bypass-approvals-and-sandbox' + }, + { command: `${CODEX} 'fix it'`, startupCommandDelivery: 'shell-ready' } + ] +] + +export const CALLER_LAUNCH_CASES: HostLaunchCase[] = [ + // C1 changes the host planner; these are its only draft (--prefill) and startupCwd-from-intent rows. + ...PAIRED_TAB_CASES.map(([name, params, provider]): HostLaunchCase => ({ + name: `paired new tab: ${name}`, + row: '7', + os: 'linux', + workspace: repo(PAIRED_TAB_PATH), + call: rpc('terminal.createAgentSession', params), + provider: { ...provider, ...ZSH }, + // main today: a paired client's launch_source never reaches the host. + telemetry: { + agent_kind: params.agent === 'claude' ? 'claude-code' : String(params.agent), + launch_source: 'unknown', + request_kind: 'new' + }, + phone: { title: 'Terminal', launchAgent: String(params.agent), isActive: false } + })), + { + // C4/C5: worktree-remote.ts spawnLocalStartupAndSetupTerminals runs the caller's command and env + // without re-quoting them, and keeps telemetry. + name: 'local-git worktrees:create startup, verbatim', + row: '8', + os: 'linux', + workspace: repo(POSIX_PATH), + call: { + kind: 'create', + options: { + command: CLIENT_CODEX, + env: CALLER_ENV, + launchConfig: { + agentCommand: CLIENT_CODEX, + agentArgs: '--dangerously-bypass-approvals-and-sandbox', + agentEnv: CALLER_ENV + }, + launchAgent: 'codex', + startupCommandDelivery: 'shell-ready', + surfaceOwner: false, + telemetry: NEW_WORKSPACE + } + }, + provider: { + command: CLIENT_CODEX, + startupCommandDelivery: 'shell-ready', + env: CALLER_ENV, + ...ZSH + }, + telemetry: NEW_WORKSPACE, + phone: { ...AGENT_PHONE, launchAgent: 'codex' } + }, + // C2 keeps model validation on the host while moving the spawn to the pane; the command must survive. + ...( + [ + ['macOS repo', 'darwin', POSIX_PATH, "opencode '--standalone'", ZSH], + ['Windows C: repo', 'win32', WIN_PATH, 'opencode "--standalone"', CMD_EXE] + ] as const + ).map(([where, os, path, command, shell]): HostLaunchCase => ({ + name: `OpenCode model pick, ${where}`, + row: '4', + os, + workspace: repo(path), + settings: CMD, + // spawnIpcPty's terminal.createAgentSession for a seeded model-pick pane. + call: rpc('terminal.createAgentSession', { + worktree: `id:repo-1::${path}`, + agent: 'opencode', + launchPreferences: { model: OPENCODE_MODEL }, + startupCwd: path, + placement: { tabId: HOST_TAB_ID, leafId: HOST_LEAF_ID }, + presentation: 'background', + // A local Windows ConPTY pane withholds kitty. + ...(path === WIN_PATH ? {} : { terminalKittyKeyboardProtocol: true }) + }), + provider: { command, ...shell }, + // main today: the pane's own launch_source never reaches the host. + telemetry: { agent_kind: 'opencode', launch_source: 'unknown', request_kind: 'new' }, + phone: { title: 'Terminal', launchAgent: 'opencode', isActive: false } + })) +] diff --git a/src/main/ipc/pty-launch-parity-host.test-cases.ts b/src/main/ipc/pty-launch-parity-host.test-cases.ts new file mode 100644 index 00000000000..631291c0a10 --- /dev/null +++ b/src/main/ipc/pty-launch-parity-host.test-cases.ts @@ -0,0 +1,188 @@ +// Test-only: the host-lane launch parity table (rows 3, 4, 7, 8, 10). Each case is the call a host +// producer makes and the facts main hands the provider, the phone and telemetry for it. +import type { TerminalCreateOptions } from '../runtime/runtime-terminal-contracts' +import type { LaunchWorkspace } from '../../shared/launch-parity-window-request.test-fixture' +import { POSIX_PATH } from '../../shared/launch-parity-window-cases.test-fixture' + +export const HOST_TAB_ID = '33333333-3333-4333-8333-333333333333' +export const HOST_LEAF_ID = '44444444-4444-4444-8444-444444444444' + +export type HostLaunchCall = + | { kind: 'create'; options: TerminalCreateOptions } + /** A paired client's RPC, run through the host's method handler; clientOperationId is added. */ + | { + kind: 'rpc' + method: 'terminal.create' | 'terminal.createAgentSession' + params: Record + } + +export type HostProviderFacts = { + command: string + /** Default: the workspace path. */ + cwd?: string + shellOverride?: string + terminalWindowsWslDistro?: string | null + startupCommandDelivery?: 'shell-ready' + /** The agent or caller env keys the case set, with their values; default none. */ + env?: Record + /** Whether main marks the PTY hidden before it has a view; default true on this lane. */ + hidden?: boolean +} + +export type HostLaunchCase = { + name: string + /** The launch-convergence producer row. */ + row: '3' | '4' | '7' | '8' | '10' + os: NodeJS.Platform + workspace: LaunchWorkspace + settings?: Record + call: HostLaunchCall + provider: HostProviderFacts + /** agent_started payload, or null when main emits none. */ + telemetry: Record | null + /** The phone's first session tab. */ + phone: { title: string; launchAgent?: string; isActive: boolean } +} + +// rpc/methods/agent-launch-surfaces.ts createTerminalAgent options for an AI button's reserved pane. +const agentLaunch = (extra: Record = {}): HostLaunchCall => ({ + kind: 'create', + options: { + startupAgent: 'claude', + tabId: HOST_TAB_ID, + leafId: HOST_LEAF_ID, + requireFreshPane: true, + launchSource: 'task_page', + viewMode: 'terminal', + surfaceOwner: false, + ...extra + } +}) +// worker-topology.ts createExistingWorktreeWorkerTerminal options. +const worker: HostLaunchCall = { + kind: 'create', + options: { + startupAgent: 'claude', + launchSource: 'orchestration', + title: 'worker-task-1', + surfaceOwner: false + } +} + +export const repo = (path: string): LaunchWorkspace => ({ kind: 'repo', path }) +const folder = (path: string): LaunchWorkspace => ({ kind: 'folder', path }) +// The same factory for a mobile quick command (agent.launchReplay with a reserved pane and a +// prompt): a paired phone never moves the host window. +const mobileQuickCommand = (startupPrompt: string): HostLaunchCall => ({ + kind: 'create', + options: { + startupAgent: 'codex', + startupPrompt, + launchSource: 'quick_command', + tabId: HOST_TAB_ID, + leafId: HOST_LEAF_ID, + requireFreshPane: true, + viewMode: 'terminal', + surfaceOwner: false + } +}) +const TASK_PAGE = { agent_kind: 'claude-code', launch_source: 'task_page', request_kind: 'new' } +// The AI button that reaches each workspace kind: fix checks needs a repo with a PR (task_page); +// in a folder, review notes' "New agent" (also on browser and markdown annotations, notes_send). +const aiButtonSource = (workspace: LaunchWorkspace): string => + workspace.kind === 'repo' ? 'task_page' : 'notes_send' +const QUICK_COMMAND = { agent_kind: 'codex', launch_source: 'quick_command', request_kind: 'new' } +export const AGENT_PHONE = { title: 'Terminal', launchAgent: 'claude', isActive: true } +const POSIX_CLAUDE = "claude '--dangerously-skip-permissions'" +// main today: a local POSIX host hands the provider the settings shell untrimmed. +export const ZSH = { shellOverride: ' /bin/zsh ', terminalWindowsWslDistro: null } +export const CMD_EXE = { shellOverride: 'cmd.exe', terminalWindowsWslDistro: null } +export const CMD = { terminalWindowsShell: 'cmd.exe' } +// A saved default argument with an apostrophe: the byte that tells POSIX and PowerShell quoting apart. +const BOB_ARGS = { + agentDefaultArgs: { claude: `--dangerously-skip-permissions --name "Bob's box"` } +} +const BOB_POSIX = `${POSIX_CLAUDE} '--name' 'Bob'"'"'s box'` +const CODEX = "codex '--dangerously-bypass-approvals-and-sandbox'" + +/** Row 3: agent.launch with no prompt; `command` + shell are what the provider gets. */ +function aiButton( + name: string, + os: NodeJS.Platform, + workspace: LaunchWorkspace, + provider: HostProviderFacts, + settings?: Record +): HostLaunchCase { + return { + name, + row: '3', + os, + workspace, + ...(settings ? { settings } : {}), + call: agentLaunch({ launchSource: aiButtonSource(workspace) }), + provider, + telemetry: { ...TASK_PAGE, launch_source: aiButtonSource(workspace) }, + phone: AGENT_PHONE + } +} +export const HOST_LAUNCH_CASES: HostLaunchCase[] = [ + // C1 (host planner inputs) and C2 (AI buttons onto the pane trigger) must keep these commands. + aiButton('macOS repo', 'darwin', repo(POSIX_PATH), { command: POSIX_CLAUDE, ...ZSH }), + aiButton( + 'macOS folder, saved default args', + 'darwin', + folder(POSIX_PATH), + { command: BOB_POSIX, ...ZSH }, + BOB_ARGS + ), + { + // C1-C7 keep the host path's prompt carry. + name: 'mobile quick command, macOS repo, codex prompt waits for the shell', + row: '10', + os: 'darwin', + workspace: repo(POSIX_PATH), + call: mobileQuickCommand("fix Bob's branch"), + provider: { + command: `${CODEX} 'fix Bob'"'"'s branch'`, + startupCommandDelivery: 'shell-ready', + ...ZSH + }, + telemetry: QUICK_COMMAND, + phone: { ...AGENT_PHONE, launchAgent: 'codex' } + }, + // C5d/C5e (options, presentation) check host callers against these. + ...[repo(POSIX_PATH), folder(POSIX_PATH)].map((workspace): HostLaunchCase => ({ + name: `orchestration worker, Linux ${workspace.kind}`, + row: '10', + os: 'linux', + workspace, + call: worker, + provider: { command: POSIX_CLAUDE, ...ZSH }, + telemetry: { ...TASK_PAGE, launch_source: 'orchestration' }, + phone: { title: 'worker-task-1', launchAgent: 'claude', isActive: true } + })) +] + +const HOST_ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_LAUNCH_TOKEN', + 'ORCA_TERMINAL_HANDLE' +] +const FOLDER_ENV_KEYS = ['ORCA_WORKSPACE_ID', 'ORCA_PROJECT_GROUP_ID', 'ORCA_WORKSPACE_ROOT'] + +/** What main hands the provider for a host-lane case. */ +export function expectedHostProvider(c: HostLaunchCase): Record { + return { + cwd: c.workspace.path.replaceAll('\\', '/'), + cols: 120, + rows: 40, + commandDelivery: 'provider', + hidden: true, + env: {}, + ...c.provider, + // main today: the host lane stamps ORCA_WORKSPACE_ID on folders only. + orcaEnv: c.workspace.kind === 'folder' ? [...FOLDER_ENV_KEYS, ...HOST_ENV_KEYS] : HOST_ENV_KEYS + } +} diff --git a/src/main/ipc/pty-launch-parity.test-fixture.ts b/src/main/ipc/pty-launch-parity.test-fixture.ts new file mode 100644 index 00000000000..f17206d7b26 --- /dev/null +++ b/src/main/ipc/pty-launch-parity.test-fixture.ts @@ -0,0 +1,255 @@ +// Test-only: one real OrcaRuntimeService wired into the real pty handlers, so both launch lanes +// reach the same fake provider. Window lane: pty:spawn. Host lane: createTerminal / createAgentSession +// through the runtime controller registerPtyHandlers installs. Suites still declare the IPC vi.mock block. +import { vi } from 'vitest' +import type { Mock } from 'vitest' +import { registerPtyHandlers } from './pty' +import { existsSyncMock, statSyncMock } from './pty-ipc-mock-registry' +import type { PtyIpcSuiteFixtures } from './pty-ipc-test-harness' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { TerminalWorkspaceLaunchScope } from '../runtime/runtime-legacy-worker-terminal-recovery-types' +import { + IDENTITY_ENV_KEYS, + type LaunchWorkspace +} from '../../shared/launch-parity-window-request.test-fixture' +import { isHiddenRendererPty } from './pty-hidden-delivery-gate' +import { HOST_TAB_ID } from './pty-launch-parity-host.test-cases' + +const WINDOWS_ENV = { + COMSPEC: 'C:\\Windows\\system32\\cmd.exe', + USERPROFILE: 'C:\\Users\\test', + SystemRoot: 'C:\\Windows', + ProgramW6432: 'C:\\Program Files', + ProgramFiles: 'C:\\Program Files', + 'ProgramFiles(x86)': undefined +} +const ORIGINAL_PLATFORM = process.platform +const savedEnv = new Map() +let remoteSpawns = 0 + +/** Main's process.platform for a case; Windows also gets the env and .exe probes its shells read. */ +export function setMainPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + if (platform !== 'win32') { + return + } + for (const [key, value] of Object.entries(WINDOWS_ENV)) { + if (!savedEnv.has(key)) { + savedEnv.set(key, process.env[key]) + } + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + statSyncMock.mockImplementation((target: string) => { + const isExe = /\.exe$/i.test(String(target)) + return { isDirectory: () => !isExe, isFile: () => isExe, size: 1024, mode: 0o755 } + }) + existsSyncMock.mockReturnValue(true) +} + +export function restoreMainPlatform(): void { + Object.defineProperty(process, 'platform', { configurable: true, value: ORIGINAL_PLATFORM }) + for (const [key, value] of savedEnv) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + savedEnv.clear() +} + +/** Settings both lanes read on main's current OS; the untrimmed default shell shows which reads trim it. */ +function parityMainSettings(overrides: Record = {}): Record { + return { + // Windows hides the default-shell setting and defaults it to ''. + terminalDefaultShell: process.platform === 'win32' ? '' : ' /bin/zsh ', + terminalDefaultShellArgs: ['-l'], + terminalWindowsShell: 'powershell.exe', + terminalWindowsPowerShellImplementation: 'auto', + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + // Why off: a fresh agent launch would otherwise write workspace trust into the agent's home. + agentWorkspaceTrustEnabled: false, + ...overrides + } +} + +function launchScope(workspace: LaunchWorkspace): TerminalWorkspaceLaunchScope { + const connectionId = null + if (workspace.kind === 'repo') { + return { + id: `repo-1::${workspace.path}`, + path: workspace.path, + connectionId, + repo: { + id: 'repo-1', + path: workspace.path, + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + connectionId + }, + folderWorkspace: null + } + } + return { + id: 'folder:fw-1', + path: workspace.path, + connectionId, + repo: null, + folderWorkspace: { + id: 'fw-1', + projectGroupId: 'pg-1', + name: 'folder', + folderPath: workspace.path, + connectionId, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } + } +} + +type ScopeResolver = { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise +} + +export type ParityLanes = { + runtime: OrcaRuntimeService + /** The local daemon provider's spawn mock. */ + provider: Mock + persistPtyBinding: Mock + reveal: Mock + spawnWindow: (request: Record) => Promise +} + +/** Wires one runtime + handlers for a workspace (its saved project runtime included). */ +export function startParityLanes( + suite: Pick, + args: { + workspace: LaunchWorkspace + settings?: Record + spawnError?: Error + } +): ParityLanes { + const settings = parityMainSettings(args.settings) + const persistPtyBinding = vi.fn(async () => true) + const { repo, folderWorkspace } = launchScope(args.workspace) + const store = { + getSettings: () => settings, + getProjects: () => [ + { + id: 'project-1', + sourceRepoIds: ['repo-1'], + ...(args.workspace.projectRuntime + ? { localWindowsRuntimePreference: args.workspace.projectRuntime } + : {}) + } + ], + getRepo: (id: string) => (repo && id === repo.id ? repo : undefined), + getRepos: () => (repo ? [repo] : []), + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + getWorkspaceSession: () => ({ tabsByWorktree: {}, terminalLayoutsByTabId: {} }), + getFolderWorkspace: () => folderWorkspace ?? undefined, + getFolderWorkspaces: () => (folderWorkspace ? [folderWorkspace] : []), + getProjectGroups: () => [{ id: 'pg-1', name: 'group', parentPath: null, connectionId: null }], + persistPtyBinding + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launches read only the store members defined above; the rest of the persistence store is unreachable here. + const runtime = new OrcaRuntimeService(store as never) + const provider = vi.fn(async (options: { sessionId?: string }) => { + if (args.spawnError) { + throw args.spawnError + } + // A fresh id per spawn, so one case's hidden mark never reads into the next. + return { id: options.sessionId ?? `remote-pty-${++remoteSpawns}` } + }) + suite.installDaemonTestProvider({ spawn: provider }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers read the window, runtime, settings and store members these fakes define. + const handlerArgs = [ + suite.mainWindow, + runtime, + undefined, + () => settings, + undefined, + store + ] as unknown as Parameters + registerPtyHandlers(...handlerArgs) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver is a real (protected) runtime method; the scope is this case's input, not an assertion. + const resolver = runtime as unknown as ScopeResolver + vi.spyOn(resolver, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue( + launchScope(args.workspace) + ) + const reveal = vi.fn().mockResolvedValue({ tabId: HOST_TAB_ID }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: reveal, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + return { + runtime, + provider, + persistPtyBinding, + reveal, + spawnWindow: async (request) => suite.handlers.get('pty:spawn')!(null, request) + } +} + +const PROVIDER_FACT_KEYS = [ + 'command', + 'cwd', + 'cols', + 'rows', + 'shellOverride', + 'terminalShellArgs', + 'terminalWindowsWslDistro', + 'commandDelivery', + 'startupCommandDelivery' +] as const + +// The agent and caller env keys the tables save; their values must reach the provider. +const CASE_ENV_KEYS = ['A', 'CUSTOM_FLAG'] + +/** + * The launch facts the plan names at the provider boundary: `orcaEnv` lists identity keys present, + * `env` the case's own env values, `hidden` whether main marked the PTY hidden before a view. + */ +export async function providerFacts(provider: Mock): Promise> { + const options = recordOf(provider.mock.calls.at(-1)?.[0]) + const env = recordOf(options.env) + const spawned = recordOf(await provider.mock.results.at(-1)?.value) + return { + ...Object.fromEntries( + PROVIDER_FACT_KEYS.filter((k) => k in options).map((k) => [k, options[k]]) + ), + orcaEnv: IDENTITY_ENV_KEYS.filter((key) => key in env), + env: Object.fromEntries(CASE_ENV_KEYS.filter((key) => key in env).map((k) => [k, env[k]])), + hidden: isHiddenRendererPty(String(spawned.id)) + } +} + +export function recordOf(value: unknown): Record { + return value && typeof value === 'object' ? { ...value } : {} +} diff --git a/src/main/ipc/pty-launch-parity.test.ts b/src/main/ipc/pty-launch-parity.test.ts new file mode 100644 index 00000000000..1a053ed0778 --- /dev/null +++ b/src/main/ipc/pty-launch-parity.test.ts @@ -0,0 +1,337 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { classifyErrorMock, trackMock } from './pty-ipc-mock-registry' +import { + providerFacts, + recordOf, + restoreMainPlatform, + setMainPlatform, + startParityLanes, + type ParityLanes +} from './pty-launch-parity.test-fixture' +import { + POSIX_PATH, + WINDOW_LAUNCH_CASES +} from '../../shared/launch-parity-window-cases.test-fixture' +import { + expectedWindowProvider, + launchWorkspaceId, + windowSpawnRequest, + type LaunchWorkspace, + type WindowProviderFacts +} from '../../shared/launch-parity-window-request.test-fixture' +import { + AUTOMATION_LAUNCH_CASES, + automationSpawnRequest +} from '../../shared/launch-parity-automation-cases.test-fixture' +import { + expectedHostProvider, + HOST_LAUNCH_CASES, + type HostLaunchCase +} from './pty-launch-parity-host.test-cases' +import { CALLER_LAUNCH_CASES, OPENCODE_MODEL } from './pty-launch-parity-caller.test-cases' +import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import { DESKTOP_RPC_CALLER } from '../runtime/rpc/rpc-caller-identity' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' +import { TERMINAL_LIFECYCLE_METHODS } from '../runtime/rpc/methods/terminal/terminal-lifecycle-methods' +import { AGENT_SESSION_METHODS } from '../runtime/rpc/methods/agent-session' +import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' +import { probeOpenCodeLaunchCapabilities } from '../opencode/opencode-launch-capabilities' +import { + probeOpenCodeModelAvailability, + resolveOpenCodeDirectModelExecutable +} from '../opencode/opencode-model-availability' +import { probeOpenCodeLaunchModelContext } from '../opencode/opencode-launch-model-context' + +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()) +) +// Why: an OpenCode v2 install with the picked model available; the probes would run the binary. +vi.mock('../managed-data-accounts/launch-environment', () => ({ + applyManagedDataAccountEnvironment: vi.fn() +})) +vi.mock('../opencode/opencode-launch-capabilities', () => ({ + probeOpenCodeLaunchCapabilities: vi.fn() +})) +vi.mock('../opencode/opencode-model-availability', () => ({ + resolveOpenCodeDirectModelExecutable: vi.fn(), + probeOpenCodeModelAvailability: vi.fn() +})) +vi.mock('../opencode/opencode-launch-model-context', () => ({ + probeOpenCodeLaunchModelContext: vi.fn() +})) +// Why: every folder in the table exists on its host; main checks that before it spawns. +vi.mock('../project-groups/folder-workspace-path-status', async (importOriginal) => ({ + ...(await importOriginal>()), + getFolderWorkspacePathStatus: vi.fn(async () => ({ path: '', exists: true })) +})) +// Why: no Antigravity account is selected on this machine, so the account switch before an agy launch is a no-op. +vi.mock('../antigravity/native-account-launch', () => ({ + prepareAntigravityAccountForLaunch: vi.fn(async () => {}) +})) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +type WindowLaneCase = { + label: string + client: NodeJS.Platform + workspace: LaunchWorkspace + settings?: Record + request: Record + provider: WindowProviderFacts + /** The agent env the case saved, as the provider must receive it. */ + env: Record +} + +const WINDOW_LANE_CASES: WindowLaneCase[] = [ + ...WINDOW_LAUNCH_CASES.map((c) => ({ + ...c, + label: `row ${c.row}: ${c.name}`, + request: windowSpawnRequest(c), + env: c.request.agentEnv ?? {} + })), + ...AUTOMATION_LAUNCH_CASES.map((c) => ({ + ...c, + label: `row 5: ${c.name}`, + request: automationSpawnRequest(c), + env: {} + })) +] + +// Pins main's current launch behaviour as the convergence parity baseline (rows 2, 5 and 6, main +// half): each table request, fed to pty:spawn, reaches the provider with these facts (the pane's +// measured size, never hidden), one agent_started, and a pane binding. +// C5a-e and C6 check the merged spawn pipeline against it. +describe('window lane: pty:spawn request -> provider', () => { + const suite = setupPtyIpcSuite() + afterEach(() => restoreMainPlatform()) + + it.each(WINDOW_LANE_CASES)( + '$label', + async ({ client, workspace, settings, request, provider, env: agentEnv }) => { + // A desktop client's main process runs on the client OS. + setMainPlatform(client) + const lanes = startParityLanes(suite, { workspace, settings }) + await lanes.spawnWindow(request) + + expect(await providerFacts(lanes.provider)).toEqual( + expectedWindowProvider(request, provider, agentEnv) + ) + expect(trackMock.mock.calls).toEqual([['agent_started', request.telemetry]]) + expect(lanes.persistPtyBinding.mock.calls).toEqual([ + [ + { + worktreeId: request.worktreeId, + tabId: request.tabId, + leafId: request.leafId, + ptyId: expect.any(String), + startupCwd: expect.any(String), + ...pick(request, ['placement']), + origin: 'spawn' + } + ] + ]) + } + ) +}) + +function pick(source: Record, keys: string[]): Record { + return Object.fromEntries(keys.filter((key) => key in source).map((key) => [key, source[key]])) +} + +function stubOpenCodeV2(): void { + vi.mocked(applyManagedDataAccountEnvironment).mockReset() + vi.mocked(resolveOpenCodeDirectModelExecutable).mockReset().mockResolvedValue('/bin/opencode') + vi.mocked(probeOpenCodeLaunchCapabilities) + .mockReset() + .mockResolvedValue({ version: '2.0.16', pluginApi: 'v2', promptMode: 'prefill' }) + vi.mocked(probeOpenCodeModelAvailability).mockReset().mockResolvedValue(true) + vi.mocked(probeOpenCodeLaunchModelContext) + .mockReset() + .mockResolvedValueOnce({ + primaryAgent: 'build', + availableModels: [OPENCODE_MODEL], + primaryModel: 'opencode/big-pickle' + }) + .mockResolvedValue({ + primaryAgent: 'build', + availableModels: [OPENCODE_MODEL], + primaryModel: OPENCODE_MODEL + }) +} + +async function runHostCall(lanes: ParityLanes, c: HostLaunchCase): Promise { + if (c.call.kind === 'create') { + return lanes.runtime.createTerminal(`id:${launchWorkspaceId(c.workspace)}`, c.call.options) + } + const { method, params } = c.call + const dispatcher = new RpcDispatcher({ + runtime: lanes.runtime, + methods: [...TERMINAL_LIFECYCLE_METHODS, ...AGENT_SESSION_METHODS] + }) + const response = await dispatcher.dispatch( + { + id: 'parity', + // The transport authenticated the caller before dispatch; the dispatcher only carries it. + authToken: 'token', + method, + params: + method === 'terminal.createAgentSession' + ? { clientOperationId: `${Date.now()}-0123456789abcdef0123456789abcdef`, ...params } + : params + }, + // Row 4 is the desktop's own runtime:call (ipc/runtime.ts); row 7 comes from a paired desktop. + c.row === '4' + ? { + clientId: 'desktop-renderer', + caller: DESKTOP_RPC_CALLER, + clientKind: 'runtime', + connectionId: 'desktop-window-1', + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES + } + : { clientId: 'device-1', pairedDeviceId: 'device-1', clientKind: 'runtime' } + ) + if (!response.ok) { + throw Object.assign(new Error(response.error.message), { code: response.error.code }) + } + return response.result +} + +// Pins main's current launch behaviour as the convergence parity baseline (rows 3, 4, 7, 8, 10): +// each host producer's call reaches the provider through the real runtime controller with these +// facts, telemetry, placement and phone tab. C1, C2 and C5a-e check the host path against it. +describe('host lane: producer call -> runtime controller -> provider', () => { + const suite = setupPtyIpcSuite() + beforeEach(() => stubOpenCodeV2()) + afterEach(() => restoreMainPlatform()) + + it.each([...HOST_LAUNCH_CASES, ...CALLER_LAUNCH_CASES])('row $row: $name', async (c) => { + setMainPlatform(c.os) + const lanes = startParityLanes(suite, { workspace: c.workspace, settings: c.settings }) + await runHostCall(lanes, c) + + expect(await providerFacts(lanes.provider)).toEqual(expectedHostProvider(c)) + // The host stamps its own pane identity (the window lane's stale-key case is below). + const spawned = recordOf(lanes.provider.mock.calls.at(-1)?.[0]) + expect(recordOf(spawned.env).ORCA_PANE_KEY).toBe(spawned.paneKey) + expect(trackMock.mock.calls).toEqual(c.telemetry ? [['agent_started', c.telemetry]] : []) + expect(lanes.persistPtyBinding.mock.calls[0]?.[0]).toMatchObject({ + placement: { kind: 'new-tab' } + }) + const phone = await lanes.runtime.listMobileSessionTabs(`id:${launchWorkspaceId(c.workspace)}`) + expect(pick({ ...phone.tabs[0] }, ['title', 'launchAgent', 'isActive'])).toEqual(c.phone) + }) +}) + +const POSIX_REPO = { kind: 'repo', path: POSIX_PATH } as const +const WORKER = HOST_LAUNCH_CASES.find((c) => c.name.startsWith('orchestration worker'))! + +// Pins main's current launch behaviour as the convergence parity baseline (rows 2 and 10; failed +// spawn, reveal, pane identity): the facts no single table row carries. +describe('launch facts outside the tables', () => { + const suite = setupPtyIpcSuite() + afterEach(() => restoreMainPlatform()) + + // C5a (one agent_error place): main today reports a failed window-lane spawn. + it.each([ + { + lane: 'telemetry kind wins over the claude sniff', + request: { command: 'claude', telemetry: { agent_kind: 'opencode' } }, + calls: [['agent_error', { agent_kind: 'opencode', error_class: 'binary_not_found' }]] + }, + { + lane: 'local claude without telemetry', + request: { command: 'claude' }, + calls: [['agent_error', { agent_kind: 'claude-code', error_class: 'binary_not_found' }]] + } + ])('a failed window spawn, $lane, emits agent_error', async (c) => { + setMainPlatform('darwin') + classifyErrorMock.mockReturnValue({ error_class: 'binary_not_found' }) + const spawnError = new Error('spawn boom') + const lanes = startParityLanes(suite, { workspace: POSIX_REPO, spawnError }) + const launch = lanes.spawnWindow({ + cols: 80, + rows: 24, + cwd: POSIX_PATH, + worktreeId: launchWorkspaceId(POSIX_REPO), + ...c.request + }) + await expect(launch).rejects.toThrow('spawn boom') + expect(trackMock.mock.calls.filter(([event]) => event === 'agent_error')).toEqual(c.calls) + }) + + // C5e (presentation merge): a worker asks the window to reveal its titled tab without activating it. + it('row 10: an orchestration worker requests a titled, inactive reveal', async () => { + setMainPlatform(WORKER.os) + const lanes = startParityLanes(suite, { workspace: WORKER.workspace }) + await runHostCall(lanes, WORKER) + expect(lanes.reveal.mock.calls[0]?.[1]).toMatchObject({ + title: 'worker-task-1', + activate: false, + surfaceOwner: false + }) + expect(lanes.reveal.mock.calls[0]?.[1]).not.toHaveProperty('presentation') + }) + + // C5c (pane identity built once in main): main rewrites stale pane keys from the request's tab and + // leaf; workspace keys pass. + it('window lane: the provider gets the pane keys main derives, not the stale ones sent', async () => { + setMainPlatform('darwin') + const tabId = '55555555-5555-4555-8555-555555555555' + const leafId = '66666666-6666-4666-8666-666666666666' + const lanes = startParityLanes(suite, { workspace: POSIX_REPO }) + await lanes.spawnWindow({ + cols: 80, + rows: 24, + cwd: POSIX_PATH, + worktreeId: launchWorkspaceId(POSIX_REPO), + tabId, + leafId, + env: { ORCA_WORKSPACE_ID: 'folder:f1', ORCA_PANE_KEY: 'stale', ORCA_TAB_ID: 'stale' } + }) + const env = recordOf(recordOf(lanes.provider.mock.calls.at(-1)?.[0]).env) + expect(pick(env, ['ORCA_WORKSPACE_ID', 'ORCA_PANE_KEY', 'ORCA_TAB_ID'])).toEqual({ + ORCA_WORKSPACE_ID: 'folder:f1', + ORCA_PANE_KEY: `${tabId}:${leafId}`, + ORCA_TAB_ID: tabId + }) + }) +}) diff --git a/src/main/ipc/pty-launch-race-parity.test.ts b/src/main/ipc/pty-launch-race-parity.test.ts new file mode 100644 index 00000000000..80c393a3a07 --- /dev/null +++ b/src/main/ipc/pty-launch-race-parity.test.ts @@ -0,0 +1,393 @@ +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { getHiddenRendererPtyIds, isHiddenRendererPty } from './pty-hidden-delivery-gate' +import { ptySizes } from './pty/delivery/visibility-state' +import { clearProviderPtyState } from './pty/provider/state-cleanup' +import { WORKTREE_TERMINALS_SLEEPING_ERROR } from '../runtime/worktree-terminals-sleeping-error' +import { + LAUNCH_RACE_LANES, + createLaunchRaceRuntime, + flushMacrotasks, + gatedClaudeAuth, + launchRacePane, + registerLaunchRaceLanes, + sleptWorktreeLock, + type LaunchRaceLane +} from './pty-launch-race-test-fixture' + +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()) +) + +const mintedFor = (worktreeId: string): string[] => + getHiddenRendererPtyIds().filter((id) => id.startsWith(`${worktreeId}@@`)) + +// Pins main's window lane (pty:spawn) and host lane (runtime controller) as the spawn-lane merge's baseline. +describe('launch race parity, both spawn lanes', () => { + const { handlers, mainWindow, installDaemonTestProvider } = setupPtyIpcSuite() + + describe('who owns the pane', () => { + // C6 picks one: the window joins before its setup and checks nothing; the host joins after and verifies. + const JOINED_UNVERIFIED_WINNER: Record = { + ipc: 'reattach', + runtime: 'refused' + } + const JOINER_PREPARES_ITS_OWN_LAUNCH: Record = { ipc: 0, runtime: 1 } + it.each(LAUNCH_RACE_LANES)( + '%s lane: a spawn arriving while a window spawn is starting the pane joins it', + async (lane) => { + const pane = launchRacePane(`race-join-${lane}`, '72727272-7272-4272-8272-727272727272') + let finishSpawn!: () => void + const spawn = vi.fn( + () => + new Promise((resolve) => { + finishSpawn = () => resolve({ id: 'pty-join-winner' }) + }) + ) + installDaemonTestProvider({ spawn }) + const auth = gatedClaudeAuth() + auth.release() + const lanes = registerLaunchRaceLanes({ + handlers, + mainWindow, + runtime: createLaunchRaceRuntime(), + prepareClaudeAuth: auth.prepareClaudeAuth + }) + + const winner = lanes.spawn.ipc(pane.args) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + const joiner = lanes.spawn[lane]({ ...pane.args, command: 'claude' }) + await flushMacrotasks() + finishSpawn() + + await expect(winner).resolves.toMatchObject({ id: 'pty-join-winner' }) + await (JOINED_UNVERIFIED_WINNER[lane] === 'reattach' + ? expect(joiner).resolves.toMatchObject({ id: 'pty-join-winner', isReattach: true }) + : expect(joiner).rejects.toThrow('terminal_pane_owner_unknown')) + expect(spawn).toHaveBeenCalledOnce() + expect(auth.prepareClaudeAuth).toHaveBeenCalledTimes(JOINER_PREPARES_ITS_OWN_LAUNCH[lane]) + } + ) + + // C6 spreads joinPaneSpawn to both lanes: a slept-worktree refusal means "nothing to join". + it('runtime lane: a spawn joining an automatic spawn refused for a slept worktree spawns itself', async () => { + const pane = launchRacePane('race-slept-runtime', '73737373-7373-4373-8373-737373737373') + let refused = false + const spawnedAfterRefusal: boolean[] = [] + installDaemonTestProvider({ + spawn: vi.fn(async () => { + spawnedAfterRefusal.push(refused) + return { id: 'pty-user-wake' } + }) + }) + const lock = sleptWorktreeLock() + const runtime = createLaunchRaceRuntime({ + acquireWorktreeTerminalSpawn: lock.acquireWorktreeTerminalSpawn + }) + const lanes = registerLaunchRaceLanes({ handlers, mainWindow, runtime }) + + const automatic = lanes.spawn.runtime({ ...pane.args, refuseSleptWorktree: true }) + await vi.waitFor(() => expect(lock.acquireWorktreeTerminalSpawn).toHaveBeenCalledOnce()) + const joiner = lanes.spawn.runtime(pane.args) + await flushMacrotasks() + refused = true + lock.refuse() + + await expect(automatic).rejects.toThrow(WORKTREE_TERMINALS_SLEEPING_ERROR) + await expect(joiner).resolves.toMatchObject({ id: 'pty-user-wake' }) + // Spawning only after the refusal proves it waited on the automatic spawn first. + expect(spawnedAfterRefusal).toEqual([true]) + }) + + // C2 and C6 rely on this: createTerminal's claim makes the window wait out the refusal, then wake it. + it('a window mount racing a claimed automatic spawn refused for a slept worktree spawns itself', async () => { + const pane = launchRacePane('race-slept-claimed', '77777777-7777-4777-8777-777777777777') + const spawn = vi.fn(async () => ({ id: 'pty-user-mount' })) + installDaemonTestProvider({ spawn }) + const lock = sleptWorktreeLock() + const runtime = createLaunchRaceRuntime({ + acquireWorktreeTerminalSpawn: lock.acquireWorktreeTerminalSpawn + }) + const lanes = registerLaunchRaceLanes({ handlers, mainWindow, runtime }) + const release = lanes.controller.claimStablePaneCreate({ + worktreeId: pane.worktreeId, + connectionId: null, + tabId: pane.tabId, + leafId: pane.leafId + }) + const automatic = lanes.spawn + .runtime({ ...pane.args, refuseSleptWorktree: true }) + .finally(release) + await vi.waitFor(() => expect(lock.acquireWorktreeTerminalSpawn).toHaveBeenCalledOnce()) + const mount = lanes.spawn.ipc(pane.args) + lock.refuse() + + await expect(automatic).rejects.toThrow(WORKTREE_TERMINALS_SLEEPING_ERROR) + await expect(mount).resolves.toMatchObject({ id: 'pty-user-mount' }) + expect(spawn).toHaveBeenCalledOnce() + }) + }) + + describe('cleanup when a start fails', () => { + // C5d and C6 pick one: the host overwrites a cached size while building, then restores it. + const CACHED_SIZE_WHILE_BUILDING: Record = { + ipc: { cols: 100, rows: 30 }, + runtime: { cols: 80, rows: 24 } + } + describe.each(LAUNCH_RACE_LANES)('%s lane: options build fails', (lane: LaunchRaceLane) => { + it.each([ + { label: 'no size before', cached: undefined }, + { label: 'a size before', cached: { cols: 100, rows: 30 } } + ])('the session keeps $label', async ({ label, cached }) => { + const sessionId = `race-size-${lane}-${label.replaceAll(' ', '-')}` + let sizeWhileBuilding: unknown + const recordSize = (): void => { + sizeWhileBuilding = ptySizes.get(sessionId) + } + const spawn = vi.fn() + installDaemonTestProvider({ + spawn, + supportsAgentSessionCreateOperations: vi.fn(async () => { + recordSize() + return false + }) + }) + const runtime = createLaunchRaceRuntime({ + acquireWorktreeTerminalSpawn: vi.fn(async () => { + recordSize() + throw new Error('execution_owner_unavailable') + }) + }) + const lanes = registerLaunchRaceLanes({ handlers, mainWindow, runtime }) + onTestFinished(() => { + ptySizes.delete(sessionId) + }) + if (cached) { + ptySizes.set(sessionId, cached) + } + const failingArgs = + lane === 'ipc' + ? {} + : { isNewSession: true, agentSessionCreateOperationId: `op-${sessionId}` } + + await expect( + lanes.spawn[lane]({ + cols: 80, + rows: 24, + cwd: '/tmp/race-size', + worktreeId: 'repo-1::/tmp/race-size', + sessionId, + ...failingArgs + }) + ).rejects.toThrow('execution_owner_unavailable') + + expect(sizeWhileBuilding).toEqual( + cached ? CACHED_SIZE_WHILE_BUILDING[lane] : { cols: 80, rows: 24 } + ) + expect(ptySizes.get(sessionId)).toEqual(cached) + expect(spawn).not.toHaveBeenCalled() + }) + }) + + // C5a and C5e rely on this: a throw after the provider spawn rolls the pre-spawn mark back. + it.each(LAUNCH_RACE_LANES)( + '%s lane: a throw after the provider spawn rolls the pre-spawn hidden mark back', + async (lane) => { + const worktreeId = `repo-1::/tmp/race-hidden-rollback-${lane}` + let markedWhileSpawning: string[] = [] + installDaemonTestProvider({ + spawn: vi.fn(async (options: { sessionId?: string }) => { + markedWhileSpawning = mintedFor(worktreeId) + return { id: String(options.sessionId) } + }) + }) + const runtime = createLaunchRaceRuntime({ + registerPty: vi.fn(() => { + throw new Error('register failed') + }) + }) + const lanes = registerLaunchRaceLanes({ handlers, mainWindow, runtime }) + + await expect( + lanes.spawn[lane]({ cols: 80, rows: 24, cwd: '/tmp', worktreeId, initiallyHidden: true }) + ).rejects.toThrow('register failed') + + expect(markedWhileSpawning).toHaveLength(1) + expect(mintedFor(worktreeId)).toEqual([]) + } + ) + + // C5e relies on this: the hidden mark follows the id the provider returned. + it.each(LAUNCH_RACE_LANES)( + '%s lane: the hidden mark moves to the id the provider returned', + async (lane) => { + installDaemonTestProvider({ spawn: vi.fn(async () => ({ id: `pty-renamed-${lane}` })) }) + const lanes = registerLaunchRaceLanes({ + handlers, + mainWindow, + runtime: createLaunchRaceRuntime() + }) + + await lanes.spawn[lane]({ cols: 80, rows: 24, cwd: '/tmp', initiallyHidden: true }) + + expect(getHiddenRendererPtyIds()).toEqual([`pty-renamed-${lane}`]) + } + ) + + // C5e and C6 change one cell: the window keeps the hidden mark on a reattach; the host skips it. + const HIDDEN_REATTACH: Record = { ipc: true, runtime: false } + it.each(LAUNCH_RACE_LANES)('%s lane: a hidden-requested reattach', async (lane) => { + const sessionId = `race-hidden-reattach-${lane}` + installDaemonTestProvider({ spawn: vi.fn(async () => ({ id: sessionId, isReattach: true })) }) + const lanes = registerLaunchRaceLanes({ + handlers, + mainWindow, + runtime: createLaunchRaceRuntime() + }) + + await lanes.spawn[lane]({ cols: 80, rows: 24, cwd: '/tmp', sessionId, initiallyHidden: true }) + + expect(isHiddenRendererPty(sessionId)).toBe(HIDDEN_REATTACH[lane]) + }) + }) + + describe('saving and admitting the new process', () => { + const forgetPtyAfterTest = (ptyId: string): void => { + onTestFinished(() => clearProviderPtyState(ptyId)) + } + + // C2 and C5e rely on this: a failed save discards the PTY, reports "unknown" and cancels registration. + const SAVE_BINDING_ARGS: Record> = { + ipc: {}, + runtime: { persistHostSessionBinding: true } + } + it.each(LAUNCH_RACE_LANES)( + '%s lane: a failed binding save discards the PTY and reports the outcome as unknown', + async (lane) => { + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}) + onTestFinished(() => errorLog.mockRestore()) + const pane = launchRacePane( + `race-save-fail-${lane}`, + '81818181-8181-4181-8181-818181818181' + ) + const ptyId = `pty-save-fail-${lane}` + const shutdown = vi.fn(async () => {}) + installDaemonTestProvider({ + spawn: vi.fn(async () => ({ id: ptyId, incarnationId: 'inc-1' })), + shutdown + }) + const runtime = createLaunchRaceRuntime() + const store = { + persistPtyBinding: vi.fn(async () => { + throw new Error('disk full') + }) + } + const lanes = registerLaunchRaceLanes({ handlers, mainWindow, runtime, store }) + forgetPtyAfterTest(ptyId) + + const spawn = lanes.spawn[lane]({ ...pane.args, ...SAVE_BINDING_ARGS[lane] }) + + await expect(spawn).rejects.toThrow('ORCA_TERMINAL_SESSION_STATE_SAVE_FAILED') + await expect(spawn).rejects.toMatchObject({ agentSessionOperationOutcome: 'unknown' }) + expect(shutdown).toHaveBeenCalledWith(ptyId, { + immediate: true, + expectedIncarnationId: 'inc-1' + }) + expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(ptyId, 'inc-1') + } + ) + + // C5e/C6: the pipeline returns an incarnation that exits mid-registration; the host adapter fails it. + const EXITED_DURING_REGISTRATION: Record = { + ipc: 'resolves', + runtime: 'rejects' + } + it.each(LAUNCH_RACE_LANES)( + '%s lane: an incarnation that exits while it is registered', + async (lane) => { + const pane = launchRacePane(`race-exited-${lane}`, '83838383-8383-4383-8383-838383838383') + const ptyId = `pty-exited-${lane}` + installDaemonTestProvider({ + spawn: vi.fn(async () => ({ id: ptyId, incarnationId: 'inc-exited' })) + }) + const runtime = createLaunchRaceRuntime({ + registerPty: vi.fn(() => { + throw new Error('agent_session_exited_during_start') + }), + getPtyLivenessVerdict: vi.fn(() => ({ status: 'exited' })) + }) + const lanes = registerLaunchRaceLanes({ handlers, mainWindow, runtime }) + forgetPtyAfterTest(ptyId) + + const spawn = lanes.spawn[lane](pane.args) + + await (EXITED_DURING_REGISTRATION[lane] === 'resolves' + ? expect(spawn).resolves.toMatchObject({ id: ptyId, incarnationId: 'inc-exited' }) + : expect(spawn).rejects.toThrow('agent_session_exited_during_start')) + expect(runtime.cancelPendingPtyRegistration).toHaveBeenCalledWith(ptyId, 'inc-exited') + expect(runtime.noteTerminalSpawnCommit).not.toHaveBeenCalled() + expect(runtime.reflowHeadlessTerminalToPtyGrid).not.toHaveBeenCalled() + } + ) + + // C5e relies on this: the model is resized to the grid only after the process is registered. + it.each(LAUNCH_RACE_LANES)( + '%s lane: an admitted spawn resizes the model to its grid after registering it', + async (lane) => { + const pane = launchRacePane(`race-reflow-${lane}`, '85858585-8585-4585-8585-858585858585') + const ptyId = `pty-reflow-${lane}` + installDaemonTestProvider({ spawn: vi.fn(async () => ({ id: ptyId })) }) + const runtime = createLaunchRaceRuntime() + const lanes = registerLaunchRaceLanes({ handlers, mainWindow, runtime }) + forgetPtyAfterTest(ptyId) + + await lanes.spawn[lane]({ ...pane.args, cols: 132, rows: 40 }) + + expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith(ptyId, 132, 40) + expect(runtime.registerPty.mock.invocationCallOrder[0]).toBeLessThan( + runtime.reflowHeadlessTerminalToPtyGrid.mock.invocationCallOrder[0]! + ) + } + ) + }) +}) diff --git a/src/main/ipc/pty-launch-race-test-fixture.ts b/src/main/ipc/pty-launch-race-test-fixture.ts new file mode 100644 index 00000000000..471030bec8a --- /dev/null +++ b/src/main/ipc/pty-launch-race-test-fixture.ts @@ -0,0 +1,159 @@ +import { vi, type Mock } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { WORKTREE_TERMINALS_SLEEPING_ERROR } from '../runtime/worktree-terminals-sleeping-error' +import { registerPtyHandlers } from './pty' + +type IpcHandlerMap = Map unknown> +type LaunchRaceSpawnReply = { + id: string + incarnationId?: string + isReattach?: boolean +} +type LaunchRaceController = { + spawn(args: Record): Promise + claimStablePaneCreate(args: { + worktreeId: string + connectionId: string | null + tabId: string + leafId: string + }): () => void +} +type LaunchRaceRuntimeBase = Record< + | 'setPtyController' + | 'resolveTerminalPane' + | 'createPreAllocatedTerminalHandle' + | 'preAllocateHandleForPty' + | 'registerPreAllocatedHandleForPty' + | 'beginPtyRegistration' + | 'cancelPendingPtyRegistration' + | 'assertPtyRegistrationAllowed' + | 'registerPty' + | 'noteTerminalSpawnCommand' + | 'noteTerminalSpawnCommit' + | 'reflowHeadlessTerminalToPtyGrid' + | 'seedHeadlessTerminal' + | 'onPtySpawned' + | 'onPtyExit' + | 'onPtyData', + Mock +> + +/** The two spawn lanes: the window's `pty:spawn` IPC and the host's runtime controller. */ +export type LaunchRaceLane = 'ipc' | 'runtime' +export const LAUNCH_RACE_LANES: LaunchRaceLane[] = ['ipc', 'runtime'] + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test doubles implement only the members the two spawn lanes read. +const testDouble = (value: unknown): T => value as T + +export const flushMacrotasks = async (rounds = 20): Promise => { + for (let index = 0; index < rounds; index++) { + await new Promise((resolve) => setImmediate(resolve)) + } +} + +/** One stable pane per test; reservation maps are module state, so names must not repeat. */ +export function launchRacePane(tag: string, leafId: string) { + const worktreeId = `repo-1::/tmp/${tag}` + const tabId = `tab-${tag}` + const paneKey = makePaneKey(tabId, leafId) + return { + worktreeId, + tabId, + leafId, + args: { + cols: 80, + rows: 24, + cwd: `/tmp/${tag}`, + worktreeId, + tabId, + leafId, + env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId } + } + } +} + +export function createLaunchRaceRuntime>( + overrides?: T +): LaunchRaceRuntimeBase & T { + const base: LaunchRaceRuntimeBase = { + setPtyController: vi.fn(), + resolveTerminalPane: vi.fn((): unknown => { + throw new Error('terminal_not_found') + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-launch-race'), + preAllocateHandleForPty: vi.fn(() => 'term-launch-race'), + registerPreAllocatedHandleForPty: vi.fn(), + beginPtyRegistration: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + assertPtyRegistrationAllowed: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + noteTerminalSpawnCommit: vi.fn(), + reflowHeadlessTerminalToPtyGrid: vi.fn(), + seedHeadlessTerminal: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + return Object.assign(base, overrides) +} + +/** Registers both lanes against one runtime, as main does. */ +export function registerLaunchRaceLanes(args: { + handlers: IpcHandlerMap + mainWindow: unknown + runtime: { setPtyController: Mock } + prepareClaudeAuth?: () => Promise + store?: unknown +}) { + registerPtyHandlers( + testDouble(args.mainWindow), + testDouble(args.runtime), + undefined, + undefined, + testDouble(args.prepareClaudeAuth), + testDouble(args.store) + ) + const controller = testDouble( + args.runtime.setPtyController.mock.calls.at(-1)?.[0] + ) + const spawn: Record< + LaunchRaceLane, + (spawnArgs: Record) => Promise + > = { + ipc: (spawnArgs) => + testDouble>(args.handlers.get('pty:spawn')!(null, spawnArgs)), + runtime: (spawnArgs) => controller.spawn(spawnArgs) + } + return { controller, spawn } +} + +export function gatedClaudeAuth() { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const prepareClaudeAuth = vi.fn(async () => { + await gate + return { configDir: '/tmp/claude', envPatch: {}, provenance: 'managed:test' } + }) + return { prepareClaudeAuth, release } +} + +/** The host's worktree spawn lock for a slept worktree: refuses only the spawns that ask it to. */ +export function sleptWorktreeLock() { + let refuse!: () => void + const refusal = new Promise((resolve) => { + refuse = resolve + }) + const acquireWorktreeTerminalSpawn = vi.fn( + async (_worktreeId?: string, opts?: { refuseSleptWorktree?: boolean }) => { + if (opts?.refuseSleptWorktree) { + await refusal + throw new Error(WORKTREE_TERMINALS_SLEEPING_ERROR) + } + return () => {} + } + ) + return { acquireWorktreeTerminalSpawn, refuse } +} diff --git a/src/main/ipc/pty-pane-materialization-race.test.ts b/src/main/ipc/pty-pane-materialization-race.test.ts index 3dcc6cbe85b..e9c2938f53d 100644 --- a/src/main/ipc/pty-pane-materialization-race.test.ts +++ b/src/main/ipc/pty-pane-materialization-race.test.ts @@ -100,7 +100,19 @@ describe('registerPtyHandlers', () => { onPtyExit: vi.fn(), onPtyData: vi.fn() } - registerPtyHandlers(mainWindow as never, runtime as never) + let claimReleased = false + const preparedAfterRelease: boolean[] = [] + const prepareClaudeAuth = vi.fn(async () => { + preparedAfterRelease.push(claimReleased) + return { configDir: '/tmp/claude', envPatch: {}, provenance: 'managed:test' } + }) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + prepareClaudeAuth + ) const tabId = 'tab-early-runtime-claim' const leafId = '44444444-4444-4444-8444-444444444444' const worktreeId = 'repo-1::/tmp/early-runtime-claim' @@ -118,6 +130,7 @@ describe('registerPtyHandlers', () => { worktreeId, tabId, leafId, + command: 'claude', env: { ORCA_PANE_KEY: makePaneKey(tabId, leafId), ORCA_TAB_ID: tabId, @@ -127,9 +140,12 @@ describe('registerPtyHandlers', () => { await new Promise((resolve) => setImmediate(resolve)) expect(providerSpawn).not.toHaveBeenCalled() + claimReleased = true releaseClaim() await expect(mounted).resolves.toMatchObject({ id: 'pty-after-runtime-claim' }) expect(providerSpawn).toHaveBeenCalledOnce() + // C6 keeps "the joiner prepares before": the window prepares its launch only after the claim settles. + expect(preparedAfterRelease).toEqual([true]) }) it('adopts a runtime reservation created during renderer preflight', async () => { type RuntimeSpawnController = { diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 36c02a0f234..250000ef465 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -11,6 +11,7 @@ import { registerPtyHandlers, buildPtyHostEnv } from './pty' import { buildJcodeRuntimeDir, shouldInjectJcodeRuntimeDir } from '../../shared/jcode-runtime-dir' import { makePaneKey } from '../../shared/stable-pane-id' import { selectShellStartupFeatures } from '../shell-startup-features' +import { CODEX_REMOTE_VIEWER_SSH_CONNECTION } from './pty/host-env/codex-terminal-clipboard' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -229,6 +230,40 @@ describe('registerPtyHandlers', () => { } }) + describe('Codex terminal clipboard on headless runtimes (#23530)', () => { + const buildEnv = (launchAgent: 'codex' | 'claude', routeBrowserOpensToClient: boolean) => + buildPtyHostEnv( + 'pty-codex-clipboard', + {}, + { + isPackaged: true, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: null, + agentStatusHooksEnabled: false, + launchCommand: launchAgent, + launchAgent, + routeBrowserOpensToClient + } + ) + + it.each([ + ['codex', true, CODEX_REMOTE_VIEWER_SSH_CONNECTION], + ['codex', false, undefined], + ['claude', true, undefined] + ] as const)( + 'launching %s with a remote viewer=%s sets SSH_CONNECTION=%s', + (launchAgent, remoteViewer, expected) => { + vi.stubEnv('SSH_CONNECTION', undefined) + vi.stubEnv('SSH_TTY', undefined) + try { + expect(buildEnv(launchAgent, remoteViewer).SSH_CONNECTION).toBe(expected) + } finally { + vi.unstubAllEnvs() + } + } + ) + }) + it('passes the PTY-resolved Codex home to the WSL relay lane', () => { const runtimeHome = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home' diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 0fe3faf375e..91b1f55b0b0 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -31,6 +31,7 @@ import { import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys' import { applyManagedDataAccountEnvironment } from '../../../managed-data-accounts/launch-environment' import { applyOpenCodeStatusPluginEnv, captureOpenCodeSourceConfig } from './opencode-config' +import { applyCodexRemoteViewerClipboardEnv } from './codex-terminal-clipboard' /** * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. @@ -292,6 +293,11 @@ export function buildPtyHostEnv( const cliCommand = opts.isWsl ? getWslCliCommandName(opts.isPackaged) : 'orca' baseEnv.BROWSER = `${cliCommand} open-url --url %s` } + // Why the browser-relay flag: both mean a headless runtime whose viewer is another machine. + applyCodexRemoteViewerClipboardEnv(baseEnv, { + launchAgent: opts.launchAgent, + viewerIsRemote: opts.routeBrowserOpensToClient === true + }) // Why: must run after the prepends above — they re-read PATH from the unscrubbed // process.env when baseEnv carries none, which is the daemon path's normal shape. diff --git a/src/main/ipc/pty/host-env/codex-terminal-clipboard.test.ts b/src/main/ipc/pty/host-env/codex-terminal-clipboard.test.ts new file mode 100644 index 00000000000..2ce3103cc83 --- /dev/null +++ b/src/main/ipc/pty/host-env/codex-terminal-clipboard.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { + applyCodexRemoteViewerClipboardEnv, + CODEX_REMOTE_VIEWER_SSH_CONNECTION +} from './codex-terminal-clipboard' + +describe('applyCodexRemoteViewerClipboardEnv', () => { + it('marks a Codex launch on a host the viewer is not sitting at', () => { + const env: Record = {} + applyCodexRemoteViewerClipboardEnv(env, { + launchAgent: 'codex', + viewerIsRemote: true, + inheritedEnv: {} + }) + expect(env.SSH_CONNECTION).toBe(CODEX_REMOTE_VIEWER_SSH_CONNECTION) + }) + + it('leaves a local Codex launch alone', () => { + const env: Record = {} + applyCodexRemoteViewerClipboardEnv(env, { + launchAgent: 'codex', + viewerIsRemote: false, + inheritedEnv: {} + }) + expect(env.SSH_CONNECTION).toBeUndefined() + }) + + it.each([undefined, 'claude'] as const)('leaves a %s launch alone', (launchAgent) => { + const env: Record = {} + applyCodexRemoteViewerClipboardEnv(env, { launchAgent, viewerIsRemote: true, inheritedEnv: {} }) + expect(env.SSH_CONNECTION).toBeUndefined() + }) + + it('never overrides an SSH session the launch already carries', () => { + const env: Record = { SSH_CONNECTION: '10.0.0.1 5000 10.0.0.2 22' } + applyCodexRemoteViewerClipboardEnv(env, { + launchAgent: 'codex', + viewerIsRemote: true, + inheritedEnv: {} + }) + expect(env.SSH_CONNECTION).toBe('10.0.0.1 5000 10.0.0.2 22') + }) + + it.each([ + ['SSH_CONNECTION', '10.0.0.1 5000 10.0.0.2 22'], + ['SSH_TTY', '/dev/ttys004'] + ])('skips when the host process already inherits %s', (key, value) => { + const env: Record = {} + applyCodexRemoteViewerClipboardEnv(env, { + launchAgent: 'codex', + viewerIsRemote: true, + inheritedEnv: { [key]: value } + }) + expect(env.SSH_CONNECTION).toBeUndefined() + }) +}) diff --git a/src/main/ipc/pty/host-env/codex-terminal-clipboard.ts b/src/main/ipc/pty/host-env/codex-terminal-clipboard.ts new file mode 100644 index 00000000000..91ecfc523db --- /dev/null +++ b/src/main/ipc/pty/host-env/codex-terminal-clipboard.ts @@ -0,0 +1,36 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' + +/** + * Codex (rust-v0.162.1 `clipboard_copy.rs`) copies to the clipboard of the machine it + * runs on and sends OSC 52 to the terminal only when SSH_TTY, SSH_CONNECTION or TMUX + * is set. SSH_CONNECTION is the least side-effectful trigger: TMUX makes Codex shell + * out to tmux and wrap OSC 52 in tmux passthrough, and SSH_TTY names a tty device. + * Syntactically valid (client ip/port, server ip/port) for tools that parse it. + */ +export const CODEX_REMOTE_VIEWER_SSH_CONNECTION = '127.0.0.1 0 127.0.0.1 0' + +/** + * Makes an Orca-launched Codex copy through the viewer's terminal (OSC 52) when the + * viewer is on another machine, so selection copy and Ctrl+C reach the user's clipboard. + * Scoped to Codex launches: bare shells keep their real SSH posture. (#23530) + */ +export function applyCodexRemoteViewerClipboardEnv( + env: Record, + options: { + launchAgent: TuiAgent | undefined + viewerIsRemote: boolean + inheritedEnv?: NodeJS.ProcessEnv + } +): void { + if (options.launchAgent !== 'codex' || !options.viewerIsRemote) { + return + } + const inherited = options.inheritedEnv ?? process.env + // Why process.env too: daemon spawns merge the host's inherited env after this build. + const alreadySsh = ['SSH_CONNECTION', 'SSH_TTY'].some( + (key) => env[key] !== undefined || inherited[key] !== undefined + ) + if (!alreadySsh) { + env.SSH_CONNECTION = CODEX_REMOTE_VIEWER_SSH_CONNECTION + } +} diff --git a/src/main/ipc/pty/host-env/spawn-cwd.ts b/src/main/ipc/pty/host-env/spawn-cwd.ts index 03bac6611be..441631c9146 100644 --- a/src/main/ipc/pty/host-env/spawn-cwd.ts +++ b/src/main/ipc/pty/host-env/spawn-cwd.ts @@ -10,7 +10,6 @@ import { assertFolderWorkspacePathUsable, getFolderWorkspacePathStatus } from '../../../project-groups/folder-workspace-path-status' -import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' export function assertFolderWorkspacePtyPathUsable( store: Store | undefined, @@ -20,11 +19,10 @@ export function assertFolderWorkspacePtyPathUsable( if (!store || workspaceScope?.type !== 'folder') { return } - return getFolderWorkspacePathStatus( - store, - { scope: 'folder-workspace', folderWorkspaceId: workspaceScope.folderWorkspaceId }, - { getSshFilesystemProvider } - ).then(assertFolderWorkspacePathUsable) + return getFolderWorkspacePathStatus(store, { + scope: 'folder-workspace', + folderWorkspaceId: workspaceScope.folderWorkspaceId + }).then(assertFolderWorkspacePathUsable) } export function resolvePtySpawnStartupCwd( diff --git a/src/main/ipc/qoder-desktop-execution-host.test.ts b/src/main/ipc/qoder-desktop-execution-host.test.ts index 16a289cfb8f..25f4e643a5e 100644 --- a/src/main/ipc/qoder-desktop-execution-host.test.ts +++ b/src/main/ipc/qoder-desktop-execution-host.test.ts @@ -14,9 +14,9 @@ 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('./preflight-wsl-agent-detection', () => ({ + detectWslCommandsOnPath: vi.fn() +})) vi.mock('node:fs/promises', async (importOriginal) => ({ ...(await importOriginal()), stat: vi.fn(async () => ({ isDirectory: () => true })) @@ -76,7 +76,6 @@ describe('desktop Qoder execution-host selection', () => { 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() @@ -171,7 +170,6 @@ describe('desktop Qoder execution-host selection', () => { 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 () => { diff --git a/src/main/ipc/repos-add-linked-worktree.test.ts b/src/main/ipc/repos-add-linked-worktree.test.ts index 1433a1e816c..80cbb991fca 100644 --- a/src/main/ipc/repos-add-linked-worktree.test.ts +++ b/src/main/ipc/repos-add-linked-worktree.test.ts @@ -70,9 +70,6 @@ vi.mock('../worktree-root-preparation', () => ({ prepareLocalWorktreeRootForRepo: prepareLocalWorktreeRootForRepoMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() })) -vi.mock('./ssh', () => ({ getActiveMultiplexer: vi.fn() })) - import { registerRepoHandlers } from './repos' const MAIN_CHECKOUT = '/Users/dev/projects/orca' diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index 74797fa3d6a..6f465188b5d 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -87,14 +87,6 @@ vi.mock('../worktree-root-preparation', () => ({ prepareLocalWorktreeRootForRepo: prepareLocalWorktreeRootForRepoMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: vi.fn() -})) - -vi.mock('./ssh', () => ({ - getActiveMultiplexer: vi.fn() -})) - import { registerRepoHandlers } from './repos' type CreateArgs = { parentPath: string; name: string; kind: 'git' | 'folder' } diff --git a/src/main/ipc/repos-execution-host-catalog.test.ts b/src/main/ipc/repos-execution-host-catalog.test.ts index 6e4de0adbdd..4ffced7d679 100644 --- a/src/main/ipc/repos-execution-host-catalog.test.ts +++ b/src/main/ipc/repos-execution-host-catalog.test.ts @@ -21,22 +21,11 @@ vi.mock('./registered-worktree-roots-cache', () => vi.mock('../worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks)) -vi.mock('../providers/ssh-filesystem-dispatch', () => - moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) -) -vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) -vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' -import { - getSshProviderAuthority, - resetSshProviderAuthorities, - rotateSshProviderAuthority -} from '../ssh/ssh-provider-authority' +import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { getGitRepoRoot, isGitRepo } from '../git/repo' -import { toSshExecutionHostId } from '../../shared/execution-host' import { createRepoHandlerHarness, resetProjectGroupMocks } from './repos-remote-test-harness' const { handleMock, mockStore } = reposMocks @@ -65,128 +54,6 @@ describe('projectGroups IPC validation', () => { expect(mockStore.createProjectGroup).not.toHaveBeenCalled() }) - it('returns an immutable repo catalog for exactly one execution host', async () => { - const localRepo = { - id: 'duplicate', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { - id: 'duplicate', - path: '/remote/repo', - displayName: 'remote', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1' - } - const runtimeRepo = { - id: 'duplicate', - path: '/runtime/repo', - displayName: 'runtime', - badgeColor: '#000', - addedAt: 0, - executionHostId: 'runtime:environment-a' - } - mockStore.getRepos.mockReturnValue([localRepo, sshRepo, runtimeRepo]) - - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1'), - expectedAuthority: getSshProviderAuthority('conn-1') - }) - ).resolves.toMatchObject({ - authoritative: true, - authority: { - kind: 'direct-ssh', - executionHostId: 'ssh:conn-1', - targetId: 'conn-1' - }, - repos: [sshRepo] - }) - - const local = await handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: 'local' - }) - expect(local).toMatchObject({ authoritative: true, repos: [localRepo] }) - expect((local as { repos: object[] }).repos[0]).not.toBe(localRepo) - }) - - it('rejects repo catalogs whose execution host contradicts their SSH connection', async () => { - const baseRepo = { - id: 'repo-1', - path: '/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0 - } - mockStore.getRepos.mockReturnValue([ - { - ...baseRepo, - connectionId: 'conn-1', - executionHostId: 'local' - } - ]) - - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: 'local' - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1'), - expectedAuthority: getSshProviderAuthority('conn-1') - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - - mockStore.getRepos.mockReturnValue([ - { - ...baseRepo, - connectionId: 'conn-1', - executionHostId: toSshExecutionHostId('conn-2') - } - ]) - - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1'), - expectedAuthority: getSshProviderAuthority('conn-1') - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - }) - - it('rejects runtime, partial, mismatched, and stale catalog authority', async () => { - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: 'runtime:environment-a' - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1') - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1'), - expectedAuthority: { - ...getSshProviderAuthority('other-target'), - targetId: 'other-target' - } - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - - const expectedAuthority = getSshProviderAuthority('conn-1') - const pending = handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1'), - expectedAuthority - }) - rotateSshProviderAuthority('conn-1') - await expect(pending).resolves.toMatchObject({ authoritative: false, reason: 'stale' }) - }) - it('rejects malformed local project group update arguments before persistence', () => { expect(() => handlers.get('projectGroups:update')!(null, { diff --git a/src/main/ipc/repos-local-add-and-project-setup.test.ts b/src/main/ipc/repos-local-add-and-project-setup.test.ts index 834c9e97e92..eca40c3c161 100644 --- a/src/main/ipc/repos-local-add-and-project-setup.test.ts +++ b/src/main/ipc/repos-local-add-and-project-setup.test.ts @@ -21,12 +21,6 @@ vi.mock('./registered-worktree-roots-cache', () => vi.mock('../worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks)) -vi.mock('../providers/ssh-filesystem-dispatch', () => - moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) -) -vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) -vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' @@ -347,6 +341,19 @@ describe('repos:add + repos:clone', () => { expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() }) + it('refuses project setup on an SSH target with the not-connected text and adds no repo', async () => { + await expect( + handlers.get('projectHostSetups:setupExistingFolder')!(null, { + projectId: 'github:acme/orca', + hostId: 'ssh:conn-1', + path: '/srv/orca', + kind: 'git' + }) + ).rejects.toThrow('SSH connection "conn-1" not found or not connected') + + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + it('prepares and invalidates roots when repos:update changes worktree base path', () => { const updated = { id: 'repo-update-root', diff --git a/src/main/ipc/repos-local-clone-lifecycle.test.ts b/src/main/ipc/repos-local-clone-lifecycle.test.ts index 00284d42699..d3d94f1b82d 100644 --- a/src/main/ipc/repos-local-clone-lifecycle.test.ts +++ b/src/main/ipc/repos-local-clone-lifecycle.test.ts @@ -25,12 +25,6 @@ vi.mock('./registered-worktree-roots-cache', () => vi.mock('../worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks)) -vi.mock('../providers/ssh-filesystem-dispatch', () => - moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) -) -vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) -vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' diff --git a/src/main/ipc/repos-nested-import.test.ts b/src/main/ipc/repos-nested-import.test.ts index 8bb62d42fbc..f6181df6a3d 100644 --- a/src/main/ipc/repos-nested-import.test.ts +++ b/src/main/ipc/repos-nested-import.test.ts @@ -24,12 +24,6 @@ vi.mock('./registered-worktree-roots-cache', () => vi.mock('../worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks)) -vi.mock('../providers/ssh-filesystem-dispatch', () => - moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) -) -vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) -vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' @@ -37,14 +31,7 @@ import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { getGitRepoRoot, isGitRepo } from '../git/repo' import { createRepoHandlerHarness, resetProjectGroupMocks } from './repos-remote-test-harness' -const { - handleMock, - mockStore, - mockGitProvider, - mockFilesystemProvider, - mockMultiplexer, - listWorktreeGraphMock -} = reposMocks +const { handleMock, mockStore, listWorktreeGraphMock } = reposMocks beforeEach(() => { clearGitCapabilityStateForTests() @@ -62,357 +49,121 @@ describe('projectGroups IPC validation', () => { registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) + const group = { + id: 'group-1', + name: 'Platform', + parentPath: '/srv/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + + // Why: a local tree whose root is a plain folder and whose listed dirs are git repos. + async function withNestedTree( + repoDirs: string[], + run: (root: string) => Promise + ): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-nested-import-')) + try { + for (const dir of repoDirs) { + await mkdir(join(root, dir, '.git'), { recursive: true }) + } + vi.mocked(isGitRepo).mockImplementation((path: string) => path !== root) + await run(root) + } finally { + await rm(root, { recursive: true, force: true }) + } + } + it('uses completed scan ids as an allowlist for nested imports', async () => { - const group = { - id: 'group-1', - name: 'Platform', - parentPath: '/srv/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } mockStore.createProjectGroup.mockReturnValue(group) - mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({ - isRepo: path === '/srv/platform/api' || path === '/srv/platform/node_modules/hidden', - rootPath: null - })) - mockFilesystemProvider.stat.mockImplementation(async (path: string) => { - if (path === '/srv/platform/api/.git') { - return { type: 'directory', size: 0, mtime: 0 } - } - if (path === '/srv/platform/node_modules/hidden/.git') { - return { type: 'directory', size: 0, mtime: 0 } - } - throw new Error('not found') - }) - mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => { - if (dirPath === '/srv/platform') { - return [ - { name: 'api', isDirectory: true, isSymlink: false }, - { name: 'node_modules', isDirectory: true, isSymlink: false } - ] - } - return [] - }) + await withNestedTree(['api', join('node_modules', 'hidden')], async (root) => { + await handlers.get('projectGroups:scanNested')!( + { sender: { send: vi.fn() } }, + { path: root, scanId: 'scan-import-allowlist' } + ) - await handlers.get('projectGroups:scanNested')!( - { sender: { send: vi.fn() } }, - { - path: '/srv/platform', - connectionId: 'conn-1', - scanId: 'scan-import-allowlist' - } - ) + const result = await handlers.get('projectGroups:importNested')!(null, { + parentPath: root, + groupName: 'Platform', + projectPaths: [join(root, 'api'), join(root, 'node_modules', 'hidden')], + scanId: 'scan-import-allowlist', + mode: 'group' + }) - const result = await handlers.get('projectGroups:importNested')!(null, { - parentPath: '/srv/platform', - groupName: 'Platform', - projectPaths: ['/srv/platform/api', '/srv/platform/node_modules/hidden'], - connectionId: 'conn-1', - scanId: 'scan-import-allowlist', - mode: 'group' + expect(result).toMatchObject({ importedCount: 1, failedCount: 1 }) + expect(mockStore.addRepo).toHaveBeenCalledTimes(1) + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ path: join(root, 'api') }) + ) }) - - expect(result).toMatchObject({ importedCount: 1, failedCount: 1 }) - expect(mockStore.addRepo).toHaveBeenCalledTimes(1) - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ path: '/srv/platform/api' }) - ) }) - it('does not reuse a completed nested scan id for a different SSH parent path', async () => { - const group = { - id: 'group-1', - name: 'Other', - parentPath: '/srv/other', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } + it('does not reuse a completed nested scan id for a different parent path', async () => { mockStore.createProjectGroup.mockReturnValue(group) - mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({ - isRepo: path === '/srv/platform/api' || path === '/srv/other/api', - rootPath: null - })) - mockFilesystemProvider.stat.mockImplementation(async (path: string) => { - if (path === '/srv/platform/api/.git' || path === '/srv/other/api/.git') { - return { type: 'directory', size: 0, mtime: 0 } - } - throw new Error('not found') - }) - mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => { - if (dirPath === '/srv/platform' || dirPath === '/srv/other') { - return [{ name: 'api', isDirectory: true, isSymlink: false }] - } - return [] - }) + await withNestedTree([join('platform', 'api'), join('other', 'api')], async (root) => { + await handlers.get('projectGroups:scanNested')!( + { sender: { send: vi.fn() } }, + { path: join(root, 'platform'), scanId: 'scan-parent-context' } + ) + mockStore.addRepo.mockClear() - await handlers.get('projectGroups:scanNested')!( - { sender: { send: vi.fn() } }, - { - path: '/srv/platform', - connectionId: 'conn-1', - scanId: 'scan-parent-context' - } - ) - mockStore.addRepo.mockClear() + const result = await handlers.get('projectGroups:importNested')!(null, { + parentPath: join(root, 'other'), + groupName: 'Other', + projectPaths: [join(root, 'platform', 'api')], + scanId: 'scan-parent-context', + mode: 'group' + }) - const result = await handlers.get('projectGroups:importNested')!(null, { - parentPath: '/srv/other', - groupName: 'Other', - projectPaths: ['/srv/platform/api'], - connectionId: 'conn-1', - scanId: 'scan-parent-context', - mode: 'group' + expect(result).toMatchObject({ importedCount: 0, failedCount: 1 }) + expect(mockStore.addRepo).not.toHaveBeenCalled() }) - - expect(result).toMatchObject({ importedCount: 0, failedCount: 1 }) - expect(mockStore.addRepo).not.toHaveBeenCalled() - expect(mockFilesystemProvider.readDir).toHaveBeenCalledWith('/srv/other') }) - it('does not reuse a completed nested scan id for a different SSH connection', async () => { - const group = { - id: 'group-1', - name: 'Platform', - parentPath: '/srv/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - mockStore.createProjectGroup.mockReturnValue(group) - mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({ - isRepo: path === '/srv/platform/api', - rootPath: null - })) - mockFilesystemProvider.stat.mockImplementation(async (path: string) => { - if (path === '/srv/platform/api/.git') { - return { type: 'directory', size: 0, mtime: 0 } - } - throw new Error('not found') - }) - mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => - dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : [] - ) - - await handlers.get('projectGroups:scanNested')!( - { sender: { send: vi.fn() } }, - { - path: '/srv/platform', - connectionId: 'conn-1', - scanId: 'scan-connection-context' - } - ) - mockStore.addRepo.mockClear() - + it('refuses nested imports on an SSH target and never inspects the path locally', async () => { await expect( handlers.get('projectGroups:importNested')!(null, { parentPath: '/srv/platform', groupName: 'Platform', projectPaths: ['/srv/platform/api'], - connectionId: 'missing-conn', - scanId: 'scan-connection-context', + connectionId: 'conn-1', mode: 'group' }) ).rejects.toThrow('ssh_connection_unavailable') expect(mockStore.addRepo).not.toHaveBeenCalled() + expect(isGitRepo).not.toHaveBeenCalled() }) - it('imports nested SSH repositories with connection-scoped repo entries', async () => { - const group = { - id: 'group-1', - name: 'Platform', - parentPath: '/srv/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } + it('imports a small selection from a large nested scan', async () => { mockStore.createProjectGroup.mockReturnValue(group) - mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({ - isRepo: path === '/srv/platform/api', - rootPath: null - })) - mockFilesystemProvider.stat.mockImplementation(async (path: string) => { - if (path === '/srv/platform/api/.git') { - return { type: 'directory', size: 0, mtime: 0 } - } - throw new Error('not found') - }) - mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => - dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : [] - ) - - const result = await handlers.get('projectGroups:importNested')!(null, { - parentPath: '/srv/platform', - groupName: 'Platform', - projectPaths: ['/srv/platform/api'], - connectionId: 'conn-1', - mode: 'group' - }) - - expect(result).toMatchObject({ importedCount: 1, failedCount: 0 }) - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ - path: '/srv/platform/api', - connectionId: 'conn-1', - projectGroupId: group.id - }) - ) - expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', { - rootPath: '/srv/platform/api' - }) - }) - - it('resolves SSH linked worktree imports through the SSH provider worktree graph', async () => { - const selectedPath = '/srv/platform/demo/brash-binder' - const secondSelectedPath = '/srv/platform/demo/quick-howler' - const mainPath = '/srv/source/demo-project' - mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({ - isRepo: path === selectedPath || path === secondSelectedPath, - rootPath: '/srv/provider/root' - })) - mockGitProvider.listWorktrees.mockResolvedValue([ - { - path: mainPath, - head: 'main-head', - branch: 'refs/heads/main', - isBare: false, - isMainWorktree: true - }, - { - path: selectedPath, - head: 'feature-head', - branch: 'refs/heads/brash-binder', - isBare: false, - isMainWorktree: false - }, - { - path: secondSelectedPath, - head: 'feature-head', - branch: 'refs/heads/quick-howler', - isBare: false, - isMainWorktree: false - } - ]) - mockFilesystemProvider.stat.mockImplementation(async (path: string) => { - if (path === `${selectedPath}/.git` || path === `${secondSelectedPath}/.git`) { - return { type: 'directory', size: 0, mtime: 0 } - } - throw new Error('not found') - }) - mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => - dirPath === '/srv/platform/demo' - ? [ - { name: 'brash-binder', isDirectory: true, isSymlink: false }, - { name: 'quick-howler', isDirectory: true, isSymlink: false } - ] - : [] - ) - - const result = await handlers.get('projectGroups:importNested')!(null, { - parentPath: '/srv/platform/demo', - groupName: '', - projectPaths: [selectedPath, secondSelectedPath], - connectionId: 'conn-1', - mode: 'separate' - }) - - expect(result).toMatchObject({ importedCount: 1, alreadyKnownCount: 1, failedCount: 0 }) - expect(mockGitProvider.listWorktrees).toHaveBeenCalledWith(selectedPath) - expect(mockGitProvider.listWorktrees).toHaveBeenCalledTimes(1) - expect(listWorktreeGraphMock).not.toHaveBeenCalled() - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ - path: mainPath, - connectionId: 'conn-1' - }) - ) - expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', { - rootPath: mainPath - }) - }) - - it('imports a small selection from a large nested SSH scan', async () => { - const group = { - id: 'group-1', - name: 'Platform', - parentPath: '/srv/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } - const repoPaths = Array.from( + const names = Array.from( { length: 87 }, - (_, index) => `/srv/platform/service-${String(index + 1).padStart(2, '0')}` + (_, index) => `service-${String(index + 1).padStart(2, '0')}` ) - const selectedPaths = [repoPaths[2], repoPaths[41], repoPaths[86]] - mockStore.addRepo.mockClear() - mockStore.createProjectGroup.mockReturnValue(group) - mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({ - isRepo: repoPaths.includes(path), - rootPath: null - })) - mockFilesystemProvider.stat.mockImplementation(async (path: string) => { - const repoPath = path.replace(/\/\.git$/, '') - if (path.endsWith('/.git') && repoPaths.includes(repoPath)) { - return { type: 'directory', size: 0, mtime: 0 } + await withNestedTree(names, async (root) => { + const selectedPaths = [names[2], names[41], names[86]].map((name) => join(root, name)) + + const result = await handlers.get('projectGroups:importNested')!(null, { + parentPath: root, + groupName: 'Platform', + projectPaths: selectedPaths, + mode: 'group' + }) + + expect(result).toMatchObject({ importedCount: 3, alreadyKnownCount: 0, failedCount: 0 }) + expect(mockStore.addRepo).toHaveBeenCalledTimes(3) + for (const path of selectedPaths) { + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ path, projectGroupId: group.id }) + ) } - throw new Error('not found') }) - mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => - dirPath === '/srv/platform' - ? repoPaths.map((repoPath) => ({ - name: repoPath.split('/').at(-1) ?? repoPath, - isDirectory: true, - isSymlink: false - })) - : [] - ) - - const result = await handlers.get('projectGroups:importNested')!(null, { - parentPath: '/srv/platform', - groupName: 'Platform', - projectPaths: selectedPaths, - connectionId: 'conn-1', - mode: 'group' - }) - - expect(result).toMatchObject({ - importedCount: 3, - alreadyKnownCount: 0, - failedCount: 0 - }) - expect(mockStore.addRepo).toHaveBeenCalledTimes(3) - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ path: selectedPaths[0], projectGroupId: group.id }) - ) - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ path: selectedPaths[1], projectGroupId: group.id }) - ) - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ path: selectedPaths[2], projectGroupId: group.id }) - ) }) it('imports selected local linked worktrees as one project rooted at the main worktree', async () => { @@ -477,44 +228,20 @@ describe('projectGroups IPC validation', () => { }) it('sanitizes unexpected nested import errors before returning results', async () => { - const group = { - id: 'group-1', - name: 'Platform', - parentPath: '/srv/platform', - parentGroupId: null, - createdFrom: 'folder-scan', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1 - } mockStore.createProjectGroup.mockReturnValue(group) - mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({ - isRepo: path === '/srv/platform/api', - rootPath: null - })) - mockFilesystemProvider.stat.mockImplementation(async (path: string) => { - if (path === '/srv/platform/api/.git') { - return { type: 'directory', size: 0, mtime: 0 } - } - throw new Error('not found') - }) - mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => - dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : [] - ) - mockStore.addRepo.mockImplementationOnce(() => { - throw new Error('secret backend path /srv/platform/api') - }) + await withNestedTree(['api'], async (root) => { + mockStore.addRepo.mockImplementationOnce(() => { + throw new Error(`secret backend path ${join(root, 'api')}`) + }) - const result = (await handlers.get('projectGroups:importNested')!(null, { - parentPath: '/srv/platform', - groupName: 'Platform', - projectPaths: ['/srv/platform/api'], - connectionId: 'conn-1', - mode: 'group' - })) as { projects: { error?: string }[] } + const result = await handlers.get('projectGroups:importNested')!(null, { + parentPath: root, + groupName: 'Platform', + projectPaths: [join(root, 'api')], + mode: 'group' + }) - expect(result.projects[0].error).toBe('Repository could not be imported') + expect(result).toMatchObject({ projects: [{ error: 'Repository could not be imported' }] }) + }) }) }) diff --git a/src/main/ipc/repos-nested-scan.test.ts b/src/main/ipc/repos-nested-scan.test.ts index 53d4e64477f..a1a7d402c8f 100644 --- a/src/main/ipc/repos-nested-scan.test.ts +++ b/src/main/ipc/repos-nested-scan.test.ts @@ -24,12 +24,6 @@ vi.mock('./registered-worktree-roots-cache', () => vi.mock('../worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks)) -vi.mock('../providers/ssh-filesystem-dispatch', () => - moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) -) -vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) -vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' @@ -37,7 +31,7 @@ import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { getGitRepoRoot, isGitRepo } from '../git/repo' import { createRepoHandlerHarness, resetProjectGroupMocks } from './repos-remote-test-harness' -const { handleMock, mockStore, mockGitProvider, mockFilesystemProvider } = reposMocks +const { handleMock, mockStore } = reposMocks beforeEach(() => { clearGitCapabilityStateForTests() @@ -55,185 +49,14 @@ describe('projectGroups IPC validation', () => { registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) - it('scans nested repositories over a connected SSH filesystem', async () => { - mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({ - isRepo: path === '/srv/platform/api', - rootPath: null - })) - mockFilesystemProvider.stat.mockImplementation(async (path: string) => { - if (path === '/srv/platform/api/.git') { - return { type: 'directory', size: 0, mtime: 0 } - } - throw new Error('not found') - }) - mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => - dirPath === '/srv/platform' ? [{ name: 'api', isDirectory: true, isSymlink: false }] : [] - ) - - const result = await handlers.get('projectGroups:scanNested')!(null, { - path: '/srv/platform', - connectionId: 'conn-1' - }) - - expect(result).toMatchObject({ - selectedPath: '/srv/platform', - selectedPathKind: 'non_git_folder', - repos: [{ path: '/srv/platform/api', displayName: 'api' }] - }) - }) - - it('detects nested bare repositories over a connected SSH filesystem', async () => { - mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: false, rootPath: null }) - mockFilesystemProvider.stat.mockImplementation(async (path: string) => { - if (path === '/srv/platform/mirror.git/HEAD') { - return { type: 'file', size: 0, mtime: 0 } - } - if (path === '/srv/platform/mirror.git/objects' || path === '/srv/platform/mirror.git/refs') { - return { type: 'directory', size: 0, mtime: 0 } - } - throw new Error('not found') - }) - mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => - dirPath === '/srv/platform' - ? [{ name: 'mirror.git', isDirectory: true, isSymlink: false }] - : [] - ) - - const result = await handlers.get('projectGroups:scanNested')!(null, { - path: '/srv/platform', - connectionId: 'conn-1' - }) - - expect(result).toMatchObject({ - selectedPath: '/srv/platform', - selectedPathKind: 'non_git_folder', - repos: [{ path: '/srv/platform/mirror.git', displayName: 'mirror.git' }] - }) - }) - - it('skips symlinked directories during SSH nested repository scans', async () => { - mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: false, rootPath: null }) - mockFilesystemProvider.stat.mockImplementation(async (path: string) => { - if (path === '/srv/platform/api/.git' || path === '/srv/platform/linked-outside/.git') { - return { type: 'directory', size: 0, mtime: 0 } - } - throw new Error('not found') - }) - mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => - dirPath === '/srv/platform' - ? [ - { name: 'linked-outside', isDirectory: true, isSymlink: true }, - { name: 'api', isDirectory: true, isSymlink: false } - ] - : [] - ) - - const result = await handlers.get('projectGroups:scanNested')!(null, { - path: '/srv/platform', - connectionId: 'conn-1' - }) - - expect((result as { repos: { path: string }[] }).repos.map((repo) => repo.path)).toEqual([ - '/srv/platform/api' - ]) - }) - - it('prioritizes shallow sibling repositories before truncated SSH archive scans', async () => { - const archivedRepoNames = Array.from( - { length: 101 }, - (_, index) => `archived-service-${String(index + 1).padStart(3, '0')}` - ) - const archivedRepoPaths = archivedRepoNames.map((name) => `/srv/platform/archive/${name}`) - const gitRepos = new Set(['/srv/platform/z-web-client', ...archivedRepoPaths]) - - mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({ - isRepo: gitRepos.has(path), - rootPath: null - })) - mockFilesystemProvider.stat.mockImplementation(async (path: string) => { - const repoPath = path.replace(/\/\.git$/, '') - if (path.endsWith('/.git') && gitRepos.has(repoPath)) { - return { type: 'directory', size: 0, mtime: 0 } - } - throw new Error('not found') - }) - mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => { - if (dirPath === '/srv/platform') { - return [ - { name: 'archive', isDirectory: true, isSymlink: false }, - { name: 'z-web-client', isDirectory: true, isSymlink: false } - ] - } - if (dirPath === '/srv/platform/archive') { - return archivedRepoNames.map((name) => ({ - name, - isDirectory: true, - isSymlink: false - })) - } - return [] - }) - - const result = await handlers.get('projectGroups:scanNested')!(null, { - path: '/srv/platform', - connectionId: 'conn-1' - }) - - expect(result).toMatchObject({ - selectedPath: '/srv/platform', - selectedPathKind: 'non_git_folder', - truncated: true - }) - expect((result as { repos: { path: string }[] }).repos).toHaveLength(100) - expect((result as { repos: { path: string }[] }).repos[0].path).toBe( - '/srv/platform/z-web-client' - ) - expect((result as { repos: { path: string }[] }).repos.map((repo) => repo.path)).toContain( - '/srv/platform/z-web-client' - ) - }) - - it('returns partial SSH scan results after cancellation', async () => { - mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: false, rootPath: null }) - mockFilesystemProvider.stat.mockImplementation(async (path: string) => { - if (path === '/srv/platform/api/.git' || path === '/srv/platform/web/.git') { - return { type: 'directory', size: 0, mtime: 0 } - } - throw new Error('not found') - }) - mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => - dirPath === '/srv/platform' - ? [ - { name: 'api', isDirectory: true, isSymlink: false }, - { name: 'web', isDirectory: true, isSymlink: false } - ] - : [] - ) - const event = { - sender: { - send: vi.fn((_channel: string, data: { scanId: string; scan: { repos: unknown[] } }) => { - if (data.scan.repos.length === 1) { - handlers.get('projectGroups:cancelNestedScan')!(null, { scanId: data.scanId }) - } - }) - } - } - - const result = await handlers.get('projectGroups:scanNested')!(event, { - path: '/srv/platform', - connectionId: 'conn-1', - scanId: 'scan-1' - }) - - expect(result).toMatchObject({ - selectedPath: '/srv/platform', - stopped: true, - repos: [{ path: '/srv/platform/api' }] - }) - expect(event.sender.send).toHaveBeenCalledWith( - 'projectGroups:scanNestedProgress', - expect.objectContaining({ scanId: 'scan-1' }) - ) + it('refuses SSH nested scans without reading the remote or the local filesystem', async () => { + await expect( + handlers.get('projectGroups:scanNested')!(null, { + path: '/srv/platform', + connectionId: 'conn-1' + }) + ).rejects.toThrow('ssh_connection_unavailable') + expect(isGitRepo).not.toHaveBeenCalled() }) it('returns partial local scan results after cancellation', async () => { diff --git a/src/main/ipc/repos-picker.test.ts b/src/main/ipc/repos-picker.test.ts index ce658981574..be3029d24ff 100644 --- a/src/main/ipc/repos-picker.test.ts +++ b/src/main/ipc/repos-picker.test.ts @@ -32,14 +32,6 @@ vi.mock('./registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCacheForRepo: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: vi.fn() -})) - -vi.mock('./ssh', () => ({ - getActiveMultiplexer: vi.fn() -})) - import { registerRepoHandlers } from './repos' describe('repos folder pickers', () => { diff --git a/src/main/ipc/repos-remote-base-ref-queries.test.ts b/src/main/ipc/repos-remote-base-ref-queries.test.ts index 8f5ff7dba53..bb9822fa0d7 100644 --- a/src/main/ipc/repos-remote-base-ref-queries.test.ts +++ b/src/main/ipc/repos-remote-base-ref-queries.test.ts @@ -21,20 +21,15 @@ vi.mock('./registered-worktree-roots-cache', () => vi.mock('../worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks)) -vi.mock('../providers/ssh-filesystem-dispatch', () => - moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) -) -vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) -vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { createRepoHandlerHarness } from './repos-remote-test-harness' -import { REPO_SEARCH_REFS_MAX_LIMIT } from '../../shared/repo-search-limits' +import { getBaseRefDefault } from '../git/repo' -const { handleMock, mockStore, mockGitProvider, prepareLocalWorktreeRootForRepoMock } = reposMocks +const { handleMock, mockStore, gitExecFileAsyncMock, prepareLocalWorktreeRootForRepoMock } = + reposMocks beforeEach(() => { clearGitCapabilityStateForTests() @@ -50,7 +45,8 @@ describe('repos:getBaseRefDefault envelope', () => { mockStore.getRepo.mockReset() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) // Reset exec so a newly added test doesn't inherit the previous test's exec mock. - mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + gitExecFileAsyncMock.mockReset() + vi.mocked(getBaseRefDefault).mockClear() registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) @@ -91,44 +87,7 @@ describe('repos:getBaseRefDefault envelope', () => { expect(result.remoteCount).toBe(1) }) - // Why: the handler resolves default-ref and remote-count in parallel, so dispatch on argv (not call order) to stay stable. - type ExecResponse = { stdout: string; stderr: string } - type ExecRule = { - matches: (argv: string[]) => boolean - respond: () => Promise - } - const dispatchExec = (rules: ExecRule[]): ((argv: string[]) => Promise) => { - return (argv: string[]) => { - for (const rule of rules) { - if (rule.matches(argv)) { - return rule.respond() - } - } - return Promise.reject(new Error(`unexpected exec call: ${argv.join(' ')}`)) - } - } - const isRefSnapshot = (argv: string[]): boolean => - argv[0] === 'for-each-ref' && argv.includes('--format=%(refname)%00%(symref)') - const isRemoteList = (argv: string[]): boolean => argv.length === 1 && argv[0] === 'remote' - - it('returns envelope over SSH relay for remote repos', async () => { - mockGitProvider.exec = vi.fn().mockImplementation( - dispatchExec([ - { - matches: isRefSnapshot, - respond: () => - Promise.resolve({ - stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', - stderr: '' - }) - }, - { - matches: isRemoteList, - respond: () => Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' }) - } - ]) - ) - + it('returns { defaultBaseRef: null, remoteCount: 0 } for an SSH repo without running git', async () => { mockStore.getRepo.mockReturnValue({ id: 'r1', path: '/remote/repo', @@ -136,82 +95,14 @@ describe('repos:getBaseRefDefault envelope', () => { kind: 'git' }) - const result = (await handlers.get('repos:getBaseRefDefault')!(null, { repoId: 'r1' })) as { - defaultBaseRef: string | null - remoteCount: number - } + const result = await handlers.get('repos:getBaseRefDefault')!(null, { repoId: 'r1' }) - expect(result.defaultBaseRef).toBe('origin/main') - expect(result.remoteCount).toBe(2) - }) - - it('returns defaultBaseRef even when remote-count lookup fails', async () => { - mockGitProvider.exec = vi.fn().mockImplementation( - dispatchExec([ - { - matches: isRefSnapshot, - respond: () => - Promise.resolve({ - stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n', - stderr: '' - }) - }, - { - matches: isRemoteList, - respond: () => Promise.reject(new Error('relay exec failed')) - } - ]) - ) - - mockStore.getRepo.mockReturnValue({ - id: 'r1', - path: '/remote/repo', - connectionId: 'conn-1', - kind: 'git' - }) - - const result = (await handlers.get('repos:getBaseRefDefault')!(null, { repoId: 'r1' })) as { - defaultBaseRef: string | null - remoteCount: number - } - - // Why: default detection is independent of remote-count; a failing count falls back to 0 while the default still resolves. - expect(result.defaultBaseRef).toBe('origin/main') - expect(result.remoteCount).toBe(0) - }) - - it('uses the primary fallback over SSH when origin/HEAD is absent', async () => { - mockGitProvider.exec = vi.fn().mockImplementation( - dispatchExec([ - { - matches: isRefSnapshot, - respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/master\0\n', stderr: '' }) - }, - { - matches: isRemoteList, - respond: () => Promise.resolve({ stdout: 'origin\n', stderr: '' }) - } - ]) - ) - - mockStore.getRepo.mockReturnValue({ - id: 'r1', - path: '/remote/repo', - connectionId: 'conn-1', - kind: 'git' - }) - - const result = (await handlers.get('repos:getBaseRefDefault')!(null, { repoId: 'r1' })) as { - defaultBaseRef: string | null - remoteCount: number - } - - expect(result.defaultBaseRef).toBe('origin/master') - expect(result.remoteCount).toBe(1) + expect(result).toEqual({ defaultBaseRef: null, remoteCount: 0 }) + expect(getBaseRefDefault).not.toHaveBeenCalled() }) }) -describe('repos:searchBaseRefs SSH relay', () => { +describe('repos:searchBaseRefs on SSH repos', () => { const { handlers, mockWindow, captureHandlers } = createRepoHandlerHarness() beforeEach(() => { @@ -219,7 +110,8 @@ describe('repos:searchBaseRefs SSH relay', () => { mockStore.getRepos.mockReset().mockReturnValue([]) mockStore.getRepo.mockReset() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) - mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + gitExecFileAsyncMock.mockReset() + vi.mocked(getBaseRefDefault).mockClear() registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) @@ -237,20 +129,9 @@ describe('repos:searchBaseRefs SSH relay', () => { }) expect(result).toEqual([]) - expect(mockGitProvider.exec).not.toHaveBeenCalled() }) - it('returns refs for an empty query so remote Branch pickers open populated', async () => { - const stdout = [ - 'refs/remotes/origin/main\0origin/main', - 'refs/remotes/upstream/feature-x\0upstream/feature-x' - ].join('\n') - mockGitProvider.exec = vi.fn().mockImplementation((argv: string[]) => { - if (argv[0] === 'remote') { - return Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' }) - } - return Promise.resolve({ stdout, stderr: '' }) - }) + it('returns [] for an SSH repo without running git', async () => { mockStore.getRepo.mockReturnValue({ id: 'r1', path: '/remote/repo', @@ -258,254 +139,13 @@ describe('repos:searchBaseRefs SSH relay', () => { kind: 'git' }) - const result = await handlers.get('repos:searchBaseRefs')!(null, { - repoId: 'r1', - query: '' - }) - - expect(result).toEqual(['origin/main', 'upstream/feature-x']) - expect(mockGitProvider.exec).toHaveBeenCalledTimes(2) - const [argv] = mockGitProvider.exec.mock.calls.find( - (call) => (call[0] as string[])[0] === 'for-each-ref' - )! - expect(argv.some((arg: string) => arg.startsWith('--exclude=refs/remotes/'))).toBe(true) - expect(argv).toContain('--count=100') - expect(argv).toContain('refs/heads/**/**') - expect(argv).toContain('refs/heads/**/**/**') - expect(argv).toContain('refs/remotes/**/**') - expect(argv).toContain('refs/remotes/**/**/**') - }) - - it('sanitizes glob metacharacter-only queries into the empty-query branch list', async () => { - mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - mockStore.getRepo.mockReturnValue({ - id: 'r1', - path: '/remote/repo', - connectionId: 'conn-1', - kind: 'git' - }) - - await handlers.get('repos:searchBaseRefs')!(null, { - repoId: 'r1', - query: '***' - }) - - // Why: glob metacharacters are stripped, so the empty query intentionally lists refs. - const [argv] = mockGitProvider.exec.mock.calls.find( - (call) => (call[0] as string[])[0] === 'for-each-ref' - )! - expect(argv.some((arg: string) => arg.startsWith('--exclude=refs/remotes/'))).toBe(true) - expect(argv).toContain('--count=100') - expect(argv).toContain('refs/heads/**/**') - expect(argv).toContain('refs/heads/**/**/**') - expect(argv).toContain('refs/remotes/**/**') - expect(argv).toContain('refs/remotes/**/**/**') - }) - - it('rejects invalid limits before building broad relay searches', async () => { - mockStore.getRepo.mockReturnValue({ - id: 'r1', - path: '/remote/repo', - connectionId: 'conn-1', - kind: 'git' - }) - - const result = await handlers.get('repos:searchBaseRefs')!(null, { - repoId: 'r1', - query: '', - limit: 0.5 - }) - - expect(result).toEqual([]) - expect(mockGitProvider.exec).not.toHaveBeenCalled() - }) - - it('clamps oversized limits before building broad relay searches', async () => { - mockGitProvider.exec = vi.fn().mockImplementation((argv: string[]) => { - if (argv[0] === 'remote') { - return Promise.resolve({ stdout: 'origin\n', stderr: '' }) - } - return Promise.resolve({ - stdout: 'refs/remotes/origin/main\0origin/main', - stderr: '' - }) - }) - mockStore.getRepo.mockReturnValue({ - id: 'r1', - path: '/remote/repo', - connectionId: 'conn-1', - kind: 'git' - }) - - const result = await handlers.get('repos:searchBaseRefs')!(null, { - repoId: 'r1', - query: '', - limit: REPO_SEARCH_REFS_MAX_LIMIT + 1 - }) - - expect(result).toEqual(['origin/main']) - const forEachRefCall = mockGitProvider.exec.mock.calls.find( - (call) => (call[0] as string[])[0] === 'for-each-ref' - ) - expect(forEachRefCall?.[0]).toContain('--count=4000') - }) - - it('retries without --exclude for older git on SSH hosts', async () => { - const stdout = [ - 'refs/remotes/origin/main\0origin/main', - 'refs/remotes/origin/HEAD\0origin/HEAD' - ].join('\n') - mockGitProvider.exec = vi.fn().mockImplementation((argv: string[]) => { - if (argv[0] === 'remote') { - return Promise.resolve({ stdout: 'origin\n', stderr: '' }) - } - if (argv.some((arg) => arg.startsWith('--exclude=refs/remotes/'))) { - return Promise.reject( - Object.assign(new Error("unknown option `exclude'"), { - stderr: "error: unknown option `exclude'" - }) - ) - } - return Promise.resolve({ stdout, stderr: '' }) - }) - mockStore.getRepo.mockReturnValue({ - id: 'r1', - path: '/remote/repo', - connectionId: 'conn-1', - kind: 'git' - }) - - const result = await handlers.get('repos:searchBaseRefs')!(null, { - repoId: 'r1', - query: '', - limit: 1 - }) - const repeatedResult = await handlers.get('repos:searchBaseRefs')!(null, { - repoId: 'r1', - query: '', - limit: 1 - }) - - expect(result).toEqual(['origin/main']) - expect(repeatedResult).toEqual(['origin/main']) - const forEachRefCalls = mockGitProvider.exec.mock.calls.filter( - (call) => (call[0] as string[])[0] === 'for-each-ref' - ) - expect(forEachRefCalls).toHaveLength(3) - expect( - (forEachRefCalls[0][0] as string[]).some((arg) => arg.startsWith('--exclude=refs/remotes/')) - ).toBe(true) - expect( - (forEachRefCalls[1][0] as string[]).some((arg) => arg.startsWith('--exclude=refs/remotes/')) - ).toBe(false) - expect(forEachRefCalls[1][0]).toContain('--count=104') - expect( - (forEachRefCalls[2][0] as string[]).some((arg) => arg.startsWith('--exclude=refs/remotes/')) - ).toBe(false) - }) - - it('sends the widened `**` argv so all remotes and slash-named branches are discoverable', async () => { - // Why: SSH globs all remotes and `**` crosses `/` so slash-named branches match a single-word query (issue #624). - mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - - mockStore.getRepo.mockReturnValue({ - id: 'r1', - path: '/remote/repo', - connectionId: 'conn-1', - kind: 'git' - }) - - await handlers.get('repos:searchBaseRefs')!(null, { repoId: 'r1', query: 'upstream' }) - - expect(mockGitProvider.exec).toHaveBeenCalledTimes(2) - const [argv, path] = mockGitProvider.exec.mock.calls.find( - (call) => (call[0] as string[])[0] === 'for-each-ref' - )! - expect(path).toBe('/remote/repo') - expect(argv[0]).toBe('for-each-ref') - expect(argv).toContain('refs/heads/**/*upstream*') - expect(argv).toContain('refs/heads/**/*upstream*/**') - expect(argv).toContain('refs/remotes/**/*upstream*') - expect(argv).toContain('refs/remotes/**/*upstream*/**') - // Guard against regression to the old origin-only glob. - expect(argv).not.toContain('refs/remotes/origin/*upstream*') - expect(mockGitProvider.exec).toHaveBeenCalledWith(['remote'], '/remote/repo') - }) - - it('sends segmented argv for display-format queries like `upstream/main`', async () => { - // Why: a single `**` glob with a literal `/` makes SSH multi-segment queries silently match nothing (issue #624 shape). - mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - - mockStore.getRepo.mockReturnValue({ - id: 'r1', - path: '/remote/repo', - connectionId: 'conn-1', - kind: 'git' - }) - - await handlers.get('repos:searchBaseRefs')!(null, { repoId: 'r1', query: 'upstream/main' }) - - expect(mockGitProvider.exec).toHaveBeenCalledTimes(3) - const forEachRefCalls = mockGitProvider.exec.mock.calls.filter( - (call) => (call[0] as string[])[0] === 'for-each-ref' - ) - expect(forEachRefCalls).toHaveLength(2) - const segmentedArgv = forEachRefCalls[0][0] as string[] - const branchRootArgv = forEachRefCalls[1][0] as string[] - expect(segmentedArgv).toContain('refs/remotes/*upstream*/*main*') - expect(segmentedArgv).toContain('refs/heads/*upstream*/*main*') - expect(branchRootArgv).toContain('refs/remotes/*/upstream/main*') - expect(branchRootArgv).toContain('refs/heads/upstream/main*') - // Regression guard: a literal slash must never appear inside a single segmented glob (`*` doesn't cross `/`). - expect(segmentedArgv).not.toContain('refs/remotes/*upstream/main*/*') - expect(segmentedArgv).not.toContain('refs/remotes/*/*upstream/main*') - expect(mockGitProvider.exec).toHaveBeenCalledWith(['remote'], '/remote/repo') - }) - - it('parses NUL-delimited stdout and filters /HEAD pseudo-refs', async () => { - // Why: confirms the HEAD filter works for any remote, not just origin, on the SSH path. - const stdout = [ - 'refs/remotes/origin/main\0origin/main', - 'refs/remotes/upstream/main\0upstream/main', - 'refs/remotes/upstream/HEAD\0upstream/HEAD', - 'refs/remotes/origin/HEAD\0origin/HEAD' - ].join('\n') - mockGitProvider.exec = vi.fn().mockResolvedValue({ stdout, stderr: '' }) - - mockStore.getRepo.mockReturnValue({ - id: 'r1', - path: '/remote/repo', - connectionId: 'conn-1', - kind: 'git' - }) - - const result = (await handlers.get('repos:searchBaseRefs')!(null, { - repoId: 'r1', - query: 'main' - })) as string[] - - expect(result).toEqual(['origin/main', 'upstream/main']) - expect(result).not.toContain('origin/HEAD') - expect(result).not.toContain('upstream/HEAD') - }) - - it('returns [] when the relay exec throws', async () => { - mockGitProvider.exec = vi.fn().mockRejectedValue(new Error('ssh connection dropped')) - - mockStore.getRepo.mockReturnValue({ - id: 'r1', - path: '/remote/repo', - connectionId: 'conn-1', - kind: 'git' - }) - - const result = await handlers.get('repos:searchBaseRefs')!(null, { + const result = await handlers.get('repos:searchBaseRefDetails')!(null, { repoId: 'r1', query: 'main' }) - // Why: transport failure falls back to an empty result set so the picker doesn't crash. expect(result).toEqual([]) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) it('returns [] when the SSH provider is not connected', async () => { diff --git a/src/main/ipc/repos-remote-client-events.test.ts b/src/main/ipc/repos-remote-client-events.test.ts index fbd3197a805..0427c612c2e 100644 --- a/src/main/ipc/repos-remote-client-events.test.ts +++ b/src/main/ipc/repos-remote-client-events.test.ts @@ -38,8 +38,6 @@ vi.mock('./registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCache: vi.fn(), invalidateAuthorizedRootsCacheForRepo: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() })) -vi.mock('./ssh', () => ({ getActiveMultiplexer: vi.fn() })) type HandlerMap = Map unknown> diff --git a/src/main/ipc/repos-remote-git-username.test.ts b/src/main/ipc/repos-remote-git-username.test.ts index 41d254fc16e..6a4c0b48174 100644 --- a/src/main/ipc/repos-remote-git-username.test.ts +++ b/src/main/ipc/repos-remote-git-username.test.ts @@ -21,19 +21,14 @@ vi.mock('./registered-worktree-roots-cache', () => vi.mock('../worktree-root-preparation', () => moduleMocks.worktreeRootPreparationModuleMock(reposMocks) ) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks)) -vi.mock('../providers/ssh-filesystem-dispatch', () => - moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) -) -vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) -vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { createRepoHandlerHarness } from './repos-remote-test-harness' -const { handleMock, mockStore, mockGitProvider, prepareLocalWorktreeRootForRepoMock } = reposMocks +const { handleMock, mockStore, gitExecFileAsyncMock, prepareLocalWorktreeRootForRepoMock } = + reposMocks beforeEach(() => { clearGitCapabilityStateForTests() @@ -46,14 +41,14 @@ describe('repos:getGitUsername', () => { beforeEach(() => { captureHandlers(handleMock) mockStore.getRepo.mockReset() - mockGitProvider.exec.mockReset() + gitExecFileAsyncMock.mockReset() mockWindow.webContents.send.mockReset() prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) }) - it('uses explicit SSH username config instead of remote author identity', async () => { + it('returns no username for an SSH repo and never reads git config for it', async () => { mockStore.getRepo.mockReturnValue({ id: 'repo-ssh', path: '/remote/repo', @@ -63,39 +58,10 @@ describe('repos:getGitUsername', () => { kind: 'git', connectionId: 'conn-1' }) - mockGitProvider.exec.mockImplementation(async (args: string[]) => { - if (args[0] === 'config' && args[1] === '--get') { - const valueByKey: Record = { - 'user.username': 'remote-login', - 'user.email': 'remote-user@example.com', - 'user.name': 'Remote User' - } - const value = valueByKey[args[2]] - if (value) { - return { stdout: `${value}\n`, stderr: '' } - } - } - throw new Error(`unexpected git args: ${args.join(' ')}`) - }) const username = await handlers.get('repos:getGitUsername')!(null, { repoId: 'repo-ssh' }) - expect(username).toBe('remote-login') - expect(mockGitProvider.exec).toHaveBeenCalledWith( - ['config', '--get', 'github.user'], - '/remote/repo' - ) - expect(mockGitProvider.exec).toHaveBeenCalledWith( - ['config', '--get', 'user.username'], - '/remote/repo' - ) - expect(mockGitProvider.exec).not.toHaveBeenCalledWith( - ['config', '--get', 'user.email'], - '/remote/repo' - ) - expect(mockGitProvider.exec).not.toHaveBeenCalledWith( - ['config', '--get', 'user.name'], - '/remote/repo' - ) + expect(username).toBe('') + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/ipc/repos-remote-test-harness.ts b/src/main/ipc/repos-remote-test-harness.ts index eea5e767725..0f5c8c127ca 100644 --- a/src/main/ipc/repos-remote-test-harness.ts +++ b/src/main/ipc/repos-remote-test-harness.ts @@ -28,16 +28,9 @@ export type ReposIpcMocks = { | 'getSshTarget' | 'getSshTargets', ReposIpcSpy - > & { updateRepo: Mock<(repoId: string, updates: Record) => unknown> } - mockGitProvider: Record< - 'isGitRepo' | 'isGitRepoAsync' | 'clone' | 'listWorktrees' | 'getHostPlatform', - ReposIpcSpy - > & { exec: ReposGitArgvSpy } - mockFilesystemProvider: Record< - 'readDir' | 'readFile' | 'stat' | 'createDir' | 'createDirNoClobber' | 'deletePath', - ReposIpcSpy - > - mockMultiplexer: Record<'request' | 'notify', ReposIpcSpy> + > & { + updateRepo: Mock<(repoId: string, updates: Record) => unknown> + } gitSpawnMock: ReposIpcSpy gitSpawnAfterWindowsEnvironmentReadyMock: ReposIpcSpy gitExecFileAsyncMock: ReposGitArgvSpy @@ -66,34 +59,6 @@ export function createReposIpcMocks(): ReposIpcMocks { getSshTarget: vi.fn(), getSshTargets: vi.fn().mockReturnValue([]) }, - mockGitProvider: { - isGitRepo: vi.fn().mockReturnValue(true), - isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }), - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), - clone: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), - listWorktrees: vi.fn().mockResolvedValue([]), - getHostPlatform: vi.fn().mockReturnValue({ - relayPlatform: 'linux-x64', - os: 'linux', - arch: 'x64', - pathFlavor: 'posix', - commandDialect: 'posix', - pathSeparator: '/', - pathDelimiter: ':' - }) - }, - mockFilesystemProvider: { - readDir: vi.fn().mockResolvedValue([]), - readFile: vi.fn().mockRejectedValue(new Error('not found')), - stat: vi.fn().mockRejectedValue(new Error('not found')), - createDir: vi.fn().mockResolvedValue(undefined), - createDirNoClobber: vi.fn().mockResolvedValue(undefined), - deletePath: vi.fn().mockResolvedValue(undefined) - }, - mockMultiplexer: { - request: vi.fn(), - notify: vi.fn() - }, gitSpawnMock: vi.fn(), gitSpawnAfterWindowsEnvironmentReadyMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), @@ -113,7 +78,7 @@ export function electronModuleMock(mocks: ReposIpcMocks): Record { const isGitRepo = vi.fn().mockReturnValue(true) const getGitRepoRoot = vi.fn((path: string) => path) @@ -165,40 +130,6 @@ export function worktreeRootPreparationModuleMock(mocks: ReposIpcMocks): Record< return { prepareLocalWorktreeRootForRepo: mocks.prepareLocalWorktreeRootForRepoMock } } -export function sshGitDispatchModuleMock(mocks: ReposIpcMocks): Record { - return { - getSshGitProviderGeneration: () => 0, - getSshGitProvider: vi.fn().mockImplementation((id: string) => { - if (id === 'conn-1') { - return mocks.mockGitProvider - } - return undefined - }) - } -} - -export function sshFilesystemDispatchModuleMock(mocks: ReposIpcMocks): Record { - return { - getSshFilesystemProvider: vi.fn().mockImplementation((id: string) => { - if (id === 'conn-1') { - return mocks.mockFilesystemProvider - } - return undefined - }) - } -} - -export function sshModuleMock(mocks: ReposIpcMocks): Record { - return { - getActiveMultiplexer: vi.fn().mockImplementation((id: string) => { - if (id === 'conn-1') { - return mocks.mockMultiplexer - } - return undefined - }) - } -} - export type RepoHandlerHarness = { handlers: Map unknown> mockWindow: { isDestroyed: () => boolean; webContents: { send: ReposIpcSpy } } @@ -240,24 +171,12 @@ export function resetProjectGroupMocks( mocks.mockStore.updateProjectHostSetup.mockReset() mocks.mockStore.getRepos.mockReset() mocks.mockStore.getRepos.mockReturnValue([]) - mocks.mockFilesystemProvider.readDir.mockReset() - mocks.mockFilesystemProvider.readDir.mockResolvedValue([]) - mocks.mockFilesystemProvider.readFile.mockReset() - mocks.mockFilesystemProvider.readFile.mockRejectedValue(new Error('not found')) - mocks.mockFilesystemProvider.stat.mockReset() - mocks.mockFilesystemProvider.stat.mockRejectedValue(new Error('not found')) - mocks.mockGitProvider.isGitRepoAsync.mockReset() - mocks.mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null }) - mocks.mockGitProvider.listWorktrees.mockReset() - mocks.mockGitProvider.listWorktrees.mockResolvedValue([]) mocks.listWorktreeGraphMock.mockReset() mocks.listWorktreeGraphMock.mockResolvedValue([]) vi.mocked(gitRepo.isGitRepo).mockReset() vi.mocked(gitRepo.isGitRepo).mockReturnValue(true) vi.mocked(gitRepo.getGitRepoRoot).mockReset() vi.mocked(gitRepo.getGitRepoRoot).mockImplementation((path: string) => path) - mocks.mockMultiplexer.notify.mockReset() - mocks.mockMultiplexer.request.mockReset() mocks.invalidateAuthorizedRootsCacheMock.mockReset() mocks.prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) } diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts deleted file mode 100644 index 95f6878a251..00000000000 --- a/src/main/ipc/repos-remote.test.ts +++ /dev/null @@ -1,767 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { EventEmitter } from 'node:events' -import type * as GitRunner from '../git/runner' -import type * as RepoModule from '../git/repo' - -const { reposMocks, moduleMocks } = await vi.hoisted(async () => { - const moduleMocks = await import('./repos-remote-test-harness') - return { reposMocks: moduleMocks.createReposIpcMocks(), moduleMocks } -}) - -vi.mock('electron', () => moduleMocks.electronModuleMock(reposMocks)) -vi.mock('../git/repo', async (importOriginal) => - moduleMocks.gitRepoModuleMock(await importOriginal()) -) -vi.mock('../git/runner', async (importOriginal) => - moduleMocks.gitRunnerModuleMock(reposMocks, await importOriginal()) -) -vi.mock('../git/worktree', () => moduleMocks.gitWorktreeModuleMock(reposMocks)) -vi.mock('./registered-worktree-roots-cache', () => - moduleMocks.registeredWorktreeRootsCacheModuleMock(reposMocks) -) -vi.mock('../worktree-root-preparation', () => - moduleMocks.worktreeRootPreparationModuleMock(reposMocks) -) -vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks)) -vi.mock('../providers/ssh-filesystem-dispatch', () => - moduleMocks.sshFilesystemDispatchModuleMock(reposMocks) -) -vi.mock('./ssh', () => moduleMocks.sshModuleMock(reposMocks)) -vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks)) - -import { registerRepoHandlers } from './repos' -import { clearGitCapabilityStateForTests } from '../git/git-capability-state' -import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' -import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' -import { clearSubmodulePathsCacheForTests } from '../git/status' -import { createRepoHandlerHarness, waitForAssertion } from './repos-remote-test-harness' - -const { - handleMock, - mockStore, - mockGitProvider, - mockFilesystemProvider, - mockMultiplexer, - gitExecFileAsyncMock, - gitSpawnMock, - prepareLocalWorktreeRootForRepoMock -} = reposMocks - -beforeEach(() => { - clearGitCapabilityStateForTests() - resetSshProviderAuthorities() -}) - -describe('repos:addRemote', () => { - const { handlers, mockWindow, captureHandlers } = createRepoHandlerHarness() - - beforeEach(() => { - captureHandlers(handleMock) - mockStore.getRepos.mockReset().mockReturnValue([]) - mockStore.addRepo.mockReset() - mockStore.getSshTarget.mockReset() - mockStore.updateRepo.mockReset() - mockGitProvider.isGitRepoAsync.mockReset() - mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null }) - mockGitProvider.exec.mockReset() - mockGitProvider.exec.mockResolvedValue({ stdout: '', stderr: '' }) - mockGitProvider.clone.mockReset() - mockGitProvider.clone.mockResolvedValue({ stdout: '', stderr: '' }) - mockGitProvider.getHostPlatform.mockReset() - mockGitProvider.getHostPlatform.mockReturnValue({ - relayPlatform: 'linux-x64', - os: 'linux', - arch: 'x64', - pathFlavor: 'posix', - commandDialect: 'posix', - pathSeparator: '/', - pathDelimiter: ':' - }) - mockFilesystemProvider.stat.mockReset() - mockFilesystemProvider.stat.mockRejectedValue(new Error('not found')) - mockFilesystemProvider.createDirNoClobber.mockReset() - mockFilesystemProvider.createDirNoClobber.mockResolvedValue(undefined) - mockFilesystemProvider.deletePath.mockReset() - mockFilesystemProvider.deletePath.mockResolvedValue(undefined) - mockMultiplexer.request.mockReset() - mockMultiplexer.notify.mockReset() - gitSpawnMock.mockReset() - gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) - clearSubmodulePathsCacheForTests() - prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) - gitSpawnMock.mockImplementation(() => { - const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } - proc.stderr = new EventEmitter() - setImmediate(() => proc.emit('close', 0, null)) - return proc - }) - mockWindow.webContents.send.mockReset() - - registerRepoHandlers(mockWindow as never, mockStore as never, {} as never) - }) - - it('registers the repos:addRemote handler', () => { - expect(handlers.has('repos:addRemote')).toBe(true) - }) - - it('registers the repos:cloneRemote handler', () => { - expect(handlers.has('repos:cloneRemote')).toBe(true) - }) - - it('registers the repos:createRemote handler', () => { - expect(handlers.has('repos:createRemote')).toBe(true) - }) - - it('creates a remote repo with connectionId', async () => { - const result = await handlers.get('repos:addRemote')!(null, { - connectionId: 'conn-1', - remotePath: '/home/user/project' - }) - - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ - path: '/home/user/project', - connectionId: 'conn-1', - kind: 'git', - displayName: 'project', - badgeColor: DEFAULT_REPO_BADGE_COLOR, - projectHostSetupMethod: 'imported-existing-folder' - }) - ) - expect(result).toHaveProperty('repo.id') - expect(result).toHaveProperty('repo.connectionId', 'conn-1') - expect(result).not.toHaveProperty('repo.externalWorktreeVisibility') - }) - - it('uses custom displayName when provided', async () => { - const result = await handlers.get('repos:addRemote')!(null, { - connectionId: 'conn-1', - remotePath: '/home/user/project', - displayName: 'My Server Repo' - }) - - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ - displayName: 'My Server Repo', - path: '/home/user/project' - }) - ) - expect(result).toHaveProperty('repo.displayName', 'My Server Repo') - }) - - it('clones a repo on an SSH target and registers the cloned path', async () => { - const result = await handlers.get('repos:cloneRemote')!(null, { - connectionId: 'conn-1', - url: 'https://github.com/stablyai/orca.git', - destination: '/home/user' - }) - - expect(mockFilesystemProvider.createDir).toHaveBeenCalledWith('/home/user') - expect(mockGitProvider.clone).toHaveBeenCalledWith( - ['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'], - '/home/user', - expect.objectContaining({ - signal: expect.any(AbortSignal), - timeoutMs: 10 * 60_000, - onProgress: expect.any(Function) - }) - ) - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ - path: '/home/user/orca', - connectionId: 'conn-1', - kind: 'git', - displayName: 'orca', - badgeColor: DEFAULT_REPO_BADGE_COLOR - }) - ) - expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', { - rootPath: '/home/user/orca' - }) - expect(result).toHaveProperty('path', '/home/user/orca') - expect(result).toHaveProperty('connectionId', 'conn-1') - }) - - it('forwards SSH clone progress through the existing clone progress event', async () => { - mockGitProvider.clone.mockImplementationOnce( - async ( - _args: string[], - _cwd: string, - options?: { onProgress?: (progress: { phase: string; percent: number }) => void } - ) => { - options?.onProgress?.({ phase: 'Receiving objects', percent: 42 }) - return { stdout: '', stderr: '' } - } - ) - - await handlers.get('repos:cloneRemote')!(null, { - connectionId: 'conn-1', - url: 'https://github.com/stablyai/orca.git', - destination: '/home/user' - }) - - expect(mockWindow.webContents.send).toHaveBeenCalledWith('repos:clone-progress', { - phase: 'Receiving objects', - percent: 42 - }) - }) - - it('refuses a saved SSH project at the destination whose folder is not this clone', async () => { - mockStore.getRepos.mockReturnValue([ - { - id: 'existing-id', - path: '/home/user/orca', - connectionId: 'conn-1', - displayName: 'orca', - badgeColor: '#fff', - addedAt: 1000, - kind: 'git' - } - ]) - mockGitProvider.exec.mockRejectedValue(new Error('fatal: not a git repository')) - - await expect( - handlers.get('repos:cloneRemote')!(null, { - connectionId: 'conn-1', - url: 'https://github.com/stablyai/orca.git', - destination: '/home/user' - }) - ).rejects.toThrow('"orca" is already an Orca project') - expect(mockGitProvider.clone).not.toHaveBeenCalled() - }) - - it('re-registers the root after re-cloning into a saved SSH project whose folder was gone', async () => { - const existing = { - id: 'existing-id', - path: '/home/user/orca', - connectionId: 'conn-1', - displayName: 'orca', - badgeColor: '#fff', - addedAt: 1000, - kind: 'git', - gitRemoteIdentity: { - canonicalKey: 'github.com/stablyai/orca', - remoteName: 'origin', - remoteUrl: 'https://github.com/stablyai/orca.git' - } - } - mockStore.getRepos.mockReturnValue([existing]) - mockGitProvider.exec.mockRejectedValue(new Error('fatal: not a git repository')) - - const result = await handlers.get('repos:cloneRemote')!(null, { - connectionId: 'conn-1', - url: 'https://github.com/stablyai/orca.git', - destination: '/home/user' - }) - - expect(mockGitProvider.clone).toHaveBeenCalledTimes(1) - expect(result).toBe(existing) - expect(mockStore.addRepo).not.toHaveBeenCalled() - // Without this the relay never learns the path came back, so sessions there stay unauthorized. - expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', { - rootPath: '/home/user/orca' - }) - }) - - it('upgrades an existing SSH folder repo after cloning into that path', async () => { - const existing = { - id: 'existing-folder', - path: '/home/user/orca', - connectionId: 'conn-1', - displayName: 'orca', - badgeColor: '#fff', - addedAt: 1000, - kind: 'folder' - } - const updated = { ...existing, kind: 'git' } - mockStore.getRepos.mockReturnValue([existing]) - mockStore.updateRepo.mockReturnValue(updated) - - const result = await handlers.get('repos:cloneRemote')!(null, { - connectionId: 'conn-1', - url: 'https://github.com/stablyai/orca.git', - destination: '/home/user' - }) - - expect(mockGitProvider.clone).toHaveBeenCalledWith( - ['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'], - '/home/user', - expect.objectContaining({ - signal: expect.any(AbortSignal), - timeoutMs: 10 * 60_000, - onProgress: expect.any(Function) - }) - ) - expect(mockStore.updateRepo).toHaveBeenCalledWith('existing-folder', { - kind: 'git', - projectHostSetupMethod: 'cloned' - }) - expect(mockStore.addRepo).not.toHaveBeenCalled() - expect(result).toBe(updated) - }) - - it('does not delete a fresh SSH clone target after git clone fails', async () => { - mockGitProvider.clone.mockRejectedValueOnce(new Error('repository not found')) - mockFilesystemProvider.stat.mockRejectedValueOnce(new Error('not found')) - - await expect( - handlers.get('repos:cloneRemote')!(null, { - connectionId: 'conn-1', - url: 'https://github.com/stablyai/orca.git', - destination: '/home/user' - }) - ).rejects.toThrow('repository not found') - - expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled() - }) - - it('rejects concurrent SSH clones to the same destination', async () => { - let releaseClone!: () => void - mockGitProvider.clone.mockImplementationOnce( - async () => - new Promise<{ stdout: string; stderr: string }>((resolve) => { - releaseClone = () => resolve({ stdout: '', stderr: '' }) - }) - ) - - const firstClone = handlers.get('repos:cloneRemote')!(null, { - connectionId: 'conn-1', - url: 'https://github.com/stablyai/orca.git', - destination: '/home/user' - }) - await waitForAssertion(() => expect(mockGitProvider.clone).toHaveBeenCalledTimes(1)) - - await expect( - handlers.get('repos:cloneRemote')!(null, { - connectionId: 'conn-1', - url: 'https://github.com/stablyai/orca.git', - destination: '/home/user' - }) - ).rejects.toThrow('A clone is already in progress for this SSH destination') - - releaseClone() - await firstClone - }) - - it('resolves SSH clone destinations under home before validating the path', async () => { - mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu/projects' }) - - await handlers.get('repos:cloneRemote')!(null, { - connectionId: 'conn-1', - url: 'https://github.com/stablyai/orca.git', - destination: '~/projects' - }) - - expect(mockMultiplexer.request).toHaveBeenCalledWith('session.resolveHome', { - path: '~/projects' - }) - expect(mockGitProvider.clone).toHaveBeenCalledWith( - ['clone', '--progress', '--', 'https://github.com/stablyai/orca.git', 'orca'], - '/home/ubuntu/projects', - expect.any(Object) - ) - }) - - it('does not clean up a pre-existing SSH clone target after git clone fails', async () => { - mockGitProvider.clone.mockRejectedValueOnce(new Error('destination already exists')) - mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 }) - - await expect( - handlers.get('repos:cloneRemote')!(null, { - connectionId: 'conn-1', - url: 'https://github.com/stablyai/orca.git', - destination: '/home/user' - }) - ).rejects.toThrow('destination already exists') - - expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled() - }) - - it('aborts an active SSH clone and reports the abort without deleting pre-existing targets', async () => { - mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 }) - mockGitProvider.clone.mockImplementationOnce( - async (_args: string[], _cwd: string, options?: { signal?: AbortSignal }) => - new Promise<{ stdout: string; stderr: string }>((_resolve, reject) => { - options?.signal?.addEventListener('abort', () => reject(new Error('aborted by test'))) - }) - ) - - const clonePromise = handlers.get('repos:cloneRemote')!(null, { - connectionId: 'conn-1', - url: 'https://github.com/stablyai/orca.git', - destination: '/home/user' - }) - await waitForAssertion(() => expect(mockGitProvider.clone).toHaveBeenCalledTimes(1)) - - await handlers.get('repos:cloneAbort')!(null, undefined) - - await expect(clonePromise).rejects.toThrow('Clone aborted') - const options = mockGitProvider.clone.mock.calls[0][2] as { signal: AbortSignal } - expect(options.signal.aborted).toBe(true) - expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalled() - }) - - it('rejects SSH clone destinations that are not absolute host paths', async () => { - await expect( - handlers.get('repos:cloneRemote')!(null, { - connectionId: 'conn-1', - url: 'https://github.com/stablyai/orca.git', - destination: 'relative/path' - }) - ).rejects.toThrow('Clone destination must be an absolute path on the SSH host') - - expect(mockGitProvider.clone).not.toHaveBeenCalled() - }) - - it('creates a new git project on an SSH target', async () => { - const result = await handlers.get('repos:createRemote')!(null, { - connectionId: 'conn-1', - parentPath: '/home/user', - name: 'created', - kind: 'git' - }) - - expect(mockFilesystemProvider.createDirNoClobber).toHaveBeenCalledWith('/home/user/created') - expect(mockGitProvider.exec).toHaveBeenCalledWith(['init'], '/home/user/created') - expect(mockGitProvider.exec).toHaveBeenCalledWith( - ['commit', '--allow-empty', '-m', 'Initial commit'], - '/home/user/created' - ) - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ - path: '/home/user/created', - connectionId: 'conn-1', - kind: 'git', - displayName: 'created' - }) - ) - expect(result).toHaveProperty('repo.path', '/home/user/created') - expect(result).toHaveProperty('repo.connectionId', 'conn-1') - }) - - it('resolves SSH create parents under home before validating the path', async () => { - mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu/projects' }) - - const result = await handlers.get('repos:createRemote')!(null, { - connectionId: 'conn-1', - parentPath: '~/projects', - name: 'created', - kind: 'folder' - }) - - expect(mockMultiplexer.request).toHaveBeenCalledWith('session.resolveHome', { - path: '~/projects' - }) - expect(mockFilesystemProvider.createDirNoClobber).toHaveBeenCalledWith( - '/home/ubuntu/projects/created' - ) - expect(result).toHaveProperty('repo.path', '/home/ubuntu/projects/created') - }) - - it('creates a new folder project on an SSH target without git init', async () => { - const result = await handlers.get('repos:createRemote')!(null, { - connectionId: 'conn-1', - parentPath: '/home/user', - name: 'notes', - kind: 'folder' - }) - - expect(mockFilesystemProvider.createDirNoClobber).toHaveBeenCalledWith('/home/user/notes') - expect(mockGitProvider.exec).not.toHaveBeenCalled() - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ - path: '/home/user/notes', - connectionId: 'conn-1', - kind: 'folder', - displayName: 'notes' - }) - ) - expect(result).toHaveProperty('repo.kind', 'folder') - }) - - it('rejects SSH create parent paths that are not absolute host paths', async () => { - const result = await handlers.get('repos:createRemote')!(null, { - connectionId: 'conn-1', - parentPath: 'relative/path', - name: 'created', - kind: 'git' - }) - - expect(result).toEqual({ error: 'Parent directory must be an absolute path on the SSH host' }) - expect(mockFilesystemProvider.createDirNoClobber).not.toHaveBeenCalled() - expect(mockGitProvider.exec).not.toHaveBeenCalled() - }) - - it('rejects non-empty existing SSH create targets', async () => { - mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 }) - mockFilesystemProvider.readDir.mockResolvedValueOnce([ - { name: 'package.json', isDirectory: false, isSymlink: false } - ]) - - const result = await handlers.get('repos:createRemote')!(null, { - connectionId: 'conn-1', - parentPath: '/home/user', - name: 'created', - kind: 'git' - }) - - expect(result).toEqual({ - error: '"created" already exists at this location and is not empty.' - }) - expect(mockFilesystemProvider.createDirNoClobber).not.toHaveBeenCalled() - expect(mockGitProvider.exec).not.toHaveBeenCalled() - }) - - it('removes a newly created SSH directory when git init fails', async () => { - mockGitProvider.exec.mockRejectedValueOnce(new Error('git init failed')) - - const result = await handlers.get('repos:createRemote')!(null, { - connectionId: 'conn-1', - parentPath: '/home/user', - name: 'created', - kind: 'git' - }) - - expect(result).toEqual({ error: 'Failed to initialize git repository: git init failed' }) - expect(mockFilesystemProvider.deletePath).toHaveBeenCalledWith('/home/user/created', true) - expect(mockStore.addRepo).not.toHaveBeenCalled() - }) - - it('preserves an existing empty SSH directory and removes only .git when commit fails', async () => { - mockFilesystemProvider.stat.mockResolvedValueOnce({ type: 'directory', size: 0, mtime: 0 }) - mockFilesystemProvider.readDir.mockResolvedValueOnce([]) - mockGitProvider.exec - .mockResolvedValueOnce({ stdout: '', stderr: '' }) - .mockRejectedValueOnce(new Error('Please tell me who you are')) - - const result = await handlers.get('repos:createRemote')!(null, { - connectionId: 'conn-1', - parentPath: '/home/user', - name: 'created', - kind: 'git' - }) - - expect(result).toEqual({ - error: - 'Git author identity is not configured on the SSH host. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"` on that host, then try again.' - }) - expect(mockFilesystemProvider.deletePath).toHaveBeenCalledWith('/home/user/created/.git', true) - expect(mockFilesystemProvider.deletePath).not.toHaveBeenCalledWith('/home/user/created', true) - expect(mockStore.addRepo).not.toHaveBeenCalled() - }) - - it('returns existing repo if same connectionId and path already added', async () => { - const existing = { - id: 'existing-id', - path: '/home/user/project', - connectionId: 'conn-1', - displayName: 'project', - badgeColor: '#fff', - addedAt: 1000, - kind: 'git' - } - mockStore.getRepos.mockReturnValue([existing]) - - const result = await handlers.get('repos:addRemote')!(null, { - connectionId: 'conn-1', - remotePath: '/home/user/project' - }) - - expect(result).toEqual({ repo: existing }) - expect(mockStore.addRepo).not.toHaveBeenCalled() - }) - - it('throws when SSH connection is not found', async () => { - const result = await handlers.get('repos:addRemote')!(null, { - connectionId: 'unknown-conn', - remotePath: '/home/user/project' - }) - expect(result).toEqual({ error: 'SSH connection "unknown-conn" not found or not connected' }) - }) - - it('throws when remote path is not a git repo', async () => { - mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({ isRepo: false, rootPath: null }) - - const result = await handlers.get('repos:addRemote')!(null, { - connectionId: 'conn-1', - remotePath: '/home/user/documents' - }) - expect(result).toEqual({ error: 'Not a valid git repository: /home/user/documents' }) - expect(mockStore.addRepo).not.toHaveBeenCalled() - }) - - it('adds as folder when kind is explicitly set', async () => { - const result = await handlers.get('repos:addRemote')!(null, { - connectionId: 'conn-1', - remotePath: '/home/user/documents', - kind: 'folder' - }) - - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ - kind: 'folder', - path: '/home/user/documents', - badgeColor: DEFAULT_REPO_BADGE_COLOR - }) - ) - expect(result).toHaveProperty('repo.kind', 'folder') - }) - - it('uses rootPath from git detection when available', async () => { - mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({ - isRepo: true, - rootPath: '/home/user/project' - }) - - const result = await handlers.get('repos:addRemote')!(null, { - connectionId: 'conn-1', - remotePath: '/home/user/project/src' - }) - - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ - kind: 'git', - path: '/home/user/project' - }) - ) - expect(result).toHaveProperty('repo.path', '/home/user/project') - }) - - it('uses the resolved git root basename for the default remote display name', async () => { - mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({ - isRepo: true, - rootPath: '/home/user/project' - }) - - const result = await handlers.get('repos:addRemote')!(null, { - connectionId: 'conn-1', - remotePath: '/home/user/project/src' - }) - - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ - path: '/home/user/project', - displayName: 'project' - }) - ) - expect(result).toHaveProperty('repo.displayName', 'project') - }) - - it('derives default remote display names from Windows path separators', async () => { - const result = await handlers.get('repos:addRemote')!(null, { - connectionId: 'conn-1', - remotePath: 'C:\\Users\\alice\\project', - kind: 'folder' - }) - - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ - path: 'C:\\Users\\alice\\project', - displayName: 'project' - }) - ) - expect(result).toHaveProperty('repo.displayName', 'project') - }) - - it('notifies renderer when remote repo is added', async () => { - await handlers.get('repos:addRemote')!(null, { - connectionId: 'conn-1', - remotePath: '/home/user/project' - }) - - expect(mockWindow.webContents.send).toHaveBeenCalledWith('repos:changed') - }) - - it('resolves ~ to absolute path via relay and uses SSH target label', async () => { - mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu' }) - mockStore.getSshTarget.mockReturnValueOnce({ - id: 'conn-1', - label: 'ubuntu-box', - host: '192.168.1.100', - port: 22, - username: 'user' - }) - - const result = await handlers.get('repos:addRemote')!(null, { - connectionId: 'conn-1', - remotePath: '~' - }) - - expect(mockMultiplexer.request).toHaveBeenCalledWith('session.resolveHome', { path: '~' }) - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ - displayName: 'ubuntu-box', - path: '/home/ubuntu' - }) - ) - expect(result).toHaveProperty('repo.displayName', 'ubuntu-box') - expect(result).toHaveProperty('repo.path', '/home/ubuntu') - }) - - it('resolves ~/subdir to absolute path via relay', async () => { - mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu/subdir' }) - - const result = await handlers.get('repos:addRemote')!(null, { - connectionId: 'conn-1', - remotePath: '~/subdir' - }) - - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ - path: '/home/ubuntu/subdir', - displayName: 'subdir' - }) - ) - expect(result).toHaveProperty('repo.path', '/home/ubuntu/subdir') - }) - - it('returns an existing SSH repo when a selected subdirectory resolves to the repo root', async () => { - const existing = { - id: 'existing-id', - path: '/home/user/orca', - connectionId: 'conn-1', - displayName: 'orca', - badgeColor: '#fff', - addedAt: 1000, - kind: 'git' - } - mockStore.getRepos.mockReturnValue([existing]) - mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({ - isRepo: true, - rootPath: '/home/user/orca' - }) - - const result = await handlers.get('repos:addRemote')!(null, { - connectionId: 'conn-1', - remotePath: '/home/user/orca/src' - }) - - expect(result).toEqual({ repo: existing }) - expect(mockStore.addRepo).not.toHaveBeenCalled() - }) - - it('ignores SSH target label when custom displayName is provided', async () => { - mockMultiplexer.request.mockResolvedValueOnce({ resolvedPath: '/home/ubuntu' }) - mockStore.getSshTarget.mockReturnValueOnce({ - id: 'conn-1', - label: 'ubuntu-box', - host: '192.168.1.100', - port: 22, - username: 'user' - }) - - const result = await handlers.get('repos:addRemote')!(null, { - connectionId: 'conn-1', - remotePath: '~', - displayName: 'My Home' - }) - - expect(mockStore.addRepo).toHaveBeenCalledWith( - expect.objectContaining({ - displayName: 'My Home', - path: '/home/ubuntu' - }) - ) - expect(result).toHaveProperty('repo.displayName', 'My Home') - }) -}) diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts index df660555c0d..651b31069ee 100644 --- a/src/main/ipc/repos-sparse-presets.test.ts +++ b/src/main/ipc/repos-sparse-presets.test.ts @@ -46,14 +46,6 @@ vi.mock('./registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCacheForRepo: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: vi.fn() -})) - -vi.mock('./ssh', () => ({ - getActiveMultiplexer: vi.fn() -})) - import { registerRepoHandlers } from './repos' type HandlerMap = Map unknown> diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 9e7a4175c22..1c2b5d8473a 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -22,7 +22,6 @@ export function registerRepoHandlers( ): void { // Remove previously registered handlers so we can re-register on macOS app re-activation (new window). ipcMain.removeHandler('repos:list') - ipcMain.removeHandler('repos:listForExecutionHost') ipcMain.removeHandler('repos:add') ipcMain.removeHandler('repos:removeForHost') ipcMain.removeHandler('repos:reorder') @@ -53,16 +52,13 @@ export function registerRepoHandlers( ipcMain.removeHandler('repos:pickDirectory') ipcMain.removeHandler('repos:clone') ipcMain.removeHandler('repos:cloneAbort') - ipcMain.removeHandler('repos:cloneRemote') ipcMain.removeHandler('repos:isGitAvailable') ipcMain.removeHandler('repos:getDefaultCreateProjectParent') ipcMain.removeHandler('repos:getGitUsername') ipcMain.removeHandler('repos:getBaseRefDefault') ipcMain.removeHandler('repos:searchBaseRefs') ipcMain.removeHandler('repos:searchBaseRefDetails') - ipcMain.removeHandler('repos:addRemote') ipcMain.removeHandler('repos:create') - ipcMain.removeHandler('repos:createRemote') ipcMain.removeHandler('sparsePresets:list') ipcMain.removeHandler('sparsePresets:save') ipcMain.removeHandler('sparsePresets:remove') diff --git a/src/main/ipc/repos/base-ref-query-handlers.ts b/src/main/ipc/repos/base-ref-query-handlers.ts index f638e7ce849..6cff029445c 100644 --- a/src/main/ipc/repos/base-ref-query-handlers.ts +++ b/src/main/ipc/repos/base-ref-query-handlers.ts @@ -8,20 +8,7 @@ import { } from '../../../shared/repo-search-limits' import { isFolderRepo } from '../../../shared/repo-kind' import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' -import { - getBaseRefDefault, - getRemoteCount, - normalizeRefSearchQuery, - parseAndFilterSearchRefDetails, - parseRemoteCount, - resolveDefaultBaseRefViaExec, - buildSearchBaseRefsArgv, - isForEachRefExcludeUnsupportedError, - mergeBaseRefSearchResultGroups, - searchBaseRefDetails -} from '../../git/repo' -import { getSshGitProvider } from '../../providers/ssh-git-dispatch' -import { getSshGitCapabilityCache } from '../../git/git-capability-state' +import { getBaseRefDefault, getRemoteCount, searchBaseRefDetails } from '../../git/repo' export function registerBaseRefQueryHandlers(store: Store): void { ipcMain.handle( @@ -35,48 +22,9 @@ export function registerBaseRefQueryHandlers(store: Store): void { // Why: folder repos have no git state for a base ref; return null + 0 so the renderer skips a fabricated default. return { defaultBaseRef: null, remoteCount: 0 } } - // Why: remote repos need the relay to resolve symbolic-ref where the git data lives. + // Why: an SSH repo's git data lives on a host this process does not serve. if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - return { defaultBaseRef: null, remoteCount: 0 } - } - // Why: delegate to shared resolveDefaultBaseRefViaExec; log symbolic-ref failures here to keep the SSH transport diagnostic it otherwise swallows. - const resolveDefault = async (): Promise => { - return resolveDefaultBaseRefViaExec(async (argv) => { - try { - return await provider.exec(argv, repo.path) - } catch (err) { - if (argv[0] === 'symbolic-ref') { - console.warn('[repos:getBaseRefDefault] SSH symbolic-ref failed', { - path: repo.path, - err - }) - } - throw err - } - }) - } - - const resolveRemoteCount = async (): Promise => { - try { - const remotesResult = await provider.exec(['remote'], repo.path) - return parseRemoteCount(remotesResult.stdout) - } catch (err) { - // Why: 0 = unknown sentinel that suppresses the multi-remote hint. - console.warn('[repos:getBaseRefDefault] SSH git remote count failed', { - path: repo.path, - err - }) - return 0 - } - } - - const [defaultBaseRef, remoteCount] = await Promise.all([ - resolveDefault(), - resolveRemoteCount() - ]) - return { defaultBaseRef, remoteCount } + return { defaultBaseRef: null, remoteCount: 0 } } // Why: run in parallel; a remote-count failure must not break default detection. const [defaultBaseRef, remoteCount] = await Promise.all([ @@ -123,66 +71,8 @@ async function searchBaseRefDetailsForRepo( // Keep the public IPC shape forgiving while bounding Git and retained results // for callers that request an unusually large page. const limit = clampRepoSearchRefsLimit(requestedLimit) - // Why: remote repos need the relay to list branches on the remote host. if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - return [] - } - // Why: strip glob metacharacters to prevent glob injection (mirrors local normalizeRefSearchQuery). - const normalizedQuery = normalizeRefSearchQuery(args.query) - try { - // Why: argv lives in buildSearchBaseRefsArgv so SSH and local paths cannot drift. - const remotesResult = await provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' })) - const remotes = remotesResult.stdout - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - const capabilities = getSshGitCapabilityCache(provider) - const runSearch = async (patternGroup?: 'segmented' | 'branchRoot'): Promise => { - return capabilities.runWithFallback( - 'for-each-ref-exclude', - async () => - ( - await provider.exec( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - remoteNames: remotes, - patternGroup - }), - repo.path - ) - ).stdout, - async () => - ( - await provider.exec( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - excludeRemoteHead: false, - remoteNames: remotes, - patternGroup - }), - repo.path - ) - ).stdout, - isForEachRefExcludeUnsupportedError - ) - } - // Why: delegate the parse/filter/dedup/limit pipeline to the shared helper so SSH and local paths cannot diverge. - const searchTokens = normalizedQuery.split('/').filter((token) => token.length > 0) - if (searchTokens.length > 1) { - const results = await Promise.all([runSearch('segmented'), runSearch('branchRoot')]) - return mergeBaseRefSearchResultGroups( - results.map((stdout) => parseAndFilterSearchRefDetails(stdout, limit, remotes)), - limit - ) - } - return parseAndFilterSearchRefDetails(await runSearch(), limit, remotes) - } catch (err) { - console.warn('[repos:searchBaseRefs] SSH for-each-ref failed', { - path: repo.path, - err - }) - return [] - } + return [] } return searchBaseRefDetails(repo.path, args.query, limit) } diff --git a/src/main/ipc/repos/folder-workspace-handlers.ts b/src/main/ipc/repos/folder-workspace-handlers.ts index 744d919d282..638591ce227 100644 --- a/src/main/ipc/repos/folder-workspace-handlers.ts +++ b/src/main/ipc/repos/folder-workspace-handlers.ts @@ -8,7 +8,6 @@ import { getFolderWorkspacePathStatus, getFolderWorkspacePathStatusForPath } from '../../project-groups/folder-workspace-path-status' -import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import { notifyReposChanged } from './repos-changed-notification' import { @@ -33,7 +32,7 @@ export function registerFolderWorkspaceHandlers( rawArgs, 'invalid_folder_workspace_path_status_args' ) as FolderWorkspacePathStatusRequest - return getFolderWorkspacePathStatus(store, args, { getSshFilesystemProvider }) + return getFolderWorkspacePathStatus(store, args) }) ipcMain.handle( @@ -53,16 +52,13 @@ export function registerFolderWorkspaceHandlers( if (!group || !folderPath) { throw new Error('folder_workspace_project_group_not_found') } - const status = await getFolderWorkspacePathStatusForPath( - { - folderPath, - projectGroupId: group.id, - connectionId: args.connectionId ?? group.connectionId ?? null, - projectGroups, - repos: store.getRepos() - }, - { getSshFilesystemProvider } - ) + const status = await getFolderWorkspacePathStatusForPath({ + folderPath, + projectGroupId: group.id, + connectionId: args.connectionId ?? group.connectionId ?? null, + projectGroups, + repos: store.getRepos() + }) assertFolderWorkspacePathUsable(status) const workspace = store.createFolderWorkspace({ ...args, @@ -90,19 +86,16 @@ export function registerFolderWorkspaceHandlers( return null } const projectGroups = store.getProjectGroups() - const status = await getFolderWorkspacePathStatusForPath( - { - folderPath: args.updates.folderPath, - projectGroupId: workspace.projectGroupId, - connectionId: - workspace.connectionId ?? - projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ?? - null, - projectGroups, - repos: store.getRepos() - }, - { getSshFilesystemProvider } - ) + const status = await getFolderWorkspacePathStatusForPath({ + folderPath: args.updates.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: + workspace.connectionId ?? + projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ?? + null, + projectGroups, + repos: store.getRepos() + }) assertFolderWorkspacePathUsable(status) } const updated = store.updateFolderWorkspace(args.folderWorkspaceId, args.updates) diff --git a/src/main/ipc/repos/host-repo-catalog-snapshot.ts b/src/main/ipc/repos/host-repo-catalog-snapshot.ts deleted file mode 100644 index 3e16cf6245b..00000000000 --- a/src/main/ipc/repos/host-repo-catalog-snapshot.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { Store } from '../../persistence' -import type { Repo } from '../../../shared/repo-types' -import type { - HostRepoCatalogSnapshot, - ListReposForExecutionHostArgs -} from '../../../shared/host-repo-catalog-contract' -import { - getRepoExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId -} from '../../../shared/execution-host' -import { isAdmissibleDirectSshAuthority } from '../../../shared/ssh-retained-payload-admission' -import { isCurrentSshProviderAuthority } from '../../ssh/ssh-provider-authority' -import { getSshGitProvider } from '../../providers/ssh-git-dispatch' -import { visibleRepos } from '../../ssh/orcad-retained-source' - -function hasValidCatalogSshAuthority( - args: ListReposForExecutionHostArgs -): args is Extract { - if (!('expectedAuthority' in args)) { - return false - } - return isAdmissibleDirectSshAuthority(args.expectedAuthority) -} - -function repoHostContradictsConnection(repo: Repo): boolean { - if (!repo.executionHostId || !repo.connectionId) { - return false - } - const explicitHost = parseExecutionHostId(repo.executionHostId) - return explicitHost?.kind !== 'ssh' || explicitHost.targetId !== repo.connectionId -} - -function getConsistentRepoCatalogForHost( - repos: readonly Repo[], - host: NonNullable> -): Repo[] | null { - const hasContradiction = repos.some( - (repo) => - repoHostContradictsConnection(repo) && - (getRepoExecutionHostId(repo) === host.id || - (host.kind === 'ssh' && repo.connectionId === host.targetId)) - ) - return hasContradiction ? null : repos.filter((repo) => getRepoExecutionHostId(repo) === host.id) -} - -export async function listReposForExecutionHost( - store: Store, - args: ListReposForExecutionHostArgs -): Promise { - const parsedHost = parseExecutionHostId(args?.executionHostId) - const rejected = ( - reason: Extract['reason'] - ): HostRepoCatalogSnapshot => ({ - authoritative: false, - executionHostId: args.executionHostId, - reason - }) - if (!parsedHost || parsedHost.kind === 'runtime') { - return rejected('rejected') - } - if (parsedHost.kind === 'local') { - if ('expectedAuthority' in args) { - return rejected('rejected') - } - const repos = getConsistentRepoCatalogForHost(visibleRepos(store), parsedHost) - if (!repos) { - return rejected('rejected') - } - return { - authoritative: true, - authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, - repos: structuredClone(repos) - } - } - if ( - !hasValidCatalogSshAuthority(args) || - args.expectedAuthority.targetId !== parsedHost.targetId - ) { - return rejected('rejected') - } - const authority = { ...args.expectedAuthority } - if (!isCurrentSshProviderAuthority(authority)) { - return rejected('stale') - } - const provider = getSshGitProvider(parsedHost.targetId) - if (!provider) { - return rejected('unavailable') - } - const matchingRepos = getConsistentRepoCatalogForHost(visibleRepos(store), parsedHost) - if (!matchingRepos) { - return rejected('rejected') - } - const repos = structuredClone(matchingRepos) - await Promise.resolve() - if ( - getSshGitProvider(parsedHost.targetId) !== provider || - !isCurrentSshProviderAuthority(authority) - ) { - return rejected('stale') - } - return { - authoritative: true, - authority: { - kind: 'direct-ssh', - executionHostId: parsedHost.id, - ...authority - }, - repos - } -} diff --git a/src/main/ipc/repos/nested-repo-import-handler.ts b/src/main/ipc/repos/nested-repo-import-handler.ts index 6d1fceb9391..69fa84a4723 100644 --- a/src/main/ipc/repos/nested-repo-import-handler.ts +++ b/src/main/ipc/repos/nested-repo-import-handler.ts @@ -13,11 +13,9 @@ import { resolveNestedRepoSelection } from '../../project-groups/nested-repo-import' import { createNestedRepoImportTargetResolver } from '../../project-groups/nested-repo-import-target' -import { getSshGitProvider } from '../../providers/ssh-git-dispatch' -import { getConnectionExecutionHostId } from '../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { detectRepoIconAndUpstream } from '../../repo-icon-autodetect' import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation' -import { getActiveMultiplexer } from '../ssh' import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cache' import { emitRepoAdded } from './repo-added-telemetry' import { notifyReposChanged } from './repos-changed-notification' @@ -68,31 +66,21 @@ export function registerNestedRepoImportHandler(mainWindow: BrowserWindow, store for (const [projectGroupOrder, repoPath] of selection.selectedPaths.entries()) { try { - let importRepoPath = repoPath + // Why: an SSH target is not served here; never inspect its path with local git. if (args.connectionId) { - const gitProvider = getSshGitProvider(args.connectionId) - const check = gitProvider ? await gitProvider.isGitRepoAsync(repoPath) : null - if (!gitProvider || !check?.isRepo) { - results.push({ - path: repoPath, - status: 'failed', - error: 'Not a valid git repository' - }) - continue - } - importRepoPath = await importTargetResolver.resolveSsh(repoPath, gitProvider) - } else { - await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath }) - if (!(await isGitRepoAsync(repoPath))) { - results.push({ - path: repoPath, - status: 'failed', - error: 'Not a valid git repository' - }) - continue - } - importRepoPath = await importTargetResolver.resolveLocal(repoPath) + results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' }) + continue } + await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath }) + if (!(await isGitRepoAsync(repoPath))) { + results.push({ + path: repoPath, + status: 'failed', + error: 'Not a valid git repository' + }) + continue + } + const importRepoPath = await importTargetResolver.resolveLocal(repoPath) const normalizedImportRepoPath = normalizeRuntimePathForComparison(importRepoPath) const alreadyImportedProjectId = importedProjectIdsByRepoPath.get(normalizedImportRepoPath) @@ -123,7 +111,7 @@ export function registerNestedRepoImportHandler(mainWindow: BrowserWindow, store const detected = await detectRepoIconAndUpstream({ repoPath: importRepoPath, kind: 'git', - executionHostId: getConnectionExecutionHostId(args.connectionId) + executionHostId: LOCAL_EXECUTION_HOST_ID }) const repo: Repo = { id: randomUUID(), @@ -133,7 +121,6 @@ export function registerNestedRepoImportHandler(mainWindow: BrowserWindow, store ...detected, addedAt: Date.now(), kind: 'git', - ...(args.connectionId ? { connectionId: args.connectionId } : {}), externalWorktreeVisibilityLegacy: false, projectHostSetupMethod: 'imported-existing-folder', ...(group @@ -145,11 +132,6 @@ export function registerNestedRepoImportHandler(mainWindow: BrowserWindow, store } store.addRepo(repo) await prepareLocalWorktreeRootForRepo(store, repo) - if (args.connectionId) { - getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', { - rootPath: importRepoPath - }) - } importedProjectIdsByRepoPath.set(normalizedImportRepoPath, repo.id) results.push({ path: repoPath, projectId: repo.id, status: 'imported' }) // Why: reaches here only after the isGitRepoAsync guard above confirmed a git repo, so always true. diff --git a/src/main/ipc/repos/nested-repo-scan-ipc.ts b/src/main/ipc/repos/nested-repo-scan-ipc.ts index 1c6fe8fa7ed..579c10e11d6 100644 --- a/src/main/ipc/repos/nested-repo-scan-ipc.ts +++ b/src/main/ipc/repos/nested-repo-scan-ipc.ts @@ -1,13 +1,10 @@ import type { IpcMainInvokeEvent } from 'electron' -import { isAbsolute, posix } from 'node:path' +import { isAbsolute } from 'node:path' import type { z } from 'zod' import type { NestedRepoScanResult } from '../../../shared/project-group-types' import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' import { awaitWindowsHostGitEnvironmentReady } from '../../git/runner' import { scanNestedRepos } from '../../project-groups/nested-repo-discovery' -import { getSshGitProvider } from '../../providers/ssh-git-dispatch' -import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' -import { getActiveMultiplexer } from '../ssh' import type { ProjectGroupScanNestedArgs } from './repo-ipc-arg-schemas' export const activeNestedRepoScans = new Map() @@ -72,23 +69,6 @@ export function getCompletedNestedRepoScan(args: { return completed.scan } -async function resolveSshProjectGroupPath(connectionId: string, path: string): Promise { - if (path === '~' || path === '~/' || path.startsWith('~/')) { - const mux = getActiveMultiplexer(connectionId) - if (mux) { - try { - const result = (await mux.request('session.resolveHome', { path })) as { - resolvedPath: string - } - return result.resolvedPath - } catch { - return path - } - } - } - return path -} - export async function scanNestedReposForIpc(args: { path: string connectionId?: string @@ -109,52 +89,8 @@ export async function scanNestedReposForIpc(args: { onProgress: args.onProgress }) } - const gitProvider = getSshGitProvider(args.connectionId) - const fsProvider = getSshFilesystemProvider(args.connectionId) - if (!gitProvider || !fsProvider) { - throw new Error('ssh_connection_unavailable') - } - const resolvedPath = await resolveSshProjectGroupPath(args.connectionId, args.path) - return scanNestedRepos({ - path: resolvedPath, - options: args.options, - signal: args.signal, - onProgress: args.onProgress, - filesystem: { - readDirectory: async (dirPath) => - (await fsProvider.readDir(dirPath)).map((entry) => ({ - name: entry.name, - isDirectory: entry.isDirectory, - isSymlink: entry.isSymlink - })), - readTextFile: async (filePath) => (await fsProvider.readFile(filePath)).content, - joinPath: (parentPath, childName) => posix.join(parentPath, childName), - basename: (path) => posix.basename(path), - hasGitMarker: async (path) => { - try { - const marker = await fsProvider.stat(posix.join(path, '.git')) - if (marker.type === 'directory' || marker.type === 'file') { - return true - } - } catch { - // Continue to cheap bare-repository marker checks below. - } - const [head, objects, refs] = await Promise.all([ - fsProvider.stat(posix.join(path, 'HEAD')).catch(() => null), - fsProvider.stat(posix.join(path, 'objects')).catch(() => null), - fsProvider.stat(posix.join(path, 'refs')).catch(() => null) - ]) - return head?.type === 'file' && objects?.type === 'directory' && refs?.type === 'directory' - }, - isSelectedPathGitRepo: async (path) => { - try { - return (await gitProvider.isGitRepoAsync(path)).isRepo - } catch { - return false - } - } - } - }) + // Why: this process serves no SSH target, so a remote scan has nothing to read. + throw new Error('ssh_connection_unavailable') } export async function runNestedRepoScanForIpc( diff --git a/src/main/ipc/repos/project-host-setup-handlers.ts b/src/main/ipc/repos/project-host-setup-handlers.ts index 8898d5ec07a..79d7976ecec 100644 --- a/src/main/ipc/repos/project-host-setup-handlers.ts +++ b/src/main/ipc/repos/project-host-setup-handlers.ts @@ -27,7 +27,7 @@ import { parseProjectGroupIpcArgs } from './repo-ipc-arg-schemas' import { addLocalRepoFromPath } from './local-repo-registration' -import { addRemoteRepoFromPath } from './remote-repo-registration' +import { sshTargetNotConnectedMessage } from '../../providers/ssh-host-not-served-error' function buildProjectHostSetupResult(store: Store, repo: Repo): ProjectHostSetupResult { const setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo) @@ -152,12 +152,7 @@ export function registerProjectHostSetupHandlers(mainWindow: BrowserWindow, stor parsedHost.kind === 'local' ? await addLocalRepoFromPath(store, args.path, args.kind) : parsedHost.kind === 'ssh' - ? await addRemoteRepoFromPath(store, { - connectionId: parsedHost.targetId, - remotePath: args.path, - displayName: args.displayName, - kind: args.kind - }) + ? { error: sshTargetNotConnectedMessage(parsedHost.targetId) } : { error: 'Runtime hosts must be set up through the runtime projectHostSetup.setupExistingFolder RPC.' diff --git a/src/main/ipc/repos/remote-home-path.ts b/src/main/ipc/repos/remote-home-path.ts deleted file mode 100644 index 602eed25684..00000000000 --- a/src/main/ipc/repos/remote-home-path.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { getActiveMultiplexer } from '../../ssh/ssh-target-registry' - -export async function resolveRemoteHomePath(connectionId: string, path: string): Promise { - if (path !== '~' && path !== '~/' && !path.startsWith('~/')) { - return path - } - const mux = getActiveMultiplexer(connectionId) - if (!mux) { - return path - } - try { - const result = (await mux.request('session.resolveHome', { path })) as { resolvedPath: string } - return result.resolvedPath - } catch { - // Why: older relays may not support this; return the original path so callers surface their own validation error. - return path - } -} diff --git a/src/main/ipc/repos/remote-repo-clone.ts b/src/main/ipc/repos/remote-repo-clone.ts deleted file mode 100644 index 8234a65d277..00000000000 --- a/src/main/ipc/repos/remote-repo-clone.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { BrowserWindow } from 'electron' -import type { Store } from '../../persistence' -import type { Repo } from '../../../shared/repo-types' -import { isFolderRepo } from '../../../shared/repo-kind' -import { - isRuntimePathAbsolute, - normalizeRuntimePathForComparison, - relativePathInsideRoot -} from '../../../shared/cross-platform-path' -import { getGitCloneFailureMessage } from '../../../shared/git-clone-failure-message' -import { deriveCloneRepoNameFromUrl } from '../../git/repo-clone-path' -import { reuseSavedCloneTarget } from '../../git/saved-clone-target' -import { getRepoSshConnectionId, toSshExecutionHostId } from '../../../shared/execution-host' -import { getSshGitProvider } from '../../providers/ssh-git-dispatch' -import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' -import { joinRemotePath } from '../../ssh/ssh-remote-platform' -import { getActiveMultiplexer } from '../ssh' -import { emitRepoAdded } from './repo-added-telemetry' -import { addRemoteRepoFromPath } from './remote-repo-registration' -import { resolveRemoteHomePath } from './remote-home-path' - -type ActiveRemoteCloneMetadata = { - connectionId: string - clonePath: string - controller: AbortController -} - -let activeRemoteClone: ActiveRemoteCloneMetadata | null = null -const remoteCloneInFlightByPath = new Set() - -export async function cloneRemoteRepo( - store: Store, - mainWindow: BrowserWindow, - args: { - connectionId: string - url: string - destination: string - } -): Promise { - const gitProvider = getSshGitProvider(args.connectionId) - if (!gitProvider) { - throw new Error(`SSH connection "${args.connectionId}" not found or not connected`) - } - const fsProvider = getSshFilesystemProvider(args.connectionId) - if (!fsProvider) { - throw new Error(`SSH connection "${args.connectionId}" not found or not connected`) - } - const host = gitProvider.getHostPlatform?.() - if (!host) { - throw new Error('SSH host platform is unavailable. Reconnect the SSH target before cloning.') - } - const trimmedDestination = await resolveRemoteHomePath(args.connectionId, args.destination.trim()) - if (!isRuntimePathAbsolute(trimmedDestination, host.pathFlavor)) { - throw new Error('Clone destination must be an absolute path on the SSH host') - } - const repoName = deriveCloneRepoNameFromUrl(args.url.trim()) - const clonePath = joinRemotePath(host, trimmedDestination, repoName) - if (relativePathInsideRoot(trimmedDestination, clonePath) === null) { - throw new Error('Clone path must be inside the destination directory') - } - const clonePathKey = normalizeRuntimePathForComparison(clonePath) - const findSaved = (): Repo | undefined => - store.getRepos().find((repo) => { - return ( - getRepoSshConnectionId(repo) === args.connectionId && - normalizeRuntimePathForComparison(repo.path) === clonePathKey - ) - }) - - const remoteCloneKey = `${args.connectionId}:${clonePathKey}` - if (remoteCloneInFlightByPath.has(remoteCloneKey)) { - throw new Error('A clone is already in progress for this SSH destination') - } - const controller = new AbortController() - const metadata: ActiveRemoteCloneMetadata = { - connectionId: args.connectionId, - clonePath, - controller - } - activeRemoteClone = metadata - remoteCloneInFlightByPath.add(remoteCloneKey) - try { - // Why: after the in-flight guard, so a retry never inspects a clone that is still running. - const reused = await reuseSavedCloneTarget( - findSaved, - args.url.trim(), - toSshExecutionHostId(args.connectionId), - controller.signal - ) - if (reused) { - emitRepoAdded('clone_url', true) - return reused - } - // Why: match local clone by creating the parent first, or a fresh remote parent surfaces as spawn ENOENT. - await fsProvider.createDir(trimmedDestination) - // Why: the SSH relay runs git argv, not a shell; use the repo folder name so git creates it under the chosen parent. - await gitProvider.clone( - ['clone', '--progress', '--', args.url.trim(), repoName], - trimmedDestination, - { - signal: controller.signal, - timeoutMs: 10 * 60_000, - onProgress: (progress) => { - if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('repos:clone-progress', progress) - } - } - } - ) - } catch (err) { - if (controller.signal.aborted) { - throw new Error('Clone aborted') - } - const message = err instanceof Error ? err.message : String(err) - if (message.startsWith('Clone failed:')) { - throw new Error(`Clone failed: ${getGitCloneFailureMessage(message, { clonePath })}`) - } - throw err - } finally { - if (activeRemoteClone === metadata) { - activeRemoteClone = null - } - remoteCloneInFlightByPath.delete(remoteCloneKey) - } - const existing = findSaved() - if (existing) { - if (isFolderRepo(existing)) { - const updated = store.updateRepo(existing.id, { - kind: 'git', - projectHostSetupMethod: 'cloned' - }) - if (updated) { - emitRepoAdded('clone_url', false) - getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', { - rootPath: clonePath - }) - return updated - } - } else { - // Why: git re-created this project's folder, and `addRemoteRepoFromPath` returns an existing - // project without registering the root, so the relay would never learn the path came back. - getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', { - rootPath: clonePath - }) - emitRepoAdded('clone_url', true) - return existing - } - } - const result = await addRemoteRepoFromPath(store, { - connectionId: args.connectionId, - remotePath: clonePath, - kind: 'git', - setupMethod: 'cloned' - }) - if ('error' in result) { - throw new Error(result.error) - } - emitRepoAdded('clone_url', result.alreadyExisted) - return result.repo -} - -// Why: `activeRemoteClone` is module-scoped and cannot be reassigned across a module boundary. -export function abortActiveRemoteClone(): void { - if (activeRemoteClone) { - activeRemoteClone.controller.abort() - activeRemoteClone = null - } -} diff --git a/src/main/ipc/repos/remote-repo-creation.ts b/src/main/ipc/repos/remote-repo-creation.ts deleted file mode 100644 index d7a2315fb1e..00000000000 --- a/src/main/ipc/repos/remote-repo-creation.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { Store } from '../../persistence' -import type { Repo } from '../../../shared/repo-types' -import { - isRuntimePathAbsolute, - normalizeRuntimePathForComparison, - relativePathInsideRoot -} from '../../../shared/cross-platform-path' -import { getSshGitProvider } from '../../providers/ssh-git-dispatch' -import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' -import { joinRemotePath } from '../../ssh/ssh-remote-platform' -import { emitRepoAdded } from './repo-added-telemetry' -import { addRemoteRepoFromPath } from './remote-repo-registration' -import { resolveRemoteHomePath } from './remote-home-path' - -export async function createRemoteRepo( - store: Store, - args: { - connectionId: string - parentPath: string - name: string - kind: 'git' | 'folder' - } -): Promise<{ repo: Repo } | { error: string }> { - const name = args.name?.trim() ?? '' - const parentPath = await resolveRemoteHomePath(args.connectionId, args.parentPath?.trim() ?? '') - const repoKind: 'git' | 'folder' = args.kind === 'folder' ? 'folder' : 'git' - if (!name) { - return { error: 'Name cannot be empty' } - } - if (/[\\/]/.test(name) || name === '.' || name === '..') { - return { error: 'Name cannot contain slashes or be "." / ".."' } - } - if (!parentPath) { - return { error: 'Parent directory is required' } - } - const gitProvider = getSshGitProvider(args.connectionId) - const fsProvider = getSshFilesystemProvider(args.connectionId) - if (!gitProvider || !fsProvider) { - return { error: `SSH connection "${args.connectionId}" not found or not connected` } - } - const host = gitProvider.getHostPlatform?.() - if (!host) { - return { error: 'SSH host platform is unavailable. Reconnect the SSH target before creating.' } - } - if (!isRuntimePathAbsolute(parentPath, host.pathFlavor)) { - return { error: 'Parent directory must be an absolute path on the SSH host' } - } - - const targetPath = joinRemotePath(host, parentPath, name) - if (relativePathInsideRoot(parentPath, targetPath) === null) { - return { error: 'Project path must be inside the parent directory' } - } - const targetPathKey = normalizeRuntimePathForComparison(targetPath) - const existing = store.getRepos().find((repo) => { - return ( - repo.connectionId === args.connectionId && - normalizeRuntimePathForComparison(repo.path) === targetPathKey - ) - }) - if (existing) { - emitRepoAdded('folder_picker', true) - return { repo: existing } - } - - let createdDir = false - let targetExists = false - try { - await fsProvider.stat(targetPath) - targetExists = true - } catch { - targetExists = false - } - - if (targetExists) { - try { - const entries = await fsProvider.readDir(targetPath) - if (entries.length > 0) { - return { error: `"${name}" already exists at this location and is not empty.` } - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - return { error: `Failed to read directory: ${message}` } - } - } else { - try { - await fsProvider.createDirNoClobber(targetPath) - createdDir = true - } catch (err) { - const raceWinner = store.getRepos().find((repo) => { - return ( - repo.connectionId === args.connectionId && - normalizeRuntimePathForComparison(repo.path) === targetPathKey - ) - }) - if (raceWinner) { - return { repo: raceWinner } - } - const message = err instanceof Error ? err.message : String(err) - return { error: `Failed to create directory: ${message}` } - } - } - - if (repoKind === 'git') { - let step: 'init' | 'commit' = 'init' - try { - await gitProvider.exec(['init'], targetPath) - step = 'commit' - await gitProvider.exec(['commit', '--allow-empty', '-m', 'Initial commit'], targetPath) - } catch (err) { - if (createdDir) { - await fsProvider.deletePath(targetPath, true).catch(() => undefined) - } else if (step === 'commit') { - await fsProvider - .deletePath(joinRemotePath(host, targetPath, '.git'), true) - .catch(() => undefined) - } - const message = err instanceof Error ? err.message : String(err) - if (step === 'commit' && /Please tell me who you are|user\.name|user\.email/i.test(message)) { - return { - error: - 'Git author identity is not configured on the SSH host. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"` on that host, then try again.' - } - } - const stepLabel = - step === 'init' ? 'Failed to initialize git repository' : 'Failed to create initial commit' - return { error: `${stepLabel}: ${message}` } - } - } - - const raceWinner = store.getRepos().find((repo) => { - return ( - repo.connectionId === args.connectionId && - normalizeRuntimePathForComparison(repo.path) === targetPathKey - ) - }) - if (raceWinner) { - emitRepoAdded('folder_picker', true) - return { repo: raceWinner } - } - - const result = await addRemoteRepoFromPath(store, { - connectionId: args.connectionId, - remotePath: targetPath, - kind: repoKind, - displayName: name - }) - if ('error' in result) { - return result - } - emitRepoAdded('folder_picker', result.alreadyExisted) - return { repo: result.repo } -} diff --git a/src/main/ipc/repos/remote-repo-registration.test.ts b/src/main/ipc/repos/remote-repo-registration.test.ts deleted file mode 100644 index 0a7069b1f63..00000000000 --- a/src/main/ipc/repos/remote-repo-registration.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -// Registration is now the runtime's SSH path too (`projectHostSetup.setupExistingFolder --host -// ssh:*`), so what it stamps decides what every downstream host resolver can read. It minted -// `connectionId`-only rows, leaving the unified spelling permanently empty, and deduped by raw -// `connectionId`, which cannot see a row stamped `executionHostId: 'ssh:*'`. -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../shared/repo-types' - -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock -})) - -vi.mock('../../repo-icon-autodetect', () => ({ - detectRepoIconAndUpstream: vi.fn(async () => ({})) -})) - -vi.mock('../../ssh/ssh-target-registry', () => ({ - getActiveMultiplexer: vi.fn(() => null) -})) - -vi.mock('./remote-home-path', () => ({ - resolveRemoteHomePath: vi.fn(async (_connectionId: string, path: string) => path) -})) - -import { addRemoteRepoFromPath } from './remote-repo-registration' - -function makeStore(repos: Repo[]) { - return { - getRepos: () => repos, - getSshTarget: () => undefined, - addRepo: (repo: Repo) => { - repos.push(repo) - } - } -} - -describe('addRemoteRepoFromPath', () => { - beforeEach(() => { - getSshGitProviderMock.mockReset() - getSshGitProviderMock.mockReturnValue({ - isGitRepoAsync: vi.fn(async () => ({ isRepo: true, rootPath: '/srv/app' })) - }) - }) - - it('stamps the unified execution-host spelling alongside the legacy connection id', async () => { - const repos: Repo[] = [] - const result = await addRemoteRepoFromPath(makeStore(repos) as never, { - connectionId: 'm4air', - remotePath: '/srv/app' - }) - - expect('error' in result).toBe(false) - const repo = (result as { repo: Repo }).repo - expect(repo.connectionId).toBe('m4air') - expect(repo.executionHostId).toBe('ssh:m4air') - }) - - it('dedupes against a row that names the host in the unified spelling only', async () => { - const existing = { - id: 'existing', - path: '/srv/app', - displayName: 'app', - badgeColor: '#000', - addedAt: 0, - executionHostId: 'ssh:m4air' - } as Repo - const repos: Repo[] = [existing] - - const result = await addRemoteRepoFromPath(makeStore(repos) as never, { - connectionId: 'm4air', - remotePath: '/srv/app' - }) - - expect(result).toEqual({ repo: existing, alreadyExisted: true }) - expect(repos).toHaveLength(1) - }) - - it('does not dedupe onto a row on a different SSH host at the same path', async () => { - // Two hosts can both hold /srv/app. Matching on path alone registers one host's repo as the - // other's — the mirror image of the id-only lookup this change removes. - const repos: Repo[] = [ - { - id: 'openclaw-row', - path: '/srv/app', - displayName: 'app', - badgeColor: '#000', - addedAt: 0, - connectionId: 'openclaw' - } as Repo - ] - - const result = await addRemoteRepoFromPath(makeStore(repos) as never, { - connectionId: 'm4air', - remotePath: '/srv/app' - }) - - expect((result as { alreadyExisted: boolean }).alreadyExisted).toBe(false) - expect((result as { repo: Repo }).repo.executionHostId).toBe('ssh:m4air') - expect(repos).toHaveLength(2) - }) - - it('does not dedupe onto a local row that carries a stale connection id', async () => { - // The pullfrog case: a row declaring itself local must not answer as an SSH host. - const repos: Repo[] = [ - { - id: 'local-row', - path: '/srv/app', - displayName: 'app', - badgeColor: '#000', - addedAt: 0, - executionHostId: 'local', - connectionId: 'develop' - } as Repo - ] - - const result = await addRemoteRepoFromPath(makeStore(repos) as never, { - connectionId: 'develop', - remotePath: '/srv/app' - }) - - expect((result as { alreadyExisted: boolean }).alreadyExisted).toBe(false) - expect(repos).toHaveLength(2) - }) -}) diff --git a/src/main/ipc/repos/remote-repo-registration.ts b/src/main/ipc/repos/remote-repo-registration.ts deleted file mode 100644 index ff92f7d0b1b..00000000000 --- a/src/main/ipc/repos/remote-repo-registration.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { randomUUID } from 'node:crypto' -import type { Store } from '../../persistence' -import type { Repo } from '../../../shared/repo-types' -import { DEFAULT_REPO_BADGE_COLOR } from '../../../shared/constants' -import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' -import { getRepoSshConnectionId, toSshExecutionHostId } from '../../../shared/execution-host' -import { getSshGitProvider } from '../../providers/ssh-git-dispatch' -import { detectRepoIconAndUpstream } from '../../repo-icon-autodetect' -import { getActiveMultiplexer } from '../../ssh/ssh-target-registry' -import { resolveRemoteHomePath } from './remote-home-path' - -export async function addRemoteRepoFromPath( - store: Store, - args: { - connectionId: string - remotePath: string - displayName?: string - kind?: 'git' | 'folder' - setupMethod?: Repo['projectHostSetupMethod'] - } -): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> { - const gitProvider = getSshGitProvider(args.connectionId) - if (!gitProvider) { - return { error: `SSH connection "${args.connectionId}" not found or not connected` } - } - - let repoKind: 'git' | 'folder' = args.kind ?? 'git' - let resolvedPath = await resolveRemoteHomePath(args.connectionId, args.remotePath) - - // Resolve the host: a row stamped only `executionHostId: 'ssh:*'` is the same registration, and - // missing it here registers a duplicate repo for a path the host already owns. - const existing = store - .getRepos() - .find( - (repo) => - getRepoSshConnectionId(repo) === args.connectionId && - normalizeRuntimePathForComparison(repo.path) === - normalizeRuntimePathForComparison(resolvedPath) - ) - if (existing) { - return { repo: existing, alreadyExisted: true } - } - - if (args.kind !== 'folder') { - try { - const check = await gitProvider.isGitRepoAsync(resolvedPath) - if (check.isRepo) { - repoKind = 'git' - if (check.rootPath) { - resolvedPath = check.rootPath - } - } else { - return { error: `Not a valid git repository: ${args.remotePath}` } - } - } catch (err) { - if (err instanceof Error && err.message.includes('Not a valid git repository')) { - return { error: err.message } - } - return { error: `Not a valid git repository: ${args.remotePath}` } - } - } - - const existingAfterRootResolve = store - .getRepos() - .find( - (repo) => - getRepoSshConnectionId(repo) === args.connectionId && - normalizeRuntimePathForComparison(repo.path) === - normalizeRuntimePathForComparison(resolvedPath) - ) - if (existingAfterRootResolve) { - return { repo: existingAfterRootResolve, alreadyExisted: true } - } - - const folderName = getRemoteRepoFolderName(resolvedPath) - let displayName = args.displayName || folderName - if (!args.displayName && (args.remotePath === '~' || args.remotePath === '~/')) { - const sshTarget = store.getSshTarget(args.connectionId) - if (sshTarget) { - displayName = sshTarget.label - } - } - - const detected = await detectRepoIconAndUpstream({ - repoPath: resolvedPath, - kind: repoKind, - executionHostId: toSshExecutionHostId(args.connectionId) - }) - const repo: Repo = { - id: randomUUID(), - path: resolvedPath, - displayName, - badgeColor: DEFAULT_REPO_BADGE_COLOR, - ...detected, - addedAt: Date.now(), - kind: repoKind, - connectionId: args.connectionId, - // Stamp the unified spelling at creation: this is now the runtime's SSH registration path too, - // and minting `connectionId`-only rows leaves every host-resolving reader on the legacy field. - executionHostId: toSshExecutionHostId(args.connectionId), - ...(repoKind === 'git' - ? { - externalWorktreeVisibilityLegacy: false, - projectHostSetupMethod: args.setupMethod ?? ('imported-existing-folder' as const) - } - : {}) - } - - store.addRepo(repo) - const mux = getActiveMultiplexer(args.connectionId) - if (mux) { - mux.notify('session.registerRoot', { rootPath: resolvedPath }) - } - - return { repo, alreadyExisted: false } -} - -function getRemoteRepoFolderName(remotePath: string): string { - const trimmed = remotePath.replace(/[\\/]+$/, '') - if (!trimmed) { - return remotePath - } - return trimmed.split(/[\\/]/).at(-1) || remotePath -} diff --git a/src/main/ipc/repos/repo-catalog-handlers.ts b/src/main/ipc/repos/repo-catalog-handlers.ts index 608d9105f19..ddb5d058056 100644 --- a/src/main/ipc/repos/repo-catalog-handlers.ts +++ b/src/main/ipc/repos/repo-catalog-handlers.ts @@ -2,17 +2,12 @@ import type { BrowserWindow } from 'electron' import { ipcMain } from 'electron' import type { Store } from '../../persistence' import type { Project, ProjectUpdateArgs } from '../../../shared/project-types' -import type { - HostRepoCatalogSnapshot, - ListReposForExecutionHostArgs -} from '../../../shared/host-repo-catalog-contract' import { normalizeExecutionHostId } from '../../../shared/execution-host' import { enrichRepoGitUsernames } from '../../repo-git-username-enrichment' import { enrichMissingRepoGitRemoteIdentities } from '../../repo-git-remote-identity-enrichment' import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cache' import { notifyReposChanged } from './repos-changed-notification' import { ProjectUpdateIpcArgs, parseProjectGroupIpcArgs } from './repo-ipc-arg-schemas' -import { listReposForExecutionHost } from './host-repo-catalog-snapshot' import { visibleRepos } from '../../ssh/orcad-retained-source' export function registerRepoCatalogHandlers(mainWindow: BrowserWindow, store: Store): void { @@ -27,12 +22,6 @@ export function registerRepoCatalogHandlers(mainWindow: BrowserWindow, store: St return visibleRepos(store) }) - ipcMain.handle( - 'repos:listForExecutionHost', - (_event, args: ListReposForExecutionHostArgs): Promise => - listReposForExecutionHost(store, args) - ) - ipcMain.handle('projects:list', () => { enrichMissingRepoGitRemoteIdentities(store, { onChanged: broadcastReposChanged }) return store.getProjects() diff --git a/src/main/ipc/repos/repo-clone-lifecycle.ts b/src/main/ipc/repos/repo-clone-lifecycle.ts index bbc8c96f7f3..595122e60cb 100644 --- a/src/main/ipc/repos/repo-clone-lifecycle.ts +++ b/src/main/ipc/repos/repo-clone-lifecycle.ts @@ -25,7 +25,6 @@ import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cac import { emitRepoAdded } from './repo-added-telemetry' import { notifyReposChanged } from './repos-changed-notification' import { runWithClonePathLock } from './clone-path-lock' -import { abortActiveRemoteClone, cloneRemoteRepo } from './remote-repo-clone' type ActiveCloneMetadata = { path: string @@ -111,7 +110,6 @@ export function registerRepoCloneHandlers(mainWindow: BrowserWindow, store: Stor clone.process.kill() activeClone = null } - abortActiveRemoteClone() }) ipcMain.handle( @@ -309,16 +307,4 @@ export function registerRepoCloneHandlers(mainWindow: BrowserWindow, store: Stor }) } ) - - ipcMain.handle( - 'repos:cloneRemote', - async ( - _event, - args: { connectionId: string; url: string; destination: string } - ): Promise => { - const repo = await cloneRemoteRepo(store, mainWindow, args) - notifyReposChanged(mainWindow) - return repo - } - ) } diff --git a/src/main/ipc/repos/repo-creation-git-availability.test.ts b/src/main/ipc/repos/repo-creation-git-availability.test.ts index fb112952470..503da0945f5 100644 --- a/src/main/ipc/repos/repo-creation-git-availability.test.ts +++ b/src/main/ipc/repos/repo-creation-git-availability.test.ts @@ -21,8 +21,6 @@ vi.mock('../registered-worktree-roots-cache', () => ({ vi.mock('./repo-added-telemetry', () => ({ emitRepoAdded: vi.fn() })) vi.mock('./repos-changed-notification', () => ({ notifyReposChanged: vi.fn() })) vi.mock('./local-repo-registration', () => ({ addLocalRepoFromPath: vi.fn() })) -vi.mock('./remote-repo-registration', () => ({ addRemoteRepoFromPath: vi.fn() })) -vi.mock('./remote-repo-creation', () => ({ createRemoteRepo: vi.fn() })) import { probeLocalGitAvailability } from './repo-creation-handlers' diff --git a/src/main/ipc/repos/repo-creation-handlers.ts b/src/main/ipc/repos/repo-creation-handlers.ts index 57bfcf66c79..9c98256697c 100644 --- a/src/main/ipc/repos/repo-creation-handlers.ts +++ b/src/main/ipc/repos/repo-creation-handlers.ts @@ -18,8 +18,6 @@ import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cac import { emitRepoAdded } from './repo-added-telemetry' import { notifyReposChanged } from './repos-changed-notification' import { addLocalRepoFromPath } from './local-repo-registration' -import { addRemoteRepoFromPath } from './remote-repo-registration' -import { createRemoteRepo } from './remote-repo-creation' const GIT_AVAILABILITY_TIMEOUT_MS = 1500 @@ -83,47 +81,6 @@ export function registerRepoCreationHandlers(mainWindow: BrowserWindow, store: S } ) - ipcMain.handle( - 'repos:addRemote', - async ( - _event, - args: { - connectionId: string - remotePath: string - displayName?: string - kind?: 'git' | 'folder' - } - ): Promise<{ repo: Repo } | { error: string }> => { - const result = await addRemoteRepoFromPath(store, args) - if ('error' in result) { - return result - } - notifyReposChanged(mainWindow) - emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git') - return { repo: result.repo } - } - ) - - ipcMain.handle( - 'repos:createRemote', - async ( - _event, - args: { - connectionId: string - parentPath: string - name: string - kind: 'git' | 'folder' - } - ): Promise<{ repo: Repo } | { error: string }> => { - const result = await createRemoteRepo(store, args) - if ('error' in result) { - return result - } - notifyReposChanged(mainWindow) - return result - } - ) - // Create a repo/folder from scratch (orca#763); git repos need an empty initial commit so HEAD has a branch ref for worktrees. ipcMain.handle( 'repos:create', diff --git a/src/main/ipc/repos/repo-git-username-handler.ts b/src/main/ipc/repos/repo-git-username-handler.ts index f96b6e52804..c384489ac70 100644 --- a/src/main/ipc/repos/repo-git-username-handler.ts +++ b/src/main/ipc/repos/repo-git-username-handler.ts @@ -1,8 +1,7 @@ import { ipcMain } from 'electron' import type { Store } from '../../persistence' import { isFolderRepo } from '../../../shared/repo-kind' -import { getSshGitProvider } from '../../providers/ssh-git-dispatch' -import { getSshGitUsername, resolveLocalGitUsername } from '../../git/git-username' +import { resolveLocalGitUsername } from '../../git/git-username' export function registerRepoGitUsernameHandler(store: Store): void { ipcMain.handle('repos:getGitUsername', async (_event, args: { repoId: string }) => { @@ -10,13 +9,9 @@ export function registerRepoGitUsernameHandler(store: Store): void { if (!repo || isFolderRepo(repo)) { return '' } - // Why: remote repos keep their git config on the remote host, so resolve the username there. + // Why: an SSH repo's git config lives on a host this process does not serve. if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - return '' - } - return getSshGitUsername(provider, repo.path) + return '' } return resolveLocalGitUsername(repo.path) }) diff --git a/src/main/ipc/ssh-app-shutdown.test.ts b/src/main/ipc/ssh-app-shutdown.test.ts index d26cd9fdd99..1435a0615d4 100644 --- a/src/main/ipc/ssh-app-shutdown.test.ts +++ b/src/main/ipc/ssh-app-shutdown.test.ts @@ -11,11 +11,7 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) diff --git a/src/main/ipc/ssh-connect-decision-cancellation.test.ts b/src/main/ipc/ssh-connect-decision-cancellation.test.ts index bb99dd2f3fc..13b802f13a2 100644 --- a/src/main/ipc/ssh-connect-decision-cancellation.test.ts +++ b/src/main/ipc/ssh-connect-decision-cancellation.test.ts @@ -11,11 +11,7 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) diff --git a/src/main/ipc/ssh-disconnect-cancellation.test.ts b/src/main/ipc/ssh-disconnect-cancellation.test.ts index 33119e75972..ba666c250af 100644 --- a/src/main/ipc/ssh-disconnect-cancellation.test.ts +++ b/src/main/ipc/ssh-disconnect-cancellation.test.ts @@ -11,11 +11,7 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) diff --git a/src/main/ipc/ssh-handler-reregistration.test.ts b/src/main/ipc/ssh-handler-reregistration.test.ts index 57615fc2f67..9c3203233ff 100644 --- a/src/main/ipc/ssh-handler-reregistration.test.ts +++ b/src/main/ipc/ssh-handler-reregistration.test.ts @@ -11,11 +11,7 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) diff --git a/src/main/ipc/ssh-host-server-on-connect-wiring.test.ts b/src/main/ipc/ssh-host-server-on-connect-wiring.test.ts index 2a7f6acd209..35713ff60f9 100644 --- a/src/main/ipc/ssh-host-server-on-connect-wiring.test.ts +++ b/src/main/ipc/ssh-host-server-on-connect-wiring.test.ts @@ -23,8 +23,7 @@ vi.mock('../../shared/app-environment', () => ({ getAppEnvironment: () => ({ getVersion: () => '1.5.0', getPath: () => '/tmp/unused' }) })) vi.mock('../ssh/ssh-target-registry', () => ({ - getSshTargetRegistryStore: () => mocks.registry.current, - hasRegisteredDirectSshAuthority: () => false + getSshTargetRegistryStore: () => mocks.registry.current })) vi.mock('../ssh/orcad-runtime-deployment', () => ({ createManagedOrcadEnvironment: mocks.deploy })) vi.mock('../ssh/orcad-runtime-conversion', () => ({ diff --git a/src/main/ipc/ssh-host-server-on-connect-wiring.ts b/src/main/ipc/ssh-host-server-on-connect-wiring.ts index bedc61f0751..7d1603a9632 100644 --- a/src/main/ipc/ssh-host-server-on-connect-wiring.ts +++ b/src/main/ipc/ssh-host-server-on-connect-wiring.ts @@ -26,14 +26,10 @@ import { setSshHostServerStatus } from '../ssh/ssh-host-server-status' import { trackSshHostServerEvent } from '../ssh/ssh-host-server-telemetry' import { knownSshHostPlatform } from '../ssh/ssh-host-platform-memo' import { knownOrcadTunnelTransport } from '../ssh/orcad-tunnel-transport-memo' -import { - getSshTargetRegistryStore, - hasRegisteredDirectSshAuthority -} from '../ssh/ssh-target-registry' +import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry' import { releaseUnreachableOrcadSetup } from '../ssh/orcad-unreachable-setup-release' import { getCurrentMainWindow } from './ssh-ipc-context' import { broadcastSshState } from './ssh-renderer-broadcast' -import { disconnectRegisteredSshTarget } from './ssh-session-teardown' import { connectInFlight } from './ssh-connect-attempt-registry' export function hostServerOnConnectDeps(userDataPath: string): HostServerOnConnectDeps { @@ -100,14 +96,8 @@ export function hostServerOnConnectDeps(userDataPath: string): HostServerOnConne // The connect's own census already answered; a conversion before any session reuses it. censusHost: (host) => hostProof ? Promise.resolve(hostProof) : censusHostRelayTerminalsFor(host)(), - destinationFor: orcadMigrationDestinationFor, - // Why guarded: this runs before the connect registers a session, and an unconditional - // disconnect would cancel the very connect attempt that asked for the conversion. - releaseDirectSession: async (targetId) => { - if (hasRegisteredDirectSshAuthority(targetId)) { - await disconnectRegisteredSshTarget(targetId) - } - } + // Why no releaseDirectSession: a disconnect here would cancel the connect that asked for it. + destinationFor: orcadMigrationDestinationFor }), abandonDeploy: async (target) => { const environmentId = diff --git a/src/main/ipc/ssh-ipc-mock-shapes.ts b/src/main/ipc/ssh-ipc-mock-shapes.ts index 3ec62e57b98..8ce34f58b0b 100644 --- a/src/main/ipc/ssh-ipc-mock-shapes.ts +++ b/src/main/ipc/ssh-ipc-mock-shapes.ts @@ -61,9 +61,6 @@ export type SshIpcMockState = { mockSshStore: SshStoreMock mockConnectionManager: SshConnectionManagerMock mockMux: SshChannelMultiplexerMock - mockFsProvider: Record - mockGitProvider: Record - mockRegisterSshGitProvider: Mock mockPortForwardManager: SshPortForwardManagerMock mockPortScannerCallbacks: Map mockListConfigHosts: Mock @@ -82,11 +79,7 @@ export type SshIpcMockModules = { sshConnectionStore: SshIpcMockModule sshConnectionManager: SshIpcMockModule sshChannelMultiplexer: SshIpcMockModule - sshFilesystemProvider: SshIpcMockModule pty: SshIpcMockModule - sshFilesystemDispatch: SshIpcMockModule - sshGitProvider: SshIpcMockModule - sshGitDispatch: SshIpcMockModule sshPortForward: SshIpcMockModule sshPortScanner: SshIpcMockModule } diff --git a/src/main/ipc/ssh-ipc-module-mocks.ts b/src/main/ipc/ssh-ipc-module-mocks.ts index 018a99ea89b..1255cd6e5e5 100644 --- a/src/main/ipc/ssh-ipc-module-mocks.ts +++ b/src/main/ipc/ssh-ipc-module-mocks.ts @@ -46,9 +46,6 @@ export function createSshIpcMocks(): SshIpcMocks { notify: vi.fn(), probeLiveness: vi.fn().mockResolvedValue(false) }, - mockFsProvider: {}, - mockGitProvider: {}, - mockRegisterSshGitProvider: vi.fn(), mockPortForwardManager: { addForward: vi.fn(), updateForward: vi.fn(), @@ -79,9 +76,6 @@ export function createSshIpcMocks(): SshIpcMocks { mockSshStore, mockConnectionManager, mockMux, - mockFsProvider, - mockGitProvider, - mockRegisterSshGitProvider, mockPortForwardManager, mockPortScannerCallbacks, mockListConfigHosts, @@ -156,13 +150,6 @@ export function createSshIpcMocks(): SshIpcMocks { } } }, - sshFilesystemProvider: { - SshFilesystemProvider: class MockSshFilesystemProvider { - constructor() { - return mockFsProvider - } - } - }, pty: { clearPtyOwnershipForConnection: vi.fn(), clearProviderPtyState: vi.fn(), @@ -172,22 +159,6 @@ export function createSshIpcMocks(): SshIpcMocks { isCurrentPtyExit: vi.fn().mockReturnValue(true), isRendererPtyOutputPaused: vi.fn().mockReturnValue(false) }, - sshFilesystemDispatch: { - registerSshFilesystemProvider: vi.fn(), - unregisterSshFilesystemProvider: vi.fn(), - getSshFilesystemProvider: vi.fn() - }, - sshGitProvider: { - SshGitProvider: class MockSshGitProvider { - constructor() { - return mockGitProvider - } - } - }, - sshGitDispatch: { - registerSshGitProvider: mockRegisterSshGitProvider, - unregisterSshGitProvider: vi.fn() - }, sshPortForward: { SshPortForwardManager: class MockPortForwardManager { constructor() { diff --git a/src/main/ipc/ssh-ipc-test-harness.ts b/src/main/ipc/ssh-ipc-test-harness.ts index 13ed1ca3787..ba7f4283b4d 100644 --- a/src/main/ipc/ssh-ipc-test-harness.ts +++ b/src/main/ipc/ssh-ipc-test-harness.ts @@ -36,7 +36,6 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness { mockSshStore, mockConnectionManager, mockMux, - mockRegisterSshGitProvider, mockPortForwardManager, mockPortScannerCallbacks, mockNextConnectionManagers, @@ -118,7 +117,6 @@ export function createSshIpcHarness(mocks: SshIpcMocks): SshIpcHarness { mockMux.onDispose.mockReset().mockReturnValue(() => {}) mockMux.request.mockReset().mockResolvedValue({}) mockMux.probeLiveness.mockReset().mockResolvedValue(false) - mockRegisterSshGitProvider.mockReset() mockPortForwardManager.addForward.mockReset() mockPortForwardManager.updateForward.mockReset() mockPortForwardManager.removeForward.mockReset() diff --git a/src/main/ipc/ssh-state-broadcast-fanout.test.ts b/src/main/ipc/ssh-state-broadcast-fanout.test.ts index 5acca148370..80a9e61fc7b 100644 --- a/src/main/ipc/ssh-state-broadcast-fanout.test.ts +++ b/src/main/ipc/ssh-state-broadcast-fanout.test.ts @@ -11,11 +11,7 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) diff --git a/src/main/ipc/ssh-target-registry.test.ts b/src/main/ipc/ssh-target-registry.test.ts index 835b454550c..726da4520df 100644 --- a/src/main/ipc/ssh-target-registry.test.ts +++ b/src/main/ipc/ssh-target-registry.test.ts @@ -11,11 +11,7 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 8bd739f6c97..33b28c79f3c 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -11,11 +11,7 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) vi.mock('./pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index a3fa33d6f1b..2dcbcc046e8 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -12,17 +12,6 @@ import { setSshTargetRegistryStore } from '../ssh/ssh-target-registry' -// Why re-exported: the registry moved to ../ssh/ssh-target-registry so the runtime can -// read it without pulling ipcMain in, but many existing importers reference these from -// here. Re-exporting keeps them working without a repo-wide rename. -export { - connectRegisteredSshTarget, - getActiveMultiplexer, - getRegisteredSshState, - getSshConnectionManager, - listRegisteredRemovedSshTargetLabels, - listRegisteredSshTargets -} from '../ssh/ssh-target-registry' import { registerSshBrowseHandler } from './ssh-browse' import { registerCredentialHandler } from './ssh-passphrase' import type { OrcaRuntimeService } from '../runtime/orca-runtime' diff --git a/src/main/ipc/workspace-cleanup-activity.test.ts b/src/main/ipc/workspace-cleanup-activity.test.ts index 39b1dbaec20..f790c2fef00 100644 --- a/src/main/ipc/workspace-cleanup-activity.test.ts +++ b/src/main/ipc/workspace-cleanup-activity.test.ts @@ -293,7 +293,7 @@ describe('resolveWorkspaceCleanupActivityWorktree', () => { const statPath = vi.fn(async () => ({ mtimeMs: 20_000 })) const worktree = await resolveWorkspaceCleanupActivityWorktree( - { kind: 'ssh', hostId: 'ssh:ssh-1', connectionId: 'ssh-1', provider: null }, + { kind: 'ssh', hostId: 'ssh:ssh-1', connectionId: 'ssh-1' }, makeWorktree({ createdAt: 10_000 }), statPath ) diff --git a/src/main/ipc/workspace-cleanup-broad-scan.test.ts b/src/main/ipc/workspace-cleanup-broad-scan.test.ts index 66555d216ac..c0dbd18607f 100644 --- a/src/main/ipc/workspace-cleanup-broad-scan.test.ts +++ b/src/main/ipc/workspace-cleanup-broad-scan.test.ts @@ -14,16 +14,14 @@ const { listRepoWorktreesMock, getStatusMock, gitExecFileAsyncMock, - getLocalProjectWorktreeGitOptionsMock, - getSshGitProviderMock + getLocalProjectWorktreeGitOptionsMock } = vi.hoisted(() => ({ lstatMock: vi.fn(), readFileMock: vi.fn(), listRepoWorktreesMock: vi.fn(), getStatusMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), - getLocalProjectWorktreeGitOptionsMock: vi.fn(), - getSshGitProviderMock: vi.fn() + getLocalProjectWorktreeGitOptionsMock: vi.fn() })) vi.mock('node:fs/promises', () => ({ @@ -47,10 +45,6 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock -})) - vi.mock('../project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock })) @@ -150,7 +144,6 @@ describe('workspace cleanup broad scan opt-in', () => { } satisfies GitStatusResult) gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '0\n', stderr: '' }) getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) - getSshGitProviderMock.mockReset().mockReturnValue(undefined) }) afterEach(() => { @@ -191,53 +184,6 @@ describe('workspace cleanup broad scan opt-in', () => { ).toBe('runtime:env-1') }) - it('does not borrow same-id local metadata for a connected SSH cleanup row', async () => { - const sharedPath = '/shared/workspace' - const worktreeId = `${REPO.id}::${sharedPath}` - const localRepo = { ...REPO, path: '/local/repo' } - const sshRepo = { ...REPO, path: '/remote/repo', connectionId: 'ssh-1' } - const sharedWorktree: GitWorktreeInfo = { - path: sharedPath, - head: 'shared123', - branch: 'refs/heads/shared', - isBare: false, - isMainWorktree: false - } - const localMeta = makeWorktreeMeta({ - displayName: 'Local metadata only', - hostId: 'local', - isPinned: true, - lastActivityAt: NOW - 40 * DAY_MS - }) - listRepoWorktreesMock.mockResolvedValue([sharedWorktree]) - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn().mockResolvedValue([sharedWorktree]) - }) - - const result = await scanWorkspaceCleanup( - makeStore([localRepo, sshRepo], { [worktreeId]: localMeta }), - { includeAllWorkspaces: true, skipGitWorktreeIds: [worktreeId] } - ) - - expect(result.candidates).toHaveLength(2) - expect(result.candidates).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - worktreeId, - executionHostId: 'local', - displayName: 'Local metadata only', - blockers: ['pinned'] - }), - expect.objectContaining({ - worktreeId, - executionHostId: 'ssh:ssh-1', - displayName: 'shared', - blockers: [] - }) - ]) - ) - }) - it('reads git for a pinned target during focused preflight scans', async () => { const worktreeId = 'repo-1::/repo-old' const pinnedMeta = makeWorktreeMeta({ @@ -354,47 +300,6 @@ describe('workspace cleanup broad scan opt-in', () => { ]) }) - it('does not publish a same-id local folder instance on an SSH host', async () => { - const localFolderRepo = { ...FOLDER_REPO } - const sshFolderRepo = { ...FOLDER_REPO, connectionId: 'ssh-1' } - const instanceId = `${FOLDER_REPO.id}::${FOLDER_REPO.path}::workspace:11111111-2222-4333-8444-555555555555` - getSshGitProviderMock.mockReturnValue({}) - - const result = await scanWorkspaceCleanup( - makeStore([localFolderRepo, sshFolderRepo], { - [instanceId]: makeWorktreeMeta({ - displayName: 'Local folder session', - hostId: 'local' - }) - }), - { includeAllWorkspaces: true } - ) - - expect(result.candidates).toHaveLength(3) - expect(result.candidates.filter((candidate) => candidate.worktreeId === instanceId)).toEqual([ - expect.objectContaining({ - executionHostId: 'local', - displayName: 'Local folder session' - }) - ]) - }) - - it('does not mark connected SSH folder workspaces as disconnected', async () => { - const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' } - getSshGitProviderMock.mockReturnValue({}) - - const result = await scanWorkspaceCleanup(makeStore([sshFolderRepo]), { - includeAllWorkspaces: true - }) - - expect(result.candidates).toHaveLength(1) - expect(result.candidates[0]).toMatchObject({ - worktreeId: 'repo-folder::/folder-workspace', - connectionId: 'ssh-1', - blockers: ['main-worktree', 'folder-repo'] - }) - }) - it('defers git evidence for rows that no cleanup decision can use', async () => { const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true diff --git a/src/main/ipc/workspace-cleanup-execution-host-routing.test.ts b/src/main/ipc/workspace-cleanup-execution-host-routing.test.ts index 1b193afa2f2..71869b413a3 100644 --- a/src/main/ipc/workspace-cleanup-execution-host-routing.test.ts +++ b/src/main/ipc/workspace-cleanup-execution-host-routing.test.ts @@ -11,7 +11,6 @@ const { listRepoWorktreesMock, getStatusMock, gitExecFileAsyncMock, - getSshGitProviderMock, getLocalProjectWorktreeGitOptionsMock } = vi.hoisted(() => ({ lstatMock: vi.fn(), @@ -20,7 +19,6 @@ const { listRepoWorktreesMock: vi.fn(), getStatusMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn(), getLocalProjectWorktreeGitOptionsMock: vi.fn() })) @@ -38,11 +36,6 @@ vi.mock('../repo-worktrees', () => ({ vi.mock('../git/status', () => ({ getStatus: getStatusMock })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH git provider unavailable' -})) - vi.mock('../project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock })) @@ -61,32 +54,6 @@ const CLEAN_STATUS: GitStatusResult = { upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } } -/** - * Two SSH targets are registered at once for every case: routing that answers with "some - * connected host" instead of *this row's* host only shows up when a second one exists. - */ -const sshProviders = new Map>() - -function makeSshGitProvider(): { - listWorktrees: ReturnType - getStatus: ReturnType - exec: ReturnType -} { - return { - listWorktrees: vi.fn(async () => [ - { - path: WORKTREE_PATH, - head: 'abc123', - branch: 'refs/heads/feature', - isBare: false, - isMainWorktree: false - } - ]), - getStatus: vi.fn(async () => CLEAN_STATUS), - exec: vi.fn(async () => ({ stdout: '0\n', stderr: '' })) - } -} - function makeRepo(overrides: Partial = {}): Repo { return { id: REPO_ID, @@ -127,9 +94,6 @@ describe('workspace cleanup execution-host routing', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(NOW) - sshProviders.clear() - sshProviders.set('alpha', makeSshGitProvider()) - sshProviders.set('beta', makeSshGitProvider()) lstatMock.mockReset().mockResolvedValue({ mtimeMs: 0 }) readFileMock.mockReset().mockRejectedValue(new Error('not a gitdir pointer')) openMock.mockReset().mockRejectedValue(new Error('no reflog')) @@ -145,49 +109,20 @@ describe('workspace cleanup execution-host routing', () => { getStatusMock.mockReset().mockResolvedValue(CLEAN_STATUS) gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '0\n', stderr: '' }) getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) - getSshGitProviderMock.mockReset().mockImplementation((id: string) => sshProviders.get(id)) }) afterEach(() => { vi.useRealTimers() }) - it('lists an executionHostId-only SSH repo through its own host, never locally', async () => { + it('never lists or reads an executionHostId-only SSH repo on this machine', async () => { const store = makeStore([makeRepo({ executionHostId: 'ssh:alpha' })], { [WORKTREE_ID]: makeMeta() }) - const result = await scanWorkspaceCleanup(store) - - expect(sshProviders.get('alpha')?.listWorktrees).toHaveBeenCalledWith('/remote/repo', { - signal: expect.any(AbortSignal) - }) - expect(sshProviders.get('beta')?.listWorktrees).not.toHaveBeenCalled() - expect(listRepoWorktreesMock).not.toHaveBeenCalled() - expect(result.candidates).toHaveLength(1) - expect(result.candidates[0]?.executionHostId).toBe('ssh:alpha') - }) - - it('reads git status and unpushed commits on the row-named host, not this machine', async () => { - const store = makeStore([makeRepo({ executionHostId: 'ssh:alpha' })], { - [WORKTREE_ID]: makeMeta() - }) - getStatusMock.mockResolvedValue({ - ...CLEAN_STATUS, - upstreamStatus: { hasUpstream: false } - } as GitStatusResult) - sshProviders.get('alpha')?.getStatus.mockResolvedValue({ - ...CLEAN_STATUS, - upstreamStatus: { hasUpstream: false } - }) - await scanWorkspaceCleanup(store) - expect(sshProviders.get('alpha')?.getStatus).toHaveBeenCalledWith(WORKTREE_PATH, { - includeLineStats: false, - signal: expect.any(AbortSignal) - }) - expect(sshProviders.get('alpha')?.exec).toHaveBeenCalled() + expect(listRepoWorktreesMock).not.toHaveBeenCalled() expect(getStatusMock).not.toHaveBeenCalled() expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -202,27 +137,6 @@ describe('workspace cleanup execution-host routing', () => { expect(lstatMock).not.toHaveBeenCalled() }) - it('keeps two simultaneously registered SSH hosts apart', async () => { - const store = makeStore( - [ - makeRepo({ id: 'repo-alpha', executionHostId: 'ssh:alpha' }), - makeRepo({ id: 'repo-beta', connectionId: 'beta' }) - ], - { - [`repo-alpha::${WORKTREE_PATH}`]: makeMeta(), - [`repo-beta::${WORKTREE_PATH}`]: makeMeta() - } - ) - - await scanWorkspaceCleanup(store) - - expect(sshProviders.get('alpha')?.listWorktrees).toHaveBeenCalledTimes(1) - expect(sshProviders.get('beta')?.listWorktrees).toHaveBeenCalledTimes(1) - expect(sshProviders.get('alpha')?.getStatus).toHaveBeenCalledTimes(1) - expect(sshProviders.get('beta')?.getStatus).toHaveBeenCalledTimes(1) - expect(listRepoWorktreesMock).not.toHaveBeenCalled() - }) - it('refuses a runtime host that carries no nested SSH target instead of scanning locally', async () => { const store = makeStore([makeRepo({ executionHostId: 'runtime:env-a' })], { [WORKTREE_ID]: makeMeta() @@ -237,7 +151,7 @@ describe('workspace cleanup execution-host routing', () => { expect(result.errors).toEqual([]) }) - it('refuses a runtime host whose nested SSH target name is also registered on this client', async () => { + it('refuses a runtime host that names a nested SSH target', async () => { const store = makeStore( [makeRepo({ executionHostId: 'runtime:env-a', connectionId: 'alpha' })], { [WORKTREE_ID]: makeMeta() } @@ -245,11 +159,7 @@ describe('workspace cleanup execution-host routing', () => { const result = await scanWorkspaceCleanup(store) - // 'alpha' names this client's own host; the runtime row's nested target lives in - // env-a's namespace, so dialing it here would inspect an entirely different machine. - expect(sshProviders.get('alpha')?.listWorktrees).not.toHaveBeenCalled() - expect(sshProviders.get('alpha')?.getStatus).not.toHaveBeenCalled() - expect(sshProviders.get('beta')?.listWorktrees).not.toHaveBeenCalled() + // The runtime row's nested target lives in env-a's namespace, never this client's. expect(listRepoWorktreesMock).not.toHaveBeenCalled() expect(result.candidates).toEqual([]) }) @@ -262,13 +172,11 @@ describe('workspace cleanup execution-host routing', () => { const result = await scanWorkspaceCleanup(store, { worktreeIds: [WORKTREE_ID] }) - expect(sshProviders.get('alpha')?.listWorktrees).not.toHaveBeenCalled() expect(result.errors).toHaveLength(1) expect(result.errors[0]?.executionHostId).toBe('runtime:env-a') }) it('synthesizes disconnected rows for an executionHostId-only SSH host that is not connected', async () => { - sshProviders.delete('alpha') const store = makeStore([makeRepo({ executionHostId: 'ssh:alpha' })], { [WORKTREE_ID]: makeMeta({ hostId: 'ssh:alpha' }) }) @@ -292,7 +200,6 @@ describe('workspace cleanup execution-host routing', () => { expect(listRepoWorktreesMock).toHaveBeenCalled() expect(getStatusMock).not.toHaveBeenCalled() - expect(sshProviders.get('alpha')?.getStatus).not.toHaveBeenCalled() expect(result.candidates).toHaveLength(1) expect(result.candidates[0]?.blockers).toContain('git-status-error') expect(result.candidates[0]?.executionHostId).toBe('ssh:alpha') @@ -307,7 +214,6 @@ describe('workspace cleanup execution-host routing', () => { expect(listRepoWorktreesMock).toHaveBeenCalled() expect(getStatusMock).toHaveBeenCalled() - expect(sshProviders.get('alpha')?.getStatus).not.toHaveBeenCalled() expect(result.candidates[0]?.executionHostId).toBe('local') }) }) diff --git a/src/main/ipc/workspace-cleanup-git-evidence.ts b/src/main/ipc/workspace-cleanup-git-evidence.ts index b57b6e99975..128332424d5 100644 --- a/src/main/ipc/workspace-cleanup-git-evidence.ts +++ b/src/main/ipc/workspace-cleanup-git-evidence.ts @@ -10,12 +10,9 @@ import { withWorkspaceCleanupTimeout } from './workspace-cleanup-scan-primitives' import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' -import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-git-dispatch' -import type { SshGitProvider } from '../providers/ssh-git-provider' import { resolveWorkspaceCleanupWorktreeGitRoute, - type WorkspaceCleanupGitRoute, - type WorkspaceCleanupWorktreeGitRoute + type WorkspaceCleanupGitRoute } from './workspace-cleanup-git-route' export type WorkspaceCleanupGitEvidence = { @@ -53,22 +50,23 @@ export async function readWorkspaceCleanupGitEvidence( ) return { ...createEmptyWorkspaceCleanupGitEvidence(), blockers: ['git-status-error'] } } - // Shared links are this machine's symlink layout; no remote checkout inherits it. - const sharedLinkPaths = route.kind === 'ssh' ? [] : getWorktreeSharedLinkPaths(repo) + // An unreachable remote host is an error, never a licence to read this machine's checkout. + if (route.kind === 'ssh') { + return { + ...createEmptyWorkspaceCleanupGitEvidence(), + blockers: ['git-status-error'] + } + } + const sharedLinkPaths = getWorktreeSharedLinkPaths(repo) try { status = await withWorkspaceCleanupTimeout( (signal) => - route.kind === 'ssh' - ? requireWorkspaceCleanupGitProvider(route).getStatus(worktree.path, { - includeLineStats: false, - signal - }) - : getStatus(worktree.path, { - includeLineStats: false, - signal, - ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) - }), + getStatus(worktree.path, { + includeLineStats: false, + signal, + ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) + }), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, 'Timed out reading git status.', signal @@ -101,7 +99,7 @@ export async function readWorkspaceCleanupGitEvidence( blockers.push('unpushed-commits') } if (clean && upstreamAhead === null) { - const unpushedCommitCount = await readUnpushedCommitCount(worktree, route, signal) + const unpushedCommitCount = await readUnpushedCommitCount(worktree, signal) if (unpushedCommitCount === null) { blockers.push('unknown-base') } else if (unpushedCommitCount > 0) { @@ -120,22 +118,15 @@ export async function readWorkspaceCleanupGitEvidence( async function readUnpushedCommitCount( worktree: Worktree, - route: Exclude, signal?: AbortSignal ): Promise { try { const result = await withWorkspaceCleanupTimeout( (signal) => - route.kind === 'ssh' - ? requireWorkspaceCleanupGitProvider(route).exec( - ['rev-list', '--count', 'HEAD', '--not', '--remotes'], - worktree.path, - { signal } - ) - : gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], { - cwd: worktree.path, - signal - }), + gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], { + cwd: worktree.path, + signal + }), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, 'Timed out checking unpushed commits.', signal @@ -150,16 +141,6 @@ async function readUnpushedCommitCount( } } -/** An unreachable remote host is an error, never a licence to read this machine's checkout. */ -function requireWorkspaceCleanupGitProvider( - route: Extract -): SshGitProvider { - if (!route.provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return route.provider -} - function uniqueWorkspaceCleanupGitBlockers( blockers: WorkspaceCleanupBlocker[] ): WorkspaceCleanupBlocker[] { diff --git a/src/main/ipc/workspace-cleanup-git-route.ts b/src/main/ipc/workspace-cleanup-git-route.ts index 5dd1344335b..b90bcf24e2d 100644 --- a/src/main/ipc/workspace-cleanup-git-route.ts +++ b/src/main/ipc/workspace-cleanup-git-route.ts @@ -27,13 +27,11 @@ import { ExecutionHostNotDispatchableError, resolveGitRouteForHost } from '../providers/execution-host-provider-dispatch' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' -import type { SshGitProvider } from '../providers/ssh-git-provider' export type WorkspaceCleanupGitRoute = | { kind: 'local'; hostId: typeof LOCAL_EXECUTION_HOST_ID } - /** `provider: null` is "remote and currently unreachable" — never "read it here". */ - | { kind: 'ssh'; hostId: `ssh:${string}`; connectionId: string; provider: SshGitProvider | null } + /** This process serves no SSH git, so an `ssh` route is always unreachable — never "read it here". */ + | { kind: 'ssh'; hostId: `ssh:${string}`; connectionId: string } /** * The workspace's own host is the authority, and it can disagree with the row that produced the @@ -57,8 +55,7 @@ export function resolveWorkspaceCleanupRepoGitRoute( return { kind: 'ssh', hostId: route.hostId, - connectionId: route.connectionId, - provider: getSshGitProvider(route.connectionId) ?? null + connectionId: route.connectionId } case 'runtime': throw new ExecutionHostNotDispatchableError(route.hostId) diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index 2ef49bb7966..6c07d75ba71 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -162,7 +162,7 @@ async function scanRepoWorkspaces( }) } - if (route.kind === 'ssh' && !route.provider) { + if (route.kind === 'ssh') { // Why: a disconnected host still owns real workspaces; the full list shows // them (blocked), while legacy scans keep omitting what they cannot inspect. const candidates = diff --git a/src/main/ipc/workspace-cleanup-worktree-listing.ts b/src/main/ipc/workspace-cleanup-worktree-listing.ts index 27ad173d3b5..0ab98f7d814 100644 --- a/src/main/ipc/workspace-cleanup-worktree-listing.ts +++ b/src/main/ipc/workspace-cleanup-worktree-listing.ts @@ -31,20 +31,8 @@ export async function listCleanupGitWorktrees( return { route, gitWorktrees: [createFolderWorktree(repo)] } } if (route.kind === 'ssh') { - if (!route.provider) { - // Why: cleanup should reflect only workspaces Orca can currently inspect. - return { route, gitWorktrees: [] } - } - const provider = route.provider - return { - route, - gitWorktrees: await withWorkspaceCleanupTimeout( - (signal) => provider.listWorktrees(repo.path, { signal }), - WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, - 'Timed out listing SSH worktrees.', - signal - ) - } + // Why: cleanup should reflect only workspaces Orca can currently inspect. + return { route, gitWorktrees: [] } } const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) return { diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index 92c61e11067..3a35c574653 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -16,7 +16,6 @@ const { getStatusMock, gitExecFileAsyncMock, getLocalProjectWorktreeGitOptionsMock, - getSshGitProviderMock, listRegisteredPtysMock } = vi.hoisted(() => ({ lstatMock: vi.fn(), @@ -25,7 +24,6 @@ const { getStatusMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), getLocalProjectWorktreeGitOptionsMock: vi.fn(), - getSshGitProviderMock: vi.fn(), listRegisteredPtysMock: vi.fn() })) @@ -54,11 +52,6 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH git provider unavailable' -})) - vi.mock('../project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock })) @@ -163,7 +156,6 @@ describe('workspace cleanup scan', () => { getStatusMock.mockReset() gitExecFileAsyncMock.mockReset() getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) - getSshGitProviderMock.mockReset() listRegisteredPtysMock.mockReset() listRegisteredPtysMock.mockReturnValue([]) lstatMock.mockResolvedValue({ mtimeMs: 0 }) @@ -185,7 +177,6 @@ describe('workspace cleanup scan', () => { upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } } satisfies GitStatusResult) gitExecFileAsyncMock.mockResolvedValue({ stdout: '0\n', stderr: '' }) - getSshGitProviderMock.mockReturnValue(undefined) }) afterEach(() => { @@ -427,67 +418,6 @@ describe('workspace cleanup scan', () => { expect(lstatMock).toHaveBeenCalledWith(path.join('/repo-feature-b', '.git')) }) - it('scans connected remote workspaces through the SSH git provider', async () => { - const provider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/repo-feature', - head: 'abc123', - branch: 'refs/heads/feature', - isBare: false, - isMainWorktree: false - } - ]), - getStatus: vi.fn().mockResolvedValue({ - entries: [], - conflictOperation: 'unknown', - upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 } - } satisfies GitStatusResult) - } - getSshGitProviderMock.mockReturnValue(provider) - - const result = await scanWorkspaceCleanup( - makeStore({ - repos: [{ ...REPO, connectionId: 'ssh-1' }] - }) - ) - - expect(provider.listWorktrees).toHaveBeenCalledWith('/repo', { - signal: expect.any(AbortSignal) - }) - expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature', { - includeLineStats: false, - signal: expect.any(AbortSignal) - }) - expect(result.errors).toEqual([]) - expect(result.candidates[0]).toMatchObject({ - connectionId: 'ssh-1', - tier: 'ready', - selectedByDefault: true, - reasons: ['idle-clean'] - }) - }) - - it('skips connected remote workspaces that fail during broad scans', async () => { - const provider = { - listWorktrees: vi.fn().mockRejectedValue(new Error('ssh timeout')), - getStatus: vi.fn() - } - getSshGitProviderMock.mockReturnValue(provider) - - const result = await scanWorkspaceCleanup( - makeStore({ - repos: [{ ...REPO, connectionId: 'ssh-1' }] - }) - ) - - expect(provider.listWorktrees).toHaveBeenCalledWith('/repo', { - signal: expect.any(AbortSignal) - }) - expect(result.errors).toEqual([]) - expect(result.candidates).toEqual([]) - }) - it('filters out recent workspaces before running git status', async () => { const result = await scanWorkspaceCleanup( makeStore({ diff --git a/src/main/ipc/worktree-create-lineage.test.ts b/src/main/ipc/worktree-create-lineage.test.ts index fa7e101ac3f..46c6e4d2a63 100644 --- a/src/main/ipc/worktree-create-lineage.test.ts +++ b/src/main/ipc/worktree-create-lineage.test.ts @@ -150,7 +150,6 @@ describe('recordWorkspaceLineageForCreatedWorktree', () => { expect(store.setWorktreeLineage).not.toHaveBeenCalled() expect(result.lineage).toBeNull() - // A persisted cross-host row makes filterLineageForHost return null for the entire host. expect(store.setWorkspaceLineage).not.toHaveBeenCalled() expect(result.workspaceLineage).toBeNull() } diff --git a/src/main/ipc/worktree-git-status-ref-watch.ts b/src/main/ipc/worktree-git-status-ref-watch.ts index 84e35748ae7..0c79b4a39a7 100644 --- a/src/main/ipc/worktree-git-status-ref-watch.ts +++ b/src/main/ipc/worktree-git-status-ref-watch.ts @@ -35,7 +35,6 @@ export type GitStatusRefBindingRequest = { worktreePath: string executionHostId: string connectionId?: string - providerGeneration?: number branch?: string upstreamName?: string } @@ -115,7 +114,6 @@ function resolutionKey(args: GitStatusRefBindingRequest): string { return [ args.executionHostId, args.connectionId ?? '', - args.providerGeneration ?? 0, args.worktreeId, args.worktreePath, args.branch, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 2a78b770e36..db38fec58da 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -285,9 +285,7 @@ export function recordWorkspaceLineageForCreatedWorktree( } } - // Why: persisting a workspace row for an out-of-boundary worktree parent poisons the whole host — - // `filterLineageForHost` returns null for any owned row whose child and parent hosts differ, so - // every nesting on that host stops hydrating, and the row survives restarts. + // Why: a workspace row for an out-of-boundary worktree parent is a cross-host link that survives restarts. if (parentOutsideLineageBoundary) { return NO_CREATED_WORKTREE_LINEAGE } diff --git a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts index d23efd2fb2b..7a30fa7365b 100644 --- a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts +++ b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts @@ -44,19 +44,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-background-removal.test.ts b/src/main/ipc/worktrees-background-removal.test.ts index ead826e7d62..f5ff1550238 100644 --- a/src/main/ipc/worktrees-background-removal.test.ts +++ b/src/main/ipc/worktrees-background-removal.test.ts @@ -37,19 +37,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-create-event-timing.test.ts b/src/main/ipc/worktrees-create-event-timing.test.ts index 44d345e9724..4fe00b7c062 100644 --- a/src/main/ipc/worktrees-create-event-timing.test.ts +++ b/src/main/ipc/worktrees-create-event-timing.test.ts @@ -37,19 +37,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index e0e7b6cc0e4..b77c0b521ee 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -36,7 +36,6 @@ const WORKTREE_HANDLER_CHANNELS = [ 'worktrees:forceDeletePreservedBranch', 'worktrees:updateMeta', 'worktrees:listLineage', - 'worktrees:listLineageForHost', 'worktrees:updateLineage', 'worktrees:persistSortOrder', 'worktrees:getBranchRenameFailureOutput', @@ -70,19 +69,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-delete-pty-teardown.test.ts b/src/main/ipc/worktrees-delete-pty-teardown.test.ts index aee10f0f637..7b9d2c90c51 100644 --- a/src/main/ipc/worktrees-delete-pty-teardown.test.ts +++ b/src/main/ipc/worktrees-delete-pty-teardown.test.ts @@ -36,19 +36,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-detected-scan-cache.test.ts b/src/main/ipc/worktrees-detected-scan-cache.test.ts index b8dec3859d8..239805ddfd3 100644 --- a/src/main/ipc/worktrees-detected-scan-cache.test.ts +++ b/src/main/ipc/worktrees-detected-scan-cache.test.ts @@ -31,19 +31,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts b/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts index 2f8bedc0970..4a4683533db 100644 --- a/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts +++ b/src/main/ipc/worktrees-discovery-metadata-backfill.test.ts @@ -26,19 +26,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-existing-branch-checkout.test.ts b/src/main/ipc/worktrees-existing-branch-checkout.test.ts index cfc9e735b21..af38cfc910c 100644 --- a/src/main/ipc/worktrees-existing-branch-checkout.test.ts +++ b/src/main/ipc/worktrees-existing-branch-checkout.test.ts @@ -34,19 +34,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-failed-removal.test.ts b/src/main/ipc/worktrees-failed-removal.test.ts index f1cfa13eb80..b2563b433c5 100644 --- a/src/main/ipc/worktrees-failed-removal.test.ts +++ b/src/main/ipc/worktrees-failed-removal.test.ts @@ -45,19 +45,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-forget-local.test.ts b/src/main/ipc/worktrees-forget-local.test.ts index efed2838bc3..2668d263ed2 100644 --- a/src/main/ipc/worktrees-forget-local.test.ts +++ b/src/main/ipc/worktrees-forget-local.test.ts @@ -7,14 +7,12 @@ import { import { listWorktreesMock, removeWorktreeMock, - getSshGitProviderMock, getSshFilesystemProviderMock, deleteWorktreeHistoryDirMock, advertisedUrlWatcherForgetWorktreeMock, killAllProcessesForWorktreeMock, clearProviderPtyStateMock, getLocalPtyProviderMock, - parseOrcaYamlMock, hasHooksFileMock } from './worktrees-test-module-mocks' import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' @@ -44,19 +42,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( @@ -126,8 +117,6 @@ describe('registerWorktreeHandlers', () => { store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue(repo) getLocalPtyProviderMock.mockReturnValue(ptyProvider) - // Why: a removed/disconnected SSH target has no live provider; forgetLocal must not reach for one. - getSshGitProviderMock.mockReturnValue(undefined) const result = await handlers['worktrees:forgetLocal'](null, { worktreeId }) @@ -152,7 +141,6 @@ describe('registerWorktreeHandlers', () => { repoId: 'repo-1' }) // The whole point: local-only cleanup never dispatches to the SSH provider. - expect(getSshGitProviderMock).not.toHaveBeenCalled() expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() expect(listWorktreesMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() @@ -210,7 +198,6 @@ describe('registerWorktreeHandlers', () => { expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { repoId: 'repo-gone' }) - expect(getSshGitProviderMock).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() }) @@ -378,14 +365,8 @@ describe('worktree execution-host disambiguation', () => { connectionId: null, executionHostId: 'ssh:conn-1' as const } - const provider = { - listWorktrees: vi.fn(), - removeWorktree: vi.fn(), - worktreeIsClean: vi.fn() - } store.getRepo.mockReturnValue(brokenRepo) store.getRepos.mockReturnValue([brokenRepo]) - getSshGitProviderMock.mockReturnValue(provider) await expect( handlers['worktrees:remove'](null, { @@ -397,7 +378,6 @@ describe('worktree execution-host disambiguation', () => { ) expect(listWorktreesMock).not.toHaveBeenCalled() - expect(provider.listWorktrees).not.toHaveBeenCalled() expect(removeWorktreeMock).not.toHaveBeenCalled() }) @@ -493,7 +473,6 @@ describe('worktree execution-host disambiguation', () => { ).rejects.toThrow('Repo not found: repo-shared') expect(removeWorktreeMock).not.toHaveBeenCalled() - expect(getSshGitProviderMock).not.toHaveBeenCalled() }) it('inspects hooks on the requested host when repo ids collide', async () => { @@ -504,29 +483,25 @@ describe('worktree execution-host disambiguation', () => { badgeColor: '#000', addedAt: 0 } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - const fsProvider = { - readFile: vi.fn().mockResolvedValue({ - content: 'scripts:\n archive: remote-cleanup', - isBinary: false - }) + const sshRepo = { + ...localRepo, + path: '/remote/repo', + connectionId: 'conn-1' } store.getRepos.mockReturnValue([localRepo, sshRepo]) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - parseOrcaYamlMock.mockReturnValue({ scripts: { archive: 'remote-cleanup' } }) + // Why: this process serves no SSH filesystem, so the SSH host answers error, never local hooks. await expect( handlers['hooks:check'](null, { repoId: 'repo-shared', hostId: 'ssh:conn-1' }) ).resolves.toEqual({ - status: 'ok', - hasHooks: true, - hooks: { scripts: { archive: 'remote-cleanup' } }, + status: 'error', + hasHooks: false, + hooks: null, mayNeedUpdate: false }) - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml') expect(hasHooksFileMock).not.toHaveBeenCalled() }) @@ -559,32 +534,21 @@ describe('worktree execution-host disambiguation', () => { badgeColor: '#000', addedAt: 0 } - const sshRepo = { ...localRepo, path: '/remote/repo', connectionId: 'conn-1' } - const fsProvider = { - readFile: vi.fn(async (filePath: string) => { - if (filePath === '/remote/repo/.superset/config.json') { - return { content: '{"setup":"remote setup"}', isBinary: false } - } - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - }), - stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + const sshRepo = { + ...localRepo, + path: '/remote/repo', + connectionId: 'conn-1' } store.getRepos.mockReturnValue([localRepo, sshRepo]) store.getRepo.mockReturnValue(localRepo) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) + // Why: this process serves no SSH filesystem, so the SSH host offers no imports. await expect( handlers['hooks:inspectSetupScriptImports'](null, { repoId: 'repo-shared', hostId: 'ssh:conn-1' }) - ).resolves.toContainEqual( - expect.objectContaining({ - provider: 'superset', - setup: 'remote setup' - }) - ) - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/.superset/config.json') + ).resolves.toEqual([]) }) it('does not coalesce forget requests for the same id on different hosts', async () => { diff --git a/src/main/ipc/worktrees-issue-command-overrides.test.ts b/src/main/ipc/worktrees-issue-command-overrides.test.ts index 36821cf7b6c..26b7271a113 100644 --- a/src/main/ipc/worktrees-issue-command-overrides.test.ts +++ b/src/main/ipc/worktrees-issue-command-overrides.test.ts @@ -6,10 +6,7 @@ import * as projectGitOptions from '../project-runtime-git-options' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createIssueCommandRunnerScriptMock, - getSshFilesystemProviderMock, - getSshGitProviderMock, addWorktreeMock, - getActiveMultiplexerMock, listWorktreesMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' @@ -38,19 +35,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( @@ -115,10 +105,6 @@ function useRepo(repo: ReturnType): void { store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue(repo) store.setWorktreeMeta.mockImplementation((_worktreeId: string, meta: unknown) => meta) - getActiveMultiplexerMock.mockReturnValue({ - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn() - }) } describe('registerWorktreeHandlers', () => { @@ -209,142 +195,7 @@ describe('registerWorktreeHandlers', () => { }) }) - it('keeps SSH issue-command local overrides usable when shared read fails', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - } - const fsProvider = { - readFile: vi.fn(async (filePath: string) => { - if (filePath.endsWith('/.orca/issue-command')) { - return { content: 'local command\n', isBinary: false } - } - throw new Error('shared read failed') - }) - } - store.getRepo.mockReturnValue(repo) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - - await expect( - handlers['hooks:readIssueCommand'](null, { - repoId: 'repo-ssh' - }) - ).resolves.toMatchObject({ - status: 'ok', - localContent: 'local command', - sharedContent: null, - effectiveContent: 'local command', - source: 'local' - }) - }) - - it('writes SSH issue-command overrides without clobbering .gitignore on read failure', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - } - const fsProvider = { - createDir: vi.fn().mockResolvedValue(undefined), - readFile: vi.fn().mockRejectedValue(new Error('ssh read failed')), - writeFile: vi.fn().mockResolvedValue(undefined), - deletePath: vi.fn().mockResolvedValue(undefined) - } - store.getRepo.mockReturnValue(repo) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - - await expect( - handlers['hooks:writeIssueCommand'](null, { - repoId: 'repo-ssh', - content: 'orca issue command' - }) - ).rejects.toThrow('ssh read failed') - - expect(fsProvider.writeFile).not.toHaveBeenCalled() - }) - - it('reads an issue-command override from the requested host when repo ids collide', async () => { - const localRepo = { - id: 'repo-shared', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { - ...localRepo, - path: '/remote/repo', - displayName: 'ssh', - connectionId: 'conn-1' - } - const fsProvider = { - readFile: vi.fn(async (filePath: string) => { - if (filePath.endsWith('/.orca/issue-command')) { - return { content: 'remote command\n', isBinary: false } - } - throw Object.assign(new Error('missing'), { code: 'ENOENT' }) - }) - } - store.getRepos.mockReturnValue([localRepo, sshRepo]) - store.getRepo.mockReturnValue(localRepo) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - - await expect( - handlers['hooks:readIssueCommand'](null, { - repoId: 'repo-shared', - hostId: 'ssh:conn-1' - }) - ).resolves.toMatchObject({ - localContent: 'remote command', - effectiveContent: 'remote command', - source: 'local' - }) - expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/.orca/issue-command') - }) - - it('creates remote .gitignore only when it is missing while writing SSH issue commands', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - } - const enoent = Object.assign(new Error('missing'), { code: 'ENOENT' }) - const fsProvider = { - createDir: vi.fn().mockResolvedValue(undefined), - readFile: vi.fn().mockRejectedValue(enoent), - writeFile: vi.fn().mockResolvedValue(undefined), - deletePath: vi.fn().mockResolvedValue(undefined) - } - store.getRepo.mockReturnValue(repo) - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - - await handlers['hooks:writeIssueCommand'](null, { - repoId: 'repo-ssh', - content: 'orca issue command' - }) - - expect(fsProvider.writeFile).toHaveBeenNthCalledWith(1, '/remote/repo/.gitignore', '.orca\n') - expect(fsProvider.writeFile).toHaveBeenNthCalledWith( - 2, - '/remote/repo/.orca/issue-command', - 'orca issue command\n' - ) - }) - - it('rejects SSH issue-command writes when the remote filesystem provider is unavailable', async () => { + it('rejects SSH issue-command writes, which this process does not serve', async () => { const repo = { id: 'repo-ssh', path: '/remote/repo', @@ -355,7 +206,6 @@ describe('registerWorktreeHandlers', () => { worktreeBaseRef: null } store.getRepo.mockReturnValue(repo) - getSshFilesystemProviderMock.mockReturnValue(null) await expect( handlers['hooks:writeIssueCommand'](null, { @@ -364,38 +214,6 @@ describe('registerWorktreeHandlers', () => { }) ).rejects.toThrow('Remote filesystem unavailable') }) - - it('preserves .gitignore when the SSH host already ignores .orca', async () => { - store.getRepo.mockReturnValue({ - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1' - }) - const checkIgnoredPaths = vi.fn().mockResolvedValue(['.orca/issue-command']) - getSshGitProviderMock.mockReturnValue({ checkIgnoredPaths }) - const fsProvider = { - createDir: vi.fn().mockResolvedValue(undefined), - readFile: vi.fn(), - writeFile: vi.fn().mockResolvedValue(undefined) - } - getSshFilesystemProviderMock.mockReturnValue(fsProvider) - - await handlers['hooks:writeIssueCommand'](null, { - repoId: 'repo-ssh', - content: 'local command' - }) - - expect(getSshGitProviderMock).toHaveBeenCalledWith('conn-1') - expect(checkIgnoredPaths).toHaveBeenCalledWith('/remote/repo', ['.orca/issue-command']) - expect(fsProvider.readFile).not.toHaveBeenCalled() - expect(fsProvider.writeFile).toHaveBeenCalledExactlyOnceWith( - '/remote/repo/.orca/issue-command', - 'local command\n' - ) - }) }) describe('worktrees:create execution host routing', () => { diff --git a/src/main/ipc/worktrees-lineage-hydration.test.ts b/src/main/ipc/worktrees-lineage-hydration.test.ts index 68de6fb5dd3..43385ee5b81 100644 --- a/src/main/ipc/worktrees-lineage-hydration.test.ts +++ b/src/main/ipc/worktrees-lineage-hydration.test.ts @@ -1,13 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { Worktree } from '../../shared/worktree/types' -import { toSshExecutionHostId } from '../../shared/execution-host' -import { LINEAGE_HYDRATION_TIMEOUT_MS } from './worktrees/metadata/host-lineage-listing' -import { getSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' -import { listWorktreesMock, getSshGitProviderMock } from './worktrees-test-module-mocks' -import { handlers, ipcEvent, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { listWorktreesMock } from './worktrees-test-module-mocks' +import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { makeWorktreeMeta } from './worktrees-test-fixtures' -import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -32,19 +28,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( @@ -92,156 +81,8 @@ vi.mock('../runtime/worktree-teardown', async () => vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) describe('registerWorktreeHandlers', () => { - let runtimeStub: WorktreeRuntimeStub - beforeEach(() => { - runtimeStub = setupWorktreeHandlers() - }) - - it('answers SSH lineage as unavailable without reading this machine', async () => { - const result = await handlers['worktrees:listLineageForHost'](ipcEvent, { - executionHostId: toSshExecutionHostId('target-a'), - expectedAuthority: getSshProviderAuthority('target-a') - }) - - expect(result).toEqual({ - authoritative: false, - executionHostId: 'ssh:target-a', - reason: 'unavailable' - }) - expect(runtimeStub.hydrateInferredWorktreeLineage).not.toHaveBeenCalled() - expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - }) - - it('snapshots lineage catalogs once and memoizes repeated owner resolution', async () => { - const worktreeIds = Array.from( - { length: 101 }, - (_, index) => `repo-1::/workspace/repo-${index}` - ) - const lineage = Object.fromEntries( - worktreeIds.slice(1).map((worktreeId, index) => [ - worktreeId, - { - worktreeId, - worktreeInstanceId: `child-${index}`, - parentWorktreeId: worktreeIds[index], - parentWorktreeInstanceId: `parent-${index}`, - origin: 'cli', - capture: { source: 'cwd-context', confidence: 'inferred' }, - createdAt: index - } - ]) - ) - store.getAllWorktreeLineage.mockReturnValue(lineage) - store.getRepos.mockClear() - store.getFolderWorkspaces.mockClear() - store.getProjectGroups.mockClear() - store.getWorktreeMeta.mockClear() - - const result = await handlers['worktrees:listLineageForHost'](ipcEvent, { - executionHostId: 'local' - }) - - expect(result).toMatchObject({ authoritative: true }) - expect( - Object.keys((result as { worktreeLineageById: Record }).worktreeLineageById) - ).toHaveLength(100) - expect(store.getRepos).toHaveBeenCalledOnce() - expect(store.getFolderWorkspaces).toHaveBeenCalledOnce() - expect(store.getProjectGroups).toHaveBeenCalledOnce() - expect(store.getWorktreeMeta).toHaveBeenCalledTimes(101) - }) - - it('preserves ambiguous legacy lineage instead of guessing among duplicate repo owners', async () => { - const child = 'duplicate::/child' - const parent = 'duplicate::/parent' - store.getRepos.mockReturnValue([ - { - id: 'duplicate', - path: '/local', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - }, - { - id: 'duplicate', - path: '/remote', - displayName: 'remote', - badgeColor: '#000', - addedAt: 0, - connectionId: 'target-a' - } - ]) - store.getAllWorktreeLineage.mockReturnValue({ - [child]: { - worktreeId: child, - worktreeInstanceId: 'child', - parentWorktreeId: parent, - parentWorktreeInstanceId: 'parent', - origin: 'cli', - capture: { source: 'cwd-context', confidence: 'inferred' }, - createdAt: 1 - } - }) - - await expect( - handlers['worktrees:listLineageForHost'](ipcEvent, { executionHostId: 'local' }) - ).resolves.toEqual({ - authoritative: false, - executionHostId: 'local', - reason: 'ambiguous-owner' - }) - expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - }) - - it('rejects runtime lineage reads and stale SSH authority', async () => { - await expect( - handlers['worktrees:listLineageForHost'](ipcEvent, { - executionHostId: 'runtime:environment-a' - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - expect(runtimeStub.hydrateInferredWorktreeLineage).not.toHaveBeenCalled() - - const authority = getSshProviderAuthority('target-a') - rotateSshProviderAuthority('target-a') - - await expect( - handlers['worktrees:listLineageForHost'](ipcEvent, { - executionHostId: toSshExecutionHostId('target-a'), - expectedAuthority: authority - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'stale' }) - expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - }) - - it('bounds noncooperative lineage hydration and permits a later read', async () => { - vi.useFakeTimers() - try { - runtimeStub.hydrateInferredWorktreeLineage.mockReturnValue(new Promise(() => {})) - const pending = handlers['worktrees:listLineageForHost'](ipcEvent, { - executionHostId: 'local' - }) - await vi.advanceTimersByTimeAsync(LINEAGE_HYDRATION_TIMEOUT_MS - 1) - let settled = false - void Promise.resolve(pending).finally(() => { - settled = true - }) - await Promise.resolve() - expect(settled).toBe(false) - await vi.advanceTimersByTimeAsync(1) - await expect(pending).resolves.toMatchObject({ - authoritative: false, - reason: 'unavailable' - }) - expect(vi.getTimerCount()).toBe(0) - - runtimeStub.hydrateInferredWorktreeLineage.mockResolvedValue(undefined) - await expect( - handlers['worktrees:listLineageForHost'](ipcEvent, { executionHostId: 'local' }) - ).resolves.toMatchObject({ authoritative: true }) - } finally { - vi.useRealTimers() - } + setupWorktreeHandlers() }) it('hydrates detected worktrees with instance-validated legacy lineage after an update', async () => { @@ -416,20 +257,12 @@ describe('registerWorktreeHandlers', () => { addedAt: 0, connectionId: 'conn-1' } - const provider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/live-child', - head: 'def456', - branch: 'refs/heads/live-child', - isBare: false, - isMainWorktree: false - } - ]) - } store.getRepos.mockReturnValue([repo]) + // Why: this process serves no SSH git, so the SSH repo lists its stored rows. + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-ssh::/remote/live-child': {} + }) store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) store.getAllWorktreeLineage.mockReturnValue({ 'repo-ssh::/remote/live-child': { parentWorktreeId: 'repo-ssh::/remote/missing-parent', diff --git a/src/main/ipc/worktrees-listing-fallback-rows.test.ts b/src/main/ipc/worktrees-listing-fallback-rows.test.ts index dacec728b53..c2d4488fa55 100644 --- a/src/main/ipc/worktrees-listing-fallback-rows.test.ts +++ b/src/main/ipc/worktrees-listing-fallback-rows.test.ts @@ -28,19 +28,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-local-base-ref-resolution.test.ts b/src/main/ipc/worktrees-local-base-ref-resolution.test.ts index 099fe5f64d2..c5db99b97e7 100644 --- a/src/main/ipc/worktrees-local-base-ref-resolution.test.ts +++ b/src/main/ipc/worktrees-local-base-ref-resolution.test.ts @@ -34,19 +34,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-local-create-flow.test.ts b/src/main/ipc/worktrees-local-create-flow.test.ts index 96e1b804194..51052ed1e5c 100644 --- a/src/main/ipc/worktrees-local-create-flow.test.ts +++ b/src/main/ipc/worktrees-local-create-flow.test.ts @@ -46,19 +46,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-mutation-scan-generation-order.test.ts b/src/main/ipc/worktrees-mutation-scan-generation-order.test.ts index 0b21a42b42f..0b5c8f31629 100644 --- a/src/main/ipc/worktrees-mutation-scan-generation-order.test.ts +++ b/src/main/ipc/worktrees-mutation-scan-generation-order.test.ts @@ -37,19 +37,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts index ff033d9fe11..40327ab8a78 100644 --- a/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts +++ b/src/main/ipc/worktrees-orphan-directory-cleanup.test.ts @@ -37,19 +37,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index e5cd9c26c45..ddfd5886dbf 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -61,19 +61,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-remove-archive-hooks.test.ts b/src/main/ipc/worktrees-remove-archive-hooks.test.ts index 7eb97cd035d..b5e82214f55 100644 --- a/src/main/ipc/worktrees-remove-archive-hooks.test.ts +++ b/src/main/ipc/worktrees-remove-archive-hooks.test.ts @@ -43,19 +43,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-remove-preflight.test.ts b/src/main/ipc/worktrees-remove-preflight.test.ts index ed41b90f3cb..6c420f3beea 100644 --- a/src/main/ipc/worktrees-remove-preflight.test.ts +++ b/src/main/ipc/worktrees-remove-preflight.test.ts @@ -39,19 +39,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts index cbf4c904096..1ce4365f339 100644 --- a/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts +++ b/src/main/ipc/worktrees-setup-launch-sparse-checkout.test.ts @@ -34,19 +34,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts index e988f242c6b..8363e12c4d1 100644 --- a/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts +++ b/src/main/ipc/worktrees-ssh-pr-head-fetch.test.ts @@ -1,11 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { REVIEW_HEAD_FETCH_TIMEOUT_MS } from '../../shared/review-head-tracking-ref' -import { - getPullRequestPushTargetMock, - gitExecFileAsyncMock, - getSshGitProviderMock -} from './worktrees-test-module-mocks' -import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { getPullRequestPushTargetMock, gitExecFileAsyncMock } from './worktrees-test-module-mocks' +import { handlers, setupWorktreeHandlers } from './worktrees-test-harness' import { ORIGIN_HEAD_COMPONENT, ORIGIN_REMOTE_URL } from './worktrees-test-fixtures' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' @@ -32,19 +28,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( @@ -98,142 +87,6 @@ describe('registerWorktreeHandlers', () => { runtimeStub = setupWorktreeHandlers() }) - it('fetches the same-repo PR head via the SSH tracking-ref RPC, not git.exec', async () => { - const fetchRemoteTrackingRef = vi.fn(async () => {}) - const exec = vi.fn(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'rev-parse') { - return { stdout: 'def456\n', stderr: '' } - } - return { stdout: '', stderr: '' } - }) - getSshGitProviderMock.mockReturnValue({ exec, fetchRemoteTrackingRef }) - store.getRepo.mockReturnValue({ - id: 'repo-1', - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - }) - - const result = await handlers['worktrees:resolvePrBase'](null, { - repoId: 'repo-1', - prNumber: 42, - headRefName: 'feature/add-feature', - isCrossRepository: false - }) - - expect(fetchRemoteTrackingRef).toHaveBeenCalledWith( - '/workspace/repo', - 'origin', - 'feature/add-feature', - 'refs/remotes/origin/feature/add-feature' - ) - expect(exec).not.toHaveBeenCalledWith(expect.arrayContaining(['fetch']), expect.anything()) - expect(result).toMatchObject({ - baseBranch: 'def456', - headSha: 'def456', - branchNameOverride: 'feature/add-feature', - pushTarget: { remoteName: 'origin', branchName: 'feature/add-feature' } - }) - }) - - it('fetches a fork PR head via the SSH pull-head RPC, not git.exec', async () => { - const durableLocalRef = `refs/orca/pull/${ORIGIN_HEAD_COMPONENT}/42` - const fetchGitHubPullRequestHead = vi.fn(async () => durableLocalRef) - const exec = vi.fn(async (args: string[]) => { - if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args[2] === `${durableLocalRef}^{commit}`) { - return { stdout: 'fork-head-sha\n', stderr: '' } - } - throw new Error(`unexpected git call: ${args.join(' ')}`) - }) - getSshGitProviderMock.mockReturnValue({ - exec, - fetchGitHubPullRequestHead, - fetchRemoteTrackingRef: vi.fn() - }) - store.getRepo.mockReturnValue({ - id: 'repo-1', - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - }) - - const result = await handlers['worktrees:resolvePrBase'](null, { - repoId: 'repo-1', - prNumber: 42, - headRefName: 'contributor/fix', - isCrossRepository: true - }) - - expect(fetchGitHubPullRequestHead).toHaveBeenCalledWith('/workspace/repo', 'origin', 42) - expect(exec).not.toHaveBeenCalledWith(expect.arrayContaining(['fetch']), expect.anything()) - expect(result).toMatchObject({ - baseBranch: 'fork-head-sha', - headSha: 'fork-head-sha', - branchNameOverride: 'contributor/fix' - }) - }) - - it('fetches a fork PR head from origin, not the first remote, over SSH', async () => { - const durableLocalRef = `refs/orca/pull/${ORIGIN_HEAD_COMPONENT}/42` - const fetchGitHubPullRequestHead = vi.fn(async () => durableLocalRef) - // Why: `fork` is listed first, but fork PR heads live on the hosting remote (origin). - const exec = vi.fn(async (args: string[]) => { - if (args[0] === 'remote' && args[1] === 'get-url') { - return { - stdout: `git@github.com:org/${args[2] === 'origin' ? 'repo' : 'fork'}.git\n`, - stderr: '' - } - } - if (args[0] === 'remote') { - return { stdout: 'fork\norigin\n', stderr: '' } - } - if (args[0] === 'rev-parse' && args[2] === `${durableLocalRef}^{commit}`) { - return { stdout: 'fork-head-sha\n', stderr: '' } - } - throw new Error(`unexpected git call: ${args.join(' ')}`) - }) - getSshGitProviderMock.mockReturnValue({ - exec, - fetchGitHubPullRequestHead, - fetchRemoteTrackingRef: vi.fn() - }) - store.getRepo.mockReturnValue({ - id: 'repo-1', - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: null - }) - - const result = await handlers['worktrees:resolvePrBase'](null, { - repoId: 'repo-1', - prNumber: 42, - headRefName: 'contributor/fix', - isCrossRepository: true - }) - - expect(fetchGitHubPullRequestHead).toHaveBeenCalledWith('/workspace/repo', 'origin', 42) - expect(result).toMatchObject({ - baseBranch: 'fork-head-sha', - headSha: 'fork-head-sha', - branchNameOverride: 'contributor/fix' - }) - }) - it('resolves a fork PR base even when push-target discovery fails', async () => { getPullRequestPushTargetMock.mockRejectedValueOnce(new Error('lookup failed')) gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { diff --git a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts index 952494ffdf4..53deeb0f02e 100644 --- a/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts +++ b/src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts @@ -9,7 +9,6 @@ import { import { getSshProviderAuthority } from '../ssh/ssh-provider-authority' import { listWorktreesMock, - getSshGitProviderMock, pruneCleanupScanSnapshotsMock, pruneSpaceAnalysisSnapshotsMock } from './worktrees-test-module-mocks' @@ -39,19 +38,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( @@ -153,7 +145,6 @@ describe('registerWorktreeHandlers', () => { it('rejects malformed and contradictory repo host provenance', async () => { const provider = { listWorktrees: vi.fn().mockResolvedValue([]) } - getSshGitProviderMock.mockReturnValue(provider) const request = { providerRequestId: 'request-1' as ProviderRequestId, repoId: 'repo-1', @@ -227,7 +218,6 @@ describe('registerWorktreeHandlers', () => { connectionId: 'target-a' } store.getRepos.mockReturnValue([sshRepo]) - getSshGitProviderMock.mockReturnValue(undefined) const expectedAuthority = getSshProviderAuthority('target-a') const result = await handlers['worktrees:listDetected'](ipcEvent, { @@ -304,7 +294,6 @@ describe('registerWorktreeHandlers', () => { ] } }) - expect(getSshGitProviderMock).not.toHaveBeenCalled() expect(listWorktreesMock).not.toHaveBeenCalled() }) @@ -346,7 +335,6 @@ describe('registerWorktreeHandlers', () => { expect.objectContaining({ id: rootId, path: folderRepo.path, isMainWorktree: true }), expect.objectContaining({ id: instanceId, path: folderRepo.path, isMainWorktree: false }) ]) - expect(getSshGitProviderMock).not.toHaveBeenCalled() }) it('stops listing SSH worktrees once an authoritative scan retires their metadata', async () => { @@ -585,7 +573,6 @@ describe('registerWorktreeHandlers', () => { executionHostId: sshHostId }) expect(store.getAllWorktreeMeta).not.toHaveBeenCalled() - expect(getSshGitProviderMock).not.toHaveBeenCalled() }) it('fails closed for duplicate exact owners and ambiguous legacy repo IDs', async () => { @@ -621,7 +608,6 @@ describe('registerWorktreeHandlers', () => { source: 'metadata-fallback', worktrees: [] }) - expect(getSshGitProviderMock).not.toHaveBeenCalled() expect(listWorktreesMock).not.toHaveBeenCalled() }) @@ -689,17 +675,6 @@ describe('registerWorktreeHandlers', () => { ? makeWorktreeMeta({ hostId: toSshExecutionHostId('target-b') }) : undefined ) - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn().mockResolvedValue([ - { - path: worktreePath, - head: 'head-a', - branch: 'refs/heads/feature', - isBare: false, - isMainWorktree: false - } - ]) - }) const result = await handlers['worktrees:listDetected'](ipcEvent, { providerRequestId: 'request-1' as ProviderRequestId, @@ -765,7 +740,6 @@ describe('registerWorktreeHandlers', () => { expect(missingAuthorityResult).toMatchObject({ status: 'rejected' }) expect(zeroOwnerResult).toMatchObject({ status: 'ambiguous-owner' }) expect(wrongProviderOwnerResult).toMatchObject({ status: 'rejected' }) - expect(getSshGitProviderMock).not.toHaveBeenCalled() expect(listWorktreesMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/ipc/worktrees-test-harness.ts b/src/main/ipc/worktrees-test-harness.ts index 52295e9fba3..44b8e9d4308 100644 --- a/src/main/ipc/worktrees-test-harness.ts +++ b/src/main/ipc/worktrees-test-harness.ts @@ -51,9 +51,7 @@ import { computeWorktreePathMock, ensurePathWithinWorkspaceMock, gitExecFileAsyncMock, - getSshGitProviderMock, getSshFilesystemProviderMock, - getActiveMultiplexerMock, deleteWorktreeHistoryDirMock, advertisedUrlWatcherForgetWorktreeMock, pruneCleanupScanSnapshotMock, @@ -129,9 +127,7 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub { computeWorktreePathMock, ensurePathWithinWorkspaceMock, gitExecFileAsyncMock, - getSshGitProviderMock, getSshFilesystemProviderMock, - getActiveMultiplexerMock, mainWindow.webContents.send, store.getRepos, store.getRepo, diff --git a/src/main/ipc/worktrees-test-module-mocks.ts b/src/main/ipc/worktrees-test-module-mocks.ts index cfd8b20e7a2..d75185778c4 100644 --- a/src/main/ipc/worktrees-test-module-mocks.ts +++ b/src/main/ipc/worktrees-test-module-mocks.ts @@ -82,9 +82,7 @@ export const loadHooksMock: ModuleMock = vi.fn() export const computeWorktreePathMock: Mock = vi.fn() export const ensurePathWithinWorkspaceMock: StringArgMock = vi.fn() export const gitExecFileAsyncMock: GitArgvMock = vi.fn() -export const getSshGitProviderMock: StringArgMock = vi.fn() export const getSshFilesystemProviderMock: ModuleMock = vi.fn() -export const getActiveMultiplexerMock: ModuleMock = vi.fn() export const deleteWorktreeHistoryDirMock: ModuleMock = vi.fn() export const advertisedUrlWatcherForgetWorktreeMock: ModuleMock = vi.fn() export const pruneCleanupScanSnapshotMock: ModuleMock = vi.fn().mockResolvedValue(undefined) @@ -157,22 +155,6 @@ export const hostedReviewModuleMock = () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock }) -export const sshGitDispatchModuleMock = () => ({ - getSshGitProviderGeneration: () => 0, - getSshGitProvider: getSshGitProviderMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.', - requireSshGitProvider: (connectionId: string) => { - const provider = getSshGitProviderMock(connectionId) - if (!provider) { - throw new Error( - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' - ) - } - return provider - } -}) - export const sshFilesystemDispatchModuleMock = () => ({ getSshFilesystemProvider: getSshFilesystemProviderMock }) @@ -189,17 +171,6 @@ export const worktreeSymlinksModuleMock = (): { removeWorktreeLinkedPaths: removeWorktreeLinkedPathsMock }) -export const sshModuleMock = () => ({ - getActiveMultiplexer: getActiveMultiplexerMock -}) - -// Why a second builder: getActiveMultiplexer moved to ../ssh/ssh-target-registry so the -// runtime could reach it without ipcMain. Production imports it from there now, so a -// vi.mock('./ssh') factory alone is inert. -export const sshTargetRegistryModuleMock = () => ({ - getActiveMultiplexer: getActiveMultiplexerMock -}) - export const hooksModuleMock = () => ({ getEffectiveHooks: getEffectiveHooksMock, loadHooks: loadHooksMock, diff --git a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts index 3877cc12030..915e254e0eb 100644 --- a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts +++ b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts @@ -48,19 +48,12 @@ vi.mock('../github/client', async () => vi.mock('../source-control/hosted-review', async () => (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() ) -vi.mock('../providers/ssh-git-dispatch', async () => - (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() -) vi.mock('../providers/ssh-filesystem-dispatch', async () => (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() ) vi.mock('./worktree-symlinks', async () => (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() ) -vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) -vi.mock('../ssh/ssh-target-registry', async () => - (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() -) vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) vi.mock('../setup-runner-script-text', async (importOriginal) => (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 0e06b3ee0ec..c581c9f2e15 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -47,7 +47,6 @@ const WORKTREE_HANDLER_CHANNELS = [ 'worktrees:forceDeletePreservedBranch', 'worktrees:updateMeta', 'worktrees:listLineage', - 'worktrees:listLineageForHost', 'worktrees:updateLineage', 'worktrees:persistSortOrder', 'worktrees:getBranchRenameFailureOutput', diff --git a/src/main/ipc/worktrees/create/register-review-base-handlers.ts b/src/main/ipc/worktrees/create/register-review-base-handlers.ts index ab2892e8954..bb2842e5756 100644 --- a/src/main/ipc/worktrees/create/register-review-base-handlers.ts +++ b/src/main/ipc/worktrees/create/register-review-base-handlers.ts @@ -6,7 +6,10 @@ import { getLocalProjectGitExecOptions, getLocalProjectGhExecOptions } from '../../../project-runtime-git-options' -import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { + SSH_GIT_RECONNECT_MESSAGE, + SshHostNotServedError +} from '../../../providers/ssh-host-not-served-error' import { fetchPrHeadTrackingRef, fetchGitHubPullRequestHeadRef @@ -41,36 +44,21 @@ export function registerReviewBaseHandlers(context: WorktreeIpcContext): void { if (!repo.connectionId) { return gitExecFileAsync(args, getLocalProjectGitExecOptions(store, repo)) } - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - throw new Error( - 'SSH Git provider is not available. Reconnect to this target and try again.' - ) - } - return provider.exec(args, repo.path) + throw new SshHostNotServedError(repo.connectionId, SSH_GIT_RECONNECT_MESSAGE) } const localGhOptions = repo.connectionId ? repo.ghAccount ? { ghAccount: repo.ghAccount } : undefined : getLocalProjectGhExecOptions(store, repo) - // Why: SSH review-head fetches require narrow write-capable RPCs. const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => - fetchPrHeadTrackingRef( - repo, - repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, - remote, - branch, - { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } - ) + fetchPrHeadTrackingRef(repo, remote, branch, { + localGitExecOptions: getLocalProjectGitExecOptions(store, repo) + }) const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise => - fetchGitHubPullRequestHeadRef( - repo, - repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, - remote, - prNumber, - { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } - ) + fetchGitHubPullRequestHeadRef(repo, remote, prNumber, { + localGitExecOptions: getLocalProjectGitExecOptions(store, repo) + }) return resolveGitHubPrStartPoint({ repoPath: repo.path, diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index 491a2342ee2..a5b78aa53de 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -14,7 +14,6 @@ import { isFolderRepo } from '../../../../shared/repo-kind' import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' import type { DirectSshDetectedWorktreeRequest } from '../../../../shared/detected-worktree-provider-contract' import { isAdmissibleDirectSshAuthority } from '../../../../shared/ssh-retained-payload-admission' -import type { ListDesktopLineageForHostArgs } from '../../../../shared/host-lineage-contract' import { buildDetectedGitWorktrees, createSshWorktreeMetaIndex, @@ -206,12 +205,3 @@ export function hasValidDirectSshAuthority( ): args is DirectSshDetectedWorktreeRequest { return isAdmissibleDirectSshAuthority(args.expectedAuthority) } - -export function hasValidLineageSshAuthority( - args: ListDesktopLineageForHostArgs -): args is Extract { - if (!('expectedAuthority' in args)) { - return false - } - return isAdmissibleDirectSshAuthority(args.expectedAuthority) -} diff --git a/src/main/ipc/worktrees/metadata/host-lineage-listing.ts b/src/main/ipc/worktrees/metadata/host-lineage-listing.ts deleted file mode 100644 index 48a25ec9d4c..00000000000 --- a/src/main/ipc/worktrees/metadata/host-lineage-listing.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' -import type { Store } from '../../../persistence/loading-store/store' -import type { - ListDesktopLineageForHostArgs, - HostLineageSnapshot -} from '../../../../shared/host-lineage-contract' -import { parseExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -import { isCurrentSshProviderAuthority } from '../../../ssh/ssh-provider-authority' -import { hasValidLineageSshAuthority } from '../listing/detected-provider-listing' -import { filterLineageForHost } from './workspace-lineage-filtering' - -export const LINEAGE_HYDRATION_TIMEOUT_MS = 5_000 - -export async function hydrateLineageWithinDeadline(runtime: OrcaRuntimeService): Promise { - let timeout: ReturnType | undefined - const hydration = Promise.resolve() - .then(() => runtime.hydrateInferredWorktreeLineage()) - .then( - () => true, - () => false - ) - const deadline = new Promise((resolve) => { - timeout = setTimeout(() => resolve(false), LINEAGE_HYDRATION_TIMEOUT_MS) - }) - try { - return await Promise.race([hydration, deadline]) - } finally { - if (timeout) { - clearTimeout(timeout) - } - } -} - -export async function listDesktopLineageForHost( - store: Store, - runtime: OrcaRuntimeService, - args: ListDesktopLineageForHostArgs -): Promise { - const parsedHost = parseExecutionHostId(args?.executionHostId) - const rejected = ( - reason: Extract['reason'] - ): HostLineageSnapshot => ({ - authoritative: false, - executionHostId: args.executionHostId, - reason - }) - if (!parsedHost || parsedHost.kind === 'runtime') { - return rejected('rejected') - } - if (parsedHost.kind === 'ssh') { - if ( - !hasValidLineageSshAuthority(args) || - args.expectedAuthority.targetId !== parsedHost.targetId - ) { - return rejected('rejected') - } - // An `ssh:` host is not served here, so its lineage is never answered from this machine. - return rejected( - isCurrentSshProviderAuthority({ ...args.expectedAuthority }) ? 'unavailable' : 'stale' - ) - } - if ('expectedAuthority' in args) { - return rejected('rejected') - } - if (!(await hydrateLineageWithinDeadline(runtime))) { - return rejected('unavailable') - } - const lineage = filterLineageForHost(store, parsedHost.id) - if (!lineage) { - return rejected('ambiguous-owner') - } - return { - authoritative: true, - authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, - ...lineage - } -} diff --git a/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts b/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts deleted file mode 100644 index a8a58bf81d1..00000000000 --- a/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { parseExecutionHostId } from '../../../../shared/execution-host' -import type { ExecutionHostId } from '../../../../shared/execution-host' -import type { Store } from '../../../persistence/loading-store/store' -import type { Repo } from '../../../../shared/repo-types' -import { parseWorktreeId } from '../../worktree-logic' -import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' -import { resolveRepoOwnershipEvidence } from '../listing/worktree-host-ownership' - -export type LineageOwner = - | { status: 'owned'; hostId: ExecutionHostId } - | { status: 'ambiguous' | 'contradictory' | 'runtime' } - -export type LineageFolder = FolderWorkspace -export type LineageGroup = ProjectGroup - -export type LineageResolutionContext = { - store: Store - repos: Repo[] - groups: LineageGroup[] - reposById: Map - foldersById: Map - groupsById: Map - groupSubtreeIdsByRoot: Map> - worktreeOwners: Map - folderOwners: Map - workspaceOwners: Map -} - -export function indexLineageEntriesById( - entries: readonly T[] -): Map { - const index = new Map() - for (const entry of entries) { - const matching = index.get(entry.id) ?? [] - matching.push(entry) - index.set(entry.id, matching) - } - return index -} - -export function createLineageResolutionContext(store: Store): LineageResolutionContext { - const repos = store.getRepos() - const folders = store.getFolderWorkspaces() - const groups = store.getProjectGroups() - return { - store, - repos, - groups, - reposById: indexLineageEntriesById(repos), - foldersById: indexLineageEntriesById(folders), - groupsById: indexLineageEntriesById(groups), - groupSubtreeIdsByRoot: new Map(), - worktreeOwners: new Map(), - folderOwners: new Map(), - workspaceOwners: new Map() - } -} - -export function resolveRepoLineageOwner(repo: Repo): LineageOwner { - const owner = resolveRepoOwnershipEvidence(repo) - if (owner.status === 'malformed') { - return { status: 'ambiguous' } - } - if (owner.status === 'contradictory') { - return { status: 'contradictory' } - } - return parseExecutionHostId(owner.hostId)?.kind === 'runtime' ? { status: 'runtime' } : owner -} - -export function resolveWorktreeLineageOwner( - context: LineageResolutionContext, - worktreeId: string -): LineageOwner { - const cached = context.worktreeOwners.get(worktreeId) - if (cached) { - return cached - } - const remember = (owner: LineageOwner): LineageOwner => { - context.worktreeOwners.set(worktreeId, owner) - return owner - } - let repoId: string - try { - repoId = parseWorktreeId(worktreeId).repoId - } catch { - return remember({ status: 'ambiguous' }) - } - const repos = context.reposById.get(repoId) ?? [] - const meta = context.store.getWorktreeMeta(worktreeId) - const runtimeOwnerEnvironmentId = ( - meta as (WorktreeMeta & { runtimeOwnerEnvironmentId?: string }) | undefined - )?.runtimeOwnerEnvironmentId?.trim() - if (runtimeOwnerEnvironmentId) { - return remember({ status: 'runtime' }) - } - if (meta?.hostId) { - const explicitHost = parseExecutionHostId(meta.hostId) - if (!explicitHost) { - return remember({ status: 'ambiguous' }) - } - if (explicitHost.kind === 'runtime') { - return remember({ status: 'runtime' }) - } - const matchingRepos = repos.filter((repo) => { - const owner = resolveRepoLineageOwner(repo) - return owner.status === 'owned' && owner.hostId === explicitHost.id - }) - if (matchingRepos.length === 1) { - return remember({ status: 'owned', hostId: explicitHost.id }) - } - return remember( - matchingRepos.length > 1 - ? { status: 'ambiguous' } - : { status: repos.length > 0 ? 'contradictory' : 'ambiguous' } - ) - } - if (repos.length !== 1) { - return remember({ status: 'ambiguous' }) - } - return remember(resolveRepoLineageOwner(repos[0])) -} diff --git a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts index 0fe85e09884..1cb36b600b1 100644 --- a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts +++ b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts @@ -4,17 +4,12 @@ import { displayNameUpdatePinsLabel } from '../../../../shared/worktree/display- import { parseExecutionHostId } from '../../../../shared/execution-host' import { stripOrcaProvenanceMetaUpdates } from '../../../worktree-removal-safety' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' -import type { - ListDesktopLineageForHostArgs, - HostLineageSnapshot -} from '../../../../shared/host-lineage-contract' import { notifyWorktreesChanged } from '../../worktree-remote' import { parseWorktreeId } from '../../worktree-logic' import { planWorktreeSortOrderUpdates } from '../../../../shared/worktree/sort-order-update' import { readBranchRenameFailureOutputForDisplay } from '../../../agent-hooks/branch-rename-failure-output' import { normalizeLinkedWorkItemFields } from '../ipc-context-schemas' import { isRemovedFolderWorkspaceInstance } from '../../../folder-workspace-meta-presence' -import { listDesktopLineageForHost } from './host-lineage-listing' import type { WorktreeIpcContext } from '../worktree-ipc-context' export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): void { @@ -72,12 +67,6 @@ export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): v } }) - ipcMain.handle( - 'worktrees:listLineageForHost', - (_event, args: ListDesktopLineageForHostArgs): Promise => - listDesktopLineageForHost(store, runtime, args) - ) - ipcMain.handle( 'worktrees:updateLineage', async (_event, args: { worktreeId: string; parentWorktreeId?: string; noParent?: boolean }) => { diff --git a/src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts b/src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts deleted file mode 100644 index e3ccee07086..00000000000 --- a/src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../../shared/repo-types' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' -import { createLineageResolutionContext } from './lineage-owner-resolution' -import { getFolderLineageCandidateRepos } from './workspace-lineage-filtering' - -vi.mock('../../worktree-logic', () => ({ parseWorktreeId: vi.fn() })) - -function repo(id: string, fields: Partial = {}): Repo { - return { id, path: `/root/${id}`, displayName: id, badgeColor: '#000', addedAt: 0, ...fields } -} - -function group(id = 'group', fields: Partial = {}): ProjectGroup { - return { - id, - name: id, - parentPath: null, - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 0, - updatedAt: 0, - ...fields - } -} - -function folder(fields: Partial = {}): FolderWorkspace { - return { - id: 'folder', - projectGroupId: 'group', - name: 'folder', - folderPath: '/root', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0, - createdAt: 0, - updatedAt: 0, - ...fields - } -} - -function context(repos: Repo[], groups = [group()]) { - const store = { - getRepos: () => repos, - getFolderWorkspaces: () => [], - getProjectGroups: () => groups - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Context construction only reads these three store catalogs. - return createLineageResolutionContext(store as never) -} - -describe('folder lineage candidate lookup', () => { - it('reads grouped target membership once across a large path-candidate list', () => { - let targetReads = 0 - const grouped = Array.from({ length: 300 }, (_, index): Repo => ({ - ...repo(`grouped-${index}`, { projectGroupId: 'group', path: `/elsewhere/${index}` }), - get connectionId() { - targetReads += 1 - return null - } - })) - const candidates = Array.from({ length: 1_000 }, (_, index) => repo(`path-${index}`)) - const result = getFolderLineageCandidateRepos(context([...grouped, ...candidates]), folder()) - - expect(result).toEqual([...grouped, ...candidates]) - expect(targetReads).toBe(300) - }) - - it('preserves descendant-group priority, path boundaries and local/SSH membership', () => { - const entries = [ - repo('path-a', { connectionId: 'a' }), - repo('group-b', { projectGroupId: 'nested', path: '/elsewhere', connectionId: 'b' }), - repo('local-null', { connectionId: null }), - repo('group-local', { projectGroupId: 'group' }), - repo('path-b', { connectionId: 'b' }), - repo('local-absent'), - repo('sibling', { path: '/root-other/repo', connectionId: 'b' }), - repo('root', { path: '/root', connectionId: 'b' }) - ] - const snapshot = structuredClone(entries) - const result = getFolderLineageCandidateRepos( - context(entries, [group(), group('nested', { parentGroupId: 'group' })]), - folder() - ) - - expect(result.map((entry) => entry.id)).toEqual([ - 'group-b', - 'group-local', - 'local-null', - 'path-b', - 'local-absent', - 'root' - ]) - expect(result[0]).toBe(entries[1]) - expect(entries).toEqual(snapshot) - }) - - it.each([ - { folderTarget: 'a', groupTarget: 'b', expected: ['grouped', 'a'] }, - { folderTarget: null, groupTarget: 'b', expected: ['grouped', 'b'] }, - { folderTarget: undefined, groupTarget: undefined, expected: ['grouped', 'local'] } - ])( - 'keeps explicit target precedence: $folderTarget / $groupTarget', - ({ folderTarget, groupTarget, expected }) => { - const entries = [ - repo('a', { connectionId: 'a' }), - repo('b', { connectionId: 'b' }), - repo('local'), - repo('grouped', { projectGroupId: 'group', path: '/outside' }) - ] - expect( - getFolderLineageCandidateRepos( - context(entries, [group('group', { connectionId: groupTarget })]), - folder({ connectionId: folderTarget }) - ).map((entry) => entry.id) - ).toEqual(expected) - } - ) - - it('keeps every contained target when the folder has no grouped repositories', () => { - const entries = [ - repo('local'), - repo('remote', { connectionId: 'a' }), - repo('outside', { path: '/elsewhere' }) - ] - expect(getFolderLineageCandidateRepos(context(entries), folder())).toEqual(entries.slice(0, 2)) - }) - - it('does not read grouped targets when there are no path candidates', () => { - let targetReads = 0 - const grouped: Repo = { - ...repo('grouped', { projectGroupId: 'group' }), - get connectionId() { - targetReads += 1 - return 'a' - } - } - expect(getFolderLineageCandidateRepos(context([grouped]), folder())).toEqual([grouped]) - expect(targetReads).toBe(0) - }) - - it('rebuilds target membership after a later change in the same context', () => { - const grouped = repo('grouped', { projectGroupId: 'group', connectionId: 'a' }) - const a = repo('a', { connectionId: 'a' }) - const b = repo('b', { connectionId: 'b' }) - const current = context([grouped, a, b]) - expect(getFolderLineageCandidateRepos(current, folder())).toEqual([grouped, a]) - grouped.connectionId = 'b' - expect(getFolderLineageCandidateRepos(current, folder())).toEqual([grouped, b]) - }) -}) diff --git a/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts b/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts deleted file mode 100644 index c478b59b81a..00000000000 --- a/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { Repo } from '../../../../shared/repo-types' -import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' -import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' -import { parseExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -import type { ExecutionHostId } from '../../../../shared/execution-host' -import { parseWorkspaceKey } from '../../../../shared/workspace-scope' -import type { Store } from '../../../persistence/loading-store/store' -import type { WorktreeLineage, WorkspaceLineage } from '../../../../shared/worktree/lineage-types' -import { - createLineageResolutionContext, - resolveRepoLineageOwner, - resolveWorktreeLineageOwner -} from './lineage-owner-resolution' -import type { - LineageFolder, - LineageOwner, - LineageResolutionContext -} from './lineage-owner-resolution' - -export function getFolderLineageCandidateRepos( - context: LineageResolutionContext, - folder: LineageFolder -): Repo[] { - let groupIds = context.groupSubtreeIdsByRoot.get(folder.projectGroupId) - if (!groupIds) { - groupIds = getProjectGroupSubtreeIds(context.groups, folder.projectGroupId) - context.groupSubtreeIdsByRoot.set(folder.projectGroupId, groupIds) - } - const grouped = context.repos.filter( - (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) - ) - const pathRepos = context.repos.filter( - (repo) => - !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && - isPathInsideOrEqual(folder.folderPath, repo.path) - ) - const group = context.groupsById.get(folder.projectGroupId)?.[0] - const connectionId = folder.connectionId ?? group?.connectionId ?? null - if (connectionId) { - return [...grouped, ...pathRepos.filter((repo) => (repo.connectionId ?? null) === connectionId)] - } - if (grouped.length === 0) { - return pathRepos - } - if (pathRepos.length === 0) { - return grouped - } - const groupedConnectionIds = new Set(grouped.map((repo) => repo.connectionId ?? null)) - return [ - ...grouped, - ...pathRepos.filter((repo) => groupedConnectionIds.has(repo.connectionId ?? null)) - ] -} - -export function resolveFolderLineageOwner( - context: LineageResolutionContext, - folderWorkspaceId: string -): LineageOwner { - const cached = context.folderOwners.get(folderWorkspaceId) - if (cached) { - return cached - } - const remember = (owner: LineageOwner): LineageOwner => { - context.folderOwners.set(folderWorkspaceId, owner) - return owner - } - const folders = context.foldersById.get(folderWorkspaceId) ?? [] - if (folders.length !== 1) { - return remember({ status: 'ambiguous' }) - } - const folder = folders[0] - const groups = context.groupsById.get(folder.projectGroupId) ?? [] - if (groups.length !== 1) { - return remember({ status: 'ambiguous' }) - } - const group = groups[0] - const hosts = new Set() - if (folder.connectionId) { - hosts.add(`ssh:${encodeURIComponent(folder.connectionId)}`) - } - if (group.connectionId) { - hosts.add(`ssh:${encodeURIComponent(group.connectionId)}`) - } - if (group.executionHostId) { - const parsed = parseExecutionHostId(group.executionHostId) - if (!parsed) { - return remember({ status: 'ambiguous' }) - } - hosts.add(parsed.id) - } - for (const repo of getFolderLineageCandidateRepos(context, folder)) { - const owner = resolveRepoLineageOwner(repo) - if (owner.status !== 'owned') { - return remember(owner) - } - hosts.add(owner.hostId) - } - if (hosts.size > 1) { - return remember({ status: 'contradictory' }) - } - const hostId = [...hosts][0] ?? LOCAL_EXECUTION_HOST_ID - return remember( - parseExecutionHostId(hostId)?.kind === 'runtime' - ? { status: 'runtime' } - : { status: 'owned', hostId } - ) -} - -export function resolveWorkspaceLineageOwner( - context: LineageResolutionContext, - workspaceKey: string -): LineageOwner { - const cached = context.workspaceOwners.get(workspaceKey) - if (cached) { - return cached - } - const workspace = parseWorkspaceKey(workspaceKey) - const owner = !workspace - ? { status: 'ambiguous' as const } - : workspace.type === 'worktree' - ? resolveWorktreeLineageOwner(context, workspace.worktreeId) - : resolveFolderLineageOwner(context, workspace.folderWorkspaceId) - context.workspaceOwners.set(workspaceKey, owner) - return owner -} - -export function filterLineageForHost( - store: Store, - executionHostId: ExecutionHostId -): { - worktreeLineageById: Record - workspaceLineageByChildKey: Record -} | null { - const context = createLineageResolutionContext(store) - const worktreeLineageById: Record = {} - const workspaceLineageByChildKey: Record = {} - for (const [worktreeId, lineage] of Object.entries(store.getAllWorktreeLineage())) { - const child = resolveWorktreeLineageOwner(context, worktreeId) - const parent = resolveWorktreeLineageOwner(context, lineage.parentWorktreeId) - if (child.status === 'ambiguous' || child.status === 'contradictory') { - return null - } - if (parent.status === 'ambiguous' || parent.status === 'contradictory') { - return null - } - if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId === executionHostId && - parent.hostId === executionHostId - ) { - worktreeLineageById[worktreeId] = structuredClone(lineage) - } else if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId !== parent.hostId - ) { - return null - } - } - for (const [childKey, lineage] of Object.entries(store.getAllWorkspaceLineage())) { - const child = resolveWorkspaceLineageOwner(context, childKey) - const parent = resolveWorkspaceLineageOwner(context, lineage.parentWorkspaceKey) - if (child.status === 'ambiguous' || child.status === 'contradictory') { - return null - } - if (parent.status === 'ambiguous' || parent.status === 'contradictory') { - return null - } - if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId === executionHostId && - parent.hostId === executionHostId - ) { - workspaceLineageByChildKey[childKey] = structuredClone(lineage) - } else if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId !== parent.hostId - ) { - return null - } - } - return { worktreeLineageById, workspaceLineageByChildKey } -} diff --git a/src/main/issue-command-file.ts b/src/main/issue-command-file.ts index daeb1f5f611..9bb69e82055 100644 --- a/src/main/issue-command-file.ts +++ b/src/main/issue-command-file.ts @@ -4,7 +4,6 @@ import { join } from 'node:path' import { loadHooks } from './hooks' import type { GitRuntimeOptions } from './git/git-runtime-options' import { checkIgnoredPaths } from './git/check-ignored-paths' -import { requireSshGitProvider } from './providers/ssh-git-dispatch' type IssueCommandGitOptions = GitRuntimeOptions | (() => GitRuntimeOptions) @@ -77,7 +76,7 @@ export async function writeIssueCommand( if (!existsSync(orcaDir)) { mkdirSync(orcaDir, { recursive: true }) } - if (!(await isIssueCommandIgnoredByGit(repoPath, undefined, options))) { + if (!(await isIssueCommandIgnoredByGit(repoPath, options))) { ensureOrcaDirIgnored(repoPath) } writeFileSync(filePath, `${trimmed}\n`, 'utf-8') @@ -88,22 +87,19 @@ export async function writeIssueCommand( } } -/** Consult the execution host before changing shared ignore rules for a private override. */ -export async function isIssueCommandIgnoredByGit( +/** Consult Git before changing shared ignore rules for a private override. */ +async function isIssueCommandIgnoredByGit( repoPath: string, - connectionId?: string, options: IssueCommandGitOptions = {} ): Promise { try { const issueCommandPath = `${ORCA_DIR}/${ISSUE_COMMAND_FILENAME}` - const ignored = connectionId - ? await requireSshGitProvider(connectionId).checkIgnoredPaths(repoPath, [issueCommandPath]) - : await checkIgnoredPaths( - repoPath, - [issueCommandPath], - // Runtime repair must not block saving or clearing the local override. - typeof options === 'function' ? options() : options - ) + const ignored = await checkIgnoredPaths( + repoPath, + [issueCommandPath], + // Runtime repair must not block saving or clearing the local override. + typeof options === 'function' ? options() : options + ) return ignored.includes(issueCommandPath) } catch { // Preserve the existing ignore-file fallback if Git cannot inspect the rules. diff --git a/src/main/local-downloaded-folder-promotion.test.ts b/src/main/local-downloaded-folder-promotion.test.ts deleted file mode 100644 index 57d82edd6e2..00000000000 --- a/src/main/local-downloaded-folder-promotion.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { writeFileSync } from 'node:fs' -import { - lstat, - mkdtemp, - mkdir, - open, - readFile, - readdir, - rm, - symlink, - writeFile -} from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { - promoteLocalDownloadedFolder, - copyLocalDownloadedFileNoClobber, - publishLocalDownloadedFileNoClobber -} from './local-downloaded-folder-promotion' - -describe('promoteLocalDownloadedFolder', () => { - const roots: string[] = [] - - afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) - }) - - async function createPaths(): Promise<{ - root: string - tempPath: string - destinationPath: string - }> { - const root = await mkdtemp(join(tmpdir(), 'orca-folder-promotion-')) - roots.push(root) - const tempPath = join(root, '.transfer.download') - const destinationPath = join(root, 'downloaded') - await mkdir(join(tempPath, 'nested'), { recursive: true }) - await writeFile(join(tempPath, 'nested', 'file.txt'), 'remote') - return { root, tempPath, destinationPath } - } - - it('claims the destination and promotes the completed temporary tree', async () => { - const { root, tempPath, destinationPath } = await createPaths() - - await promoteLocalDownloadedFolder(tempPath, destinationPath) - - await expect(readFile(join(destinationPath, 'nested', 'file.txt'), 'utf8')).resolves.toBe( - 'remote' - ) - await expect(readdir(root)).resolves.toEqual(['downloaded']) - }) - - it('does not replace a destination created before publication', async () => { - const { tempPath, destinationPath } = await createPaths() - await mkdir(destinationPath) - await writeFile(join(destinationPath, 'local.txt'), 'local') - - await expect(promoteLocalDownloadedFolder(tempPath, destinationPath)).rejects.toThrow( - 'Destination folder already exists' - ) - - await expect(readFile(join(destinationPath, 'local.txt'), 'utf8')).resolves.toBe('local') - await expect(readFile(join(tempPath, 'nested', 'file.txt'), 'utf8')).resolves.toBe('remote') - }) - - it('does not replace a file added inside a claimed destination', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-folder-promotion-')) - roots.push(root) - const sourcePath = join(root, 'remote.txt') - const destinationPath = join(root, 'third-party.txt') - await writeFile(sourcePath, 'remote') - await writeFile(destinationPath, 'third-party') - - await expect( - publishLocalDownloadedFileNoClobber(sourcePath, destinationPath) - ).rejects.toMatchObject({ code: 'EEXIST' }) - - await expect(readFile(sourcePath, 'utf8')).resolves.toBe('remote') - await expect(readFile(destinationPath, 'utf8')).resolves.toBe('third-party') - }) - - it('preserves a mutation made after hard-link state is recorded', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-folder-promotion-')) - roots.push(root) - const sourcePath = join(root, 'remote.txt') - const destinationPath = join(root, 'published.txt') - await writeFile(sourcePath, 'remote') - const signal = { - throwIfAborted: () => { - writeFileSync(destinationPath, 'third-party mutation') - throw new Error('window closed') - } - } as unknown as AbortSignal - - await expect( - publishLocalDownloadedFileNoClobber(sourcePath, destinationPath, signal) - ).rejects.toThrow('window closed') - - await expect(readFile(sourcePath, 'utf8')).resolves.toBe('third-party mutation') - await expect(readFile(destinationPath, 'utf8')).resolves.toBe('third-party mutation') - }) - - it('does not claim a destination after cancellation', async () => { - const { tempPath, destinationPath } = await createPaths() - const controller = new AbortController() - controller.abort(new Error('window closed')) - - await expect( - promoteLocalDownloadedFolder(tempPath, destinationPath, controller.signal) - ).rejects.toThrow('window closed') - - await expect(readdir(tempPath)).resolves.toEqual(['nested']) - await expect(readdir(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) - }) - - it('rolls back unchanged entries after a mid-publication failure', async () => { - const { tempPath, destinationPath } = await createPaths() - await writeFile(join(tempPath, 'a-first.txt'), 'remote') - await symlink('a-first.txt', join(tempPath, 'z-unsupported-link')) - - await expect(promoteLocalDownloadedFolder(tempPath, destinationPath)).rejects.toThrow( - "Unexpected local download entry 'z-unsupported-link'" - ) - - await expect(readdir(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) - }) - - it('preserves a third-party file mutation when later publication fails', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-folder-promotion-')) - roots.push(root) - const tempPath = join(root, '.transfer.download') - const destinationPath = join(root, 'downloaded') - await mkdir(join(tempPath, 'b-work'), { recursive: true }) - await writeFile(join(tempPath, 'a-first.txt'), 'remote') - await Promise.all( - Array.from({ length: 200 }, (_, index) => - writeFile(join(tempPath, 'b-work', `${String(index).padStart(3, '0')}.txt`), 'remote') - ) - ) - await symlink('a-first.txt', join(tempPath, 'z-unsupported-link')) - - const promotion = promoteLocalDownloadedFolder(tempPath, destinationPath) - const failure = expect(promotion).rejects.toThrow( - "Unexpected local download entry 'z-unsupported-link'" - ) - const publishedFile = join(destinationPath, 'a-first.txt') - for (;;) { - try { - await readFile(publishedFile) - break - } catch { - await new Promise((resolve) => setImmediate(resolve)) - } - } - await writeFile(publishedFile, 'third-party mutation') - - await failure - await expect(readFile(publishedFile, 'utf8')).resolves.toBe('third-party mutation') - }) - - it('cancels between entries and rolls back unchanged published files', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-folder-promotion-')) - roots.push(root) - const tempPath = join(root, '.transfer.download') - const destinationPath = join(root, 'downloaded') - await mkdir(tempPath) - await Promise.all( - Array.from({ length: 300 }, (_, index) => - writeFile(join(tempPath, `${String(index).padStart(3, '0')}.txt`), 'remote') - ) - ) - const controller = new AbortController() - - const promotion = promoteLocalDownloadedFolder(tempPath, destinationPath, controller.signal) - const failure = expect(promotion).rejects.toThrow('window closed') - for (;;) { - try { - await readFile(join(destinationPath, '000.txt')) - break - } catch { - await new Promise((resolve) => setImmediate(resolve)) - } - } - controller.abort(new Error('window closed')) - - await failure - await expect(readdir(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) - }) - - it('cancels a chunked exclusive-copy fallback and removes its partial file', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-folder-promotion-')) - roots.push(root) - const sourcePath = join(root, 'large-source.bin') - const destinationPath = join(root, 'large-destination.bin') - const sourceHandle = await open(sourcePath, 'wx') - await sourceHandle.truncate(64 * 1024 * 1024) - await sourceHandle.close() - const controller = new AbortController() - - const copying = copyLocalDownloadedFileNoClobber(sourcePath, destinationPath, controller.signal) - const failure = expect(copying).rejects.toThrow('copy canceled') - for (;;) { - try { - await lstat(destinationPath) - break - } catch { - await new Promise((resolve) => setImmediate(resolve)) - } - } - controller.abort(new Error('copy canceled')) - - await failure - await expect(lstat(destinationPath)).rejects.toMatchObject({ code: 'ENOENT' }) - await expect(lstat(sourcePath)).resolves.toMatchObject({ size: 64 * 1024 * 1024 }) - }) -}) diff --git a/src/main/local-downloaded-folder-promotion.ts b/src/main/local-downloaded-folder-promotion.ts deleted file mode 100644 index f5ba45f7a5c..00000000000 --- a/src/main/local-downloaded-folder-promotion.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { fstatSync, lstatSync } from 'node:fs' -import { link, lstat, mkdir, open, readdir, rm, rmdir, unlink } from 'node:fs/promises' -import type { BigIntStats } from 'node:fs' -import type { FileHandle } from 'node:fs/promises' -import { join } from 'node:path' - -const LOCAL_COPY_CHUNK_BYTES = 1024 * 1024 - -type PublishedEntry = { - kind: 'directory' | 'file' - path: string - identity: Pick - fileState?: Pick -} - -function isEEXIST(error: unknown): boolean { - return ( - error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EEXIST' - ) -} - -function hasSameIdentity(current: BigIntStats, published: PublishedEntry): boolean { - const { identity } = published - const hasStableInode = identity.dev !== 0n || identity.ino !== 0n - if (hasStableInode) { - return current.dev === identity.dev && current.ino === identity.ino - } - return identity.birthtimeNs !== 0n && current.birthtimeNs === identity.birthtimeNs -} - -function hasSamePublishedFileState(current: BigIntStats, published: PublishedEntry): boolean { - const state = published.fileState - return ( - state === undefined || - (current.size === state.size && - current.mtimeNs === state.mtimeNs && - current.mode === state.mode) - ) -} - -function publishedEntryFromStats( - kind: PublishedEntry['kind'], - filePath: string, - stats: BigIntStats -): PublishedEntry { - return { - kind, - path: filePath, - identity: { dev: stats.dev, ino: stats.ino, birthtimeNs: stats.birthtimeNs }, - ...(kind === 'file' - ? { - fileState: { - size: stats.size, - mtimeNs: stats.mtimeNs, - mode: stats.mode - } - } - : {}) - } -} - -function updatePublishedFileState(entry: PublishedEntry, stats: BigIntStats): void { - Object.assign(entry, publishedEntryFromStats('file', entry.path, stats)) -} - -async function closeFileHandle(handle: FileHandle | undefined): Promise { - await handle?.close().catch(() => {}) -} - -async function copyTrackedLocalDownloadedFileNoClobber( - sourcePath: string, - destinationPath: string, - publishedEntries: PublishedEntry[], - signal?: AbortSignal -): Promise { - let sourceHandle: FileHandle | undefined - let destinationHandle: FileHandle | undefined - let record: PublishedEntry | undefined - try { - sourceHandle = await open(sourcePath, 'r') - destinationHandle = await open(destinationPath, 'wx') - record = publishedEntryFromStats( - 'file', - destinationPath, - fstatSync(destinationHandle.fd, { bigint: true }) - ) - publishedEntries.push(record) - const buffer = Buffer.allocUnsafe(LOCAL_COPY_CHUNK_BYTES) - let position = 0 - for (;;) { - signal?.throwIfAborted() - const { bytesRead } = await sourceHandle.read(buffer, 0, buffer.length, position) - if (bytesRead === 0) { - break - } - let written = 0 - while (written < bytesRead) { - signal?.throwIfAborted() - const result = await destinationHandle.write( - buffer, - written, - bytesRead - written, - position + written - ) - written += result.bytesWritten - } - position += bytesRead - } - updatePublishedFileState(record, await destinationHandle.stat({ bigint: true })) - } catch (error) { - if (record && destinationHandle) { - await destinationHandle - .stat({ bigint: true }) - .then((stats) => updatePublishedFileState(record!, stats)) - .catch(() => {}) - } - throw error - } finally { - await Promise.all([closeFileHandle(sourceHandle), closeFileHandle(destinationHandle)]) - } - await unlink(sourcePath) -} - -async function publishTrackedFileNoClobber( - sourcePath: string, - destinationPath: string, - publishedEntries: PublishedEntry[], - signal?: AbortSignal -): Promise { - const sourceStats = await lstat(sourcePath, { bigint: true }) - const hardLinkRecord = publishedEntryFromStats('file', destinationPath, sourceStats) - try { - await link(sourcePath, destinationPath) - } catch (error) { - if (isEEXIST(error)) { - throw error - } - await copyTrackedLocalDownloadedFileNoClobber( - sourcePath, - destinationPath, - publishedEntries, - signal - ) - return - } - // Why: hard links preserve dev+ino; register that known ownership first, - // then synchronously snapshot state without an async third-party window. - publishedEntries.push(hardLinkRecord) - updatePublishedFileState(hardLinkRecord, lstatSync(destinationPath, { bigint: true })) - signal?.throwIfAborted() - await unlink(sourcePath) -} - -export async function copyLocalDownloadedFileNoClobber( - sourcePath: string, - destinationPath: string, - signal?: AbortSignal -): Promise { - const publishedEntries: PublishedEntry[] = [] - try { - await copyTrackedLocalDownloadedFileNoClobber( - sourcePath, - destinationPath, - publishedEntries, - signal - ) - } catch (error) { - await rollbackPublishedEntries(publishedEntries) - throw error - } -} - -export async function publishLocalDownloadedFileNoClobber( - sourcePath: string, - destinationPath: string, - signal?: AbortSignal -): Promise { - const publishedEntries: PublishedEntry[] = [] - try { - await publishTrackedFileNoClobber(sourcePath, destinationPath, publishedEntries, signal) - } catch (error) { - await rollbackPublishedEntries(publishedEntries) - throw error - } -} - -async function rollbackPublishedEntries(entries: PublishedEntry[]): Promise { - for (const entry of entries.toReversed()) { - try { - const current = await lstat(entry.path, { bigint: true }) - if (!hasSameIdentity(current, entry)) { - continue - } - if (entry.kind === 'file') { - if (hasSamePublishedFileState(current, entry)) { - await unlink(entry.path) - } - } else { - // Why: rmdir removes only our still-empty directory; third-party children - // make it fail closed instead of being deleted recursively. - await rmdir(entry.path).catch(() => {}) - } - } catch { - // A missing or unreadable path is no longer safe for rollback to own. - } - } -} - -async function publishDirectoryNoClobber( - sourcePath: string, - destinationPath: string, - publishedEntries: PublishedEntry[], - signal?: AbortSignal -): Promise { - await mkdir(destinationPath, { recursive: false }) - // Why: capture the directory identity synchronously after the exclusive - // claim so no async gap exists before rollback ownership is registered. - const destinationStats = lstatSync(destinationPath, { bigint: true }) - publishedEntries.push(publishedEntryFromStats('directory', destinationPath, destinationStats)) - const entries = (await readdir(sourcePath, { withFileTypes: true })).toSorted((a, b) => - a.name.localeCompare(b.name) - ) - for (const entry of entries) { - signal?.throwIfAborted() - const sourceEntryPath = join(sourcePath, entry.name) - const destinationEntryPath = join(destinationPath, entry.name) - if (entry.isDirectory()) { - await publishDirectoryNoClobber( - sourceEntryPath, - destinationEntryPath, - publishedEntries, - signal - ) - } else if (entry.isFile()) { - await publishTrackedFileNoClobber( - sourceEntryPath, - destinationEntryPath, - publishedEntries, - signal - ) - } else { - throw new Error(`Unexpected local download entry '${entry.name}'`) - } - signal?.throwIfAborted() - } - signal?.throwIfAborted() -} - -export async function promoteLocalDownloadedFolder( - tempPath: string, - destinationPath: string, - signal?: AbortSignal -): Promise { - signal?.throwIfAborted() - const publishedEntries: PublishedEntry[] = [] - try { - // Why: Node has no portable atomic no-replace directory rename. Claiming - // the destination first preserves no-clobber while promotion stays local. - await publishDirectoryNoClobber(tempPath, destinationPath, publishedEntries, signal) - } catch (error) { - await rollbackPublishedEntries(publishedEntries) - if (isEEXIST(error)) { - throw new Error('Destination folder already exists') - } - throw error - } - await rm(tempPath, { recursive: true, force: true }).catch(() => {}) -} diff --git a/src/main/media/media-preview-protocol.test.ts b/src/main/media/media-preview-protocol.test.ts index 07683976b03..c005ba75eff 100644 --- a/src/main/media/media-preview-protocol.test.ts +++ b/src/main/media/media-preview-protocol.test.ts @@ -5,13 +5,15 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' -const { authorize, provider } = vi.hoisted(() => ({ - authorize: vi.fn(async (filePath: string) => filePath), - provider: vi.fn() +const { authorize } = vi.hoisted(() => ({ + authorize: vi.fn(async (filePath: string) => filePath) +})) +vi.mock('electron', () => ({ + protocol: { handle: vi.fn(), registerSchemesAsPrivileged: vi.fn() } +})) +vi.mock('../ipc/local-file-access-resolution', () => ({ + resolveLocalFileRequestPath: authorize })) -vi.mock('electron', () => ({ protocol: { handle: vi.fn(), registerSchemesAsPrivileged: vi.fn() } })) -vi.mock('../ipc/local-file-access-resolution', () => ({ resolveLocalFileRequestPath: authorize })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ requireSshFilesystemProvider: provider })) import { handleMediaPreviewRequest, readMediaPreview } from './media-preview-protocol' @@ -33,7 +35,6 @@ describe('media preview protocol', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-video-protocol-')) authorize.mockReset().mockImplementation(async (path) => path) - provider.mockReset() }) afterEach(async () => { emitter.emit('destroyed') @@ -88,30 +89,7 @@ describe('media preview protocol', () => { expect(readMediaPreview(event, { filePath: join(directory, 'secret.txt') }, store)).toBeNull() }) - it('serves SSH ranges on the execution host without reading a local path', async () => { - const readFileRange = vi.fn(async () => ({ bytes: Buffer.from([7, 8, 9]), bytesRead: 3 })) - provider.mockReturnValue({ stat: async () => ({ type: 'file', size: 10 }), readFileRange }) - const result = readMediaPreview( - event, - { filePath: '/remote/clip.mp4', connectionId: 'ssh-owner' }, - store - ) - if (!result) { - throw new Error('Missing preview') - } - const response = await handleMediaPreviewRequest( - new Request(result.mediaUrl, { headers: { range: 'bytes=7-' } }) - ) - expect([...new Uint8Array(await response.arrayBuffer())]).toEqual([7, 8, 9]) - expect(readFileRange).toHaveBeenCalledWith('/remote/clip.mp4', 7, 3) - expect(authorize).not.toHaveBeenCalled() - expect(provider).toHaveBeenCalledWith('ssh-owner') - }) - - it('does not try a local read when an SSH host is disconnected', async () => { - provider.mockImplementation(() => { - throw new Error('SSH disconnected') - }) + it('does not try a local read for an SSH host', async () => { const result = readMediaPreview( event, { filePath: '/remote/clip.mp4', connectionId: 'ssh-owner' }, diff --git a/src/main/media/media-preview-protocol.ts b/src/main/media/media-preview-protocol.ts index f37f1a91eee..f441644a991 100644 --- a/src/main/media/media-preview-protocol.ts +++ b/src/main/media/media-preview-protocol.ts @@ -6,8 +6,6 @@ import type { LocalFileAccess } from '../../shared/local-file-access' import { MEDIA_FILE_MIME_TYPES, MEDIA_PREVIEW_SCHEME } from '../../shared/media-file-extensions' import { resolveLocalFileRequestPath } from '../ipc/local-file-access-resolution' import { openLocalRegularFile } from '../ipc/filesystem/local-regular-file-read' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { readSshFileExplorerChunk } from '../runtime/ssh-file-explorer-chunk-read' import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort' import { createMediaRangeResponse } from './media-range-response' @@ -74,26 +72,9 @@ export async function handleMediaPreviewRequest(request: Request): Promise { - const chunk = await readSshFileExplorerChunk( - provider, - target.filePath, - stats.size, - offset, - length - ) - return Buffer.from(chunk.contentBase64, 'base64') - }, - close: async () => {} - }) + return new Response(null, { status: 404 }) } const filePath = await resolveLocalFileRequestPath(target.filePath, target.access, store) const { handle, stats } = await openLocalRegularFile(filePath) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 681dd3088a8..8f9a97671a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -175,6 +175,8 @@ export class StructuredAgentSessionHost { private now = (): number => this.deps.now?.() ?? Date.now() hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) + hasLoadedProviders = (): boolean => + [...this.sessions.values()].some(({ child }) => child !== null) sessionAgent = (sessionId: string) => this.deps.store.getRecord(sessionId)?.provider ?? null handleAdapterEvent = (event: Parameters[0]) => diff --git a/src/main/native-chat/ssh-transcript-fs-access.ts b/src/main/native-chat/ssh-transcript-fs-access.ts index 2b0c4424ae5..cfb786c3d44 100644 --- a/src/main/native-chat/ssh-transcript-fs-access.ts +++ b/src/main/native-chat/ssh-transcript-fs-access.ts @@ -1,31 +1,22 @@ -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { readRemoteTranscriptRange } from '../runtime/orchestration/worker-transcript-remote-range-read' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import type { SshTranscriptLocation } from './ssh-transcript-path' export type SshTranscriptHandle = { sshTranscript: SshTranscriptLocation } -// Why: the provider is looked up per call, so a reconnect is picked up and a disconnect throws -// instead of reading anything on this machine. -export async function statSshTranscript(location: SshTranscriptLocation) { - const stat = await requireSshFilesystemProvider(location.connectionId).stat(location.remotePath) - const mtimeMs = stat.mtimeMs ?? stat.mtime - return { size: stat.size, mtimeMs, ctimeMs: mtimeMs, dev: stat.dev ?? 0, ino: stat.ino ?? 0 } +// Why: this process serves no SSH filesystem, so an SSH transcript throws instead of reading +// anything on this machine. +export async function statSshTranscript(location: SshTranscriptLocation): Promise<{ + size: number + mtimeMs: number + ctimeMs: number + dev: number + ino: number +}> { + throw new SshHostNotServedError(location.connectionId) } export async function readSshTranscript( - handle: SshTranscriptHandle, - buffer: Buffer, - offset: number, - length: number, - position: number + handle: SshTranscriptHandle ): Promise<{ bytesRead: number; buffer: Buffer }> { - const { connectionId, remotePath } = handle.sshTranscript - const bytes = await readRemoteTranscriptRange( - requireSshFilesystemProvider(connectionId), - remotePath, - position, - length - ) - buffer.set(bytes, offset) - return { bytesRead: bytes.length, buffer } + throw new SshHostNotServedError(handle.sshTranscript.connectionId) } diff --git a/src/main/native-chat/subagent-tracker/subagent-tracker.test.ts b/src/main/native-chat/subagent-tracker/subagent-tracker.test.ts index bdccd49ad10..995fd0e49e0 100644 --- a/src/main/native-chat/subagent-tracker/subagent-tracker.test.ts +++ b/src/main/native-chat/subagent-tracker/subagent-tracker.test.ts @@ -194,7 +194,13 @@ describe('SubagentTracker', () => { groupOf: (id) => (id.startsWith('old-') ? `old-turn-${id.slice(4)}` : null), claimGroup: (groupId) => ({ entries: [ - { id: `old-${groupId.slice(9)}`, label: 'Old', state: 'completed', startedAt: 1, settledAt: 2 } + { + id: `old-${groupId.slice(9)}`, + label: 'Old', + state: 'completed', + startedAt: 1, + settledAt: 2 + } ], placement: groupId }) diff --git a/src/main/native-chat/wsl-transcript-fs-access.ts b/src/main/native-chat/wsl-transcript-fs-access.ts index 7fcac1d8950..e0dd53663a1 100644 --- a/src/main/native-chat/wsl-transcript-fs-access.ts +++ b/src/main/native-chat/wsl-transcript-fs-access.ts @@ -158,7 +158,7 @@ export function readTranscriptFile( signal?: AbortSignal ): Promise<{ bytesRead: number; buffer: Buffer }> { if (isSshTranscriptHandle(handle)) { - return readSshTranscript(handle, buffer, offset, length, position) + return readSshTranscript(handle) } // Handle kind decides before path spelling: a process-owned handle must never // hit the FileHandle branch even if a caller re-derives the path off-UNC. diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index 30e21462a8f..ca28cea4174 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -42,7 +42,12 @@ function failStartup(error: unknown): void { } // One-shot launch and stop commands must skip server startup and preflights. -if (process.argv[2] === WINDOWS_BREAKAWAY_LAUNCH_FLAG) { +if (process.argv[2] === '--orcad-structured-provider-load-check') { + void import('./orcad-structured-provider-smoke') + .then(({ smokeLoadOrcadStructuredProviders }) => smokeLoadOrcadStructuredProviders()) + .then(() => process.exit(0)) + .catch(failStartup) +} else if (process.argv[2] === WINDOWS_BREAKAWAY_LAUNCH_FLAG) { runWindowsBreakawayLaunchIfRequested(ORCAD_WINDOWS_BREAKAWAY_CONTRACT, process.argv) } else if ( process.argv[2] === ORCAD_COMPLETE_MANAGED_STOP_FLAG || diff --git a/src/main/orcad/orcad-idle-exit-monitor.test.ts b/src/main/orcad/orcad-idle-exit-monitor.test.ts index fbe31074a52..87ea58eefcf 100644 --- a/src/main/orcad/orcad-idle-exit-monitor.test.ts +++ b/src/main/orcad/orcad-idle-exit-monitor.test.ts @@ -100,6 +100,22 @@ describe('OrcadIdleExitMonitor', () => { expect(h.onIdle).not.toHaveBeenCalled() }) + it('restarts the quiet period when the final idle stop declines after an asynchronous check', async () => { + const h = harness() + await h.monitor.check() + const decision = Promise.withResolvers() + h.onIdle.mockImplementationOnce(() => decision.promise) + h.advance(100) + const checking = h.monitor.check() + await vi.waitFor(() => expect(h.onIdle).toHaveBeenCalledOnce()) + decision.resolve(false) + expect(await checking).toBe(false) + h.advance(100) + expect(await h.monitor.check()).toBe(false) + h.advance(100) + expect(await h.monitor.check()).toBe(true) + }) + it('polls often enough for a short test timeout and at most once a minute', () => { expect(resolveOrcadIdlePollMs(15 * 60_000)).toBe(60_000) expect(resolveOrcadIdlePollMs(2_000)).toBe(400) diff --git a/src/main/orcad/orcad-idle-exit-monitor.ts b/src/main/orcad/orcad-idle-exit-monitor.ts index 58a053adffc..4ddc0bf7329 100644 --- a/src/main/orcad/orcad-idle-exit-monitor.ts +++ b/src/main/orcad/orcad-idle-exit-monitor.ts @@ -19,7 +19,7 @@ export type OrcadIdleExitMonitorOptions = { probes: readonly OrcadIdleProbe[] /** Any client request restarts the quiet period, even one that came and went between checks. */ lastClientActivityAt: () => number - onIdle: (evidence: OrcadIdleExitEvidence) => void + onIdle: (evidence: OrcadIdleExitEvidence) => void | boolean | Promise now?: () => number pollMs?: number log?: (line: string) => void @@ -80,8 +80,17 @@ export class OrcadIdleExitMonitor { if (now - quietSince < this.options.timeoutMs) { return false } + if ( + (await this.options.onIdle({ + quietSince, + stoppedAt: now, + timeoutMs: this.options.timeoutMs + })) === false + ) { + this.quietSince = null + return false + } this.stop() - this.options.onIdle({ quietSince, stoppedAt: now, timeoutMs: this.options.timeoutMs }) return true } diff --git a/src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts b/src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts new file mode 100644 index 00000000000..e86bff86da6 --- /dev/null +++ b/src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import { + createRestTestRig, + foundRestTestChat, + IDLE_MS, + REST_TEST_SESSION, + REST_TEST_THREAD, + sweepOnce, + type RestTestRig +} from '../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { OrcadIdleExitMonitor } from './orcad-idle-exit-monitor' +import { createOrcadIdleProbes } from './orcad-managed-idle-exit' + +let rig: RestTestRig +const SERVER_IDLE_MS = 15 * 60_000 + +beforeEach(async () => { + rig = await createRestTestRig() + rig.host.collaboratorsForTests().lifetime.idleSweep.dispose() + await foundRestTestChat(rig) +}) + +afterEach(async () => { + await rig.dispose() +}) + +function idleMonitor() { + const onIdle = vi.fn() + const monitor = new OrcadIdleExitMonitor({ + timeoutMs: SERVER_IDLE_MS, + now: () => rig.clock.now, + log: () => {}, + lastClientActivityAt: () => 0, + onIdle, + probes: createOrcadIdleProbes( + { timeoutMs: SERVER_IDLE_MS, activationRoot: 'test-fence' }, + { + readClientActivity: () => ({ openConnections: 0, requestsInFlight: 0, lastRequestAt: 0 }), + listTerminals: async () => [], + countDaemonSessions: async () => 0, + hasDaemon: () => true, + hasChatProviders: () => rig.host.hasLoadedProviders(), + agentStates: () => [{ state: 'blocked' }], + hasStagedMigration: () => false, + automationsBusy: () => false, + activationFenceExists: async () => false + } + ) + }) + return { monitor, onIdle } +} + +function providerEvents() { + const events = rig.adapter.acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('no provider was started') + } + return events +} + +it('U2: keeps the provider and disconnected server alive while an approval is unanswered', async () => { + providerEvents().appendItem( + { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'approval-turn', ordinal: 70 }, + { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(REST_TEST_SESSION) + const { monitor, onIdle } = idleMonitor() + expect(await monitor.check()).toBe(false) + rig.clock.now += 7 * 24 * 60 * 60_000 + await sweepOnce(rig.host) + + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(await monitor.check()).toBe(false) + expect(onIdle).not.toHaveBeenCalled() +}) + +it('releases an idle provider after the existing window, then lets the disconnected server exit', async () => { + const { monitor, onIdle } = idleMonitor() + expect(await monitor.check()).toBe(false) + rig.clock.now += IDLE_MS - 1 + await sweepOnce(rig.host) + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(await monitor.check()).toBe(false) + + rig.clock.now += 2 + await sweepOnce(rig.host) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION) + expect(rig.host.hasLoadedProviders()).toBe(false) + expect(await monitor.check()).toBe(false) + rig.clock.now += SERVER_IDLE_MS + expect(await monitor.check()).toBe(true) + expect(onIdle).toHaveBeenCalledOnce() +}) + +it('never releases a quiet running turn or idle-exits its server', async () => { + providerEvents().appendItem( + { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'working', ordinal: 71 }, + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(REST_TEST_SESSION) + const { monitor, onIdle } = idleMonitor() + expect(await monitor.check()).toBe(false) + for (let pass = 0; pass < 3; pass += 1) { + rig.clock.now += 7 * 24 * 60 * 60_000 + await sweepOnce(rig.host) + expect(await monitor.check()).toBe(false) + } + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(onIdle).not.toHaveBeenCalled() +}) diff --git a/src/main/orcad/orcad-managed-idle-exit-host.ts b/src/main/orcad/orcad-managed-idle-exit-host.ts index f7a6d455d3b..e3826dcd712 100644 --- a/src/main/orcad/orcad-managed-idle-exit-host.ts +++ b/src/main/orcad/orcad-managed-idle-exit-host.ts @@ -15,6 +15,7 @@ import { } from './orcad-idle-stop-record' import type { OrcadShutdownTrigger } from './orcad-lifecycle' import type { OrcadIdleStopRecord } from '../../shared/orcad-idle-exit' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' let requestIdleShutdown: OrcadShutdownTrigger | null = null @@ -67,6 +68,8 @@ async function startOrcadManagedIdleExit( const { countLiveOrcadDaemonSessions, retireOrcadDaemonIfIdle } = await import('./orcad-daemon-retirement') const idleRecord = createIdleStopRecordOwnership(input.userDataPath) + const hasChatProviders = (): boolean => + getStructuredAgentSessionHost()?.hasLoadedProviders() ?? false const dispose = installOrcadManagedIdleExit({ config: input.config, ports: { @@ -75,14 +78,26 @@ async function startOrcadManagedIdleExit( countDaemonSessions: countLiveOrcadDaemonSessions, hasDaemon: () => getDaemonEndpointFacts() !== null, agentStates: input.agentStates, + hasChatProviders, hasStagedMigration: input.hasStagedMigration, automationsBusy: input.automationsBusy, activationFenceExists }, - stop: (evidence) => { - void stopForIdle(input, evidence, retireOrcadDaemonIfIdle).finally(() => - idleRecord.requestShutdown(requestIdleShutdown) - ) + stop: async (evidence) => { + await stopForIdle(input, evidence, retireOrcadDaemonIfIdle) + // Clients or providers may return while daemon retirement awaits its reply. + const activity = input.rpc.readClientActivity() + if ( + hasChatProviders() || + activity.openConnections > 0 || + activity.requestsInFlight > 0 || + activity.lastRequestAt > evidence.quietSince + ) { + discardOrcadIdleStopRecord(input.userDataPath) + return false + } + idleRecord.requestShutdown(requestIdleShutdown) + return true } }) input.registerCleanup(() => { diff --git a/src/main/orcad/orcad-managed-idle-exit-provider-race.test.ts b/src/main/orcad/orcad-managed-idle-exit-provider-race.test.ts new file mode 100644 index 00000000000..a5ac52b1612 --- /dev/null +++ b/src/main/orcad/orcad-managed-idle-exit-provider-race.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { ORCAD_MANAGED_ACTIVATION_ROOT_ENV } from '../../shared/orcad-idle-exit' +import { bindOrcadIdleShutdown, beginOrcadIdleExit } from './orcad-managed-idle-exit-host' +import { installOrcadManagedIdleExit } from './orcad-managed-idle-exit' +import type * as ManagedIdleExit from './orcad-managed-idle-exit' +import { retireOrcadDaemonIfIdle } from './orcad-daemon-retirement' +import { discardOrcadIdleStopRecord } from './orcad-idle-stop-record' + +const { hasLoadedProviders } = vi.hoisted(() => ({ hasLoadedProviders: vi.fn(() => false) })) + +vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => ({ hasLoadedProviders }) +})) +vi.mock('../ipc/pty', () => ({ getLocalPtyProvider: () => ({ listProcesses: async () => [] }) })) +vi.mock('../daemon/daemon-init', () => ({ getDaemonEndpointFacts: () => null })) +vi.mock('./orcad-daemon-retirement', () => ({ + countLiveOrcadDaemonSessions: vi.fn(async () => 0), + retireOrcadDaemonIfIdle: vi.fn() +})) +vi.mock('./orcad-idle-stop-record', () => ({ + consumeOrcadIdleStopRecord: vi.fn(() => null), + discardOrcadIdleStopRecord: vi.fn(), + writeOrcadIdleStopRecord: vi.fn() +})) +vi.mock('./orcad-managed-idle-exit', async (importOriginal) => ({ + ...(await importOriginal()), + installOrcadManagedIdleExit: vi.fn(() => () => {}) +})) + +beforeEach(() => { + vi.clearAllMocks() + hasLoadedProviders.mockReturnValue(false) + vi.stubEnv(ORCAD_MANAGED_ACTIVATION_ROOT_ENV, 'test-fence') +}) + +afterEach(() => vi.unstubAllEnvs()) + +it.each(['provider', 'client', 'request', 'recent request'])( + 'rechecks a returning %s after daemon retirement before requesting an idle shutdown', + async (returning) => { + const activity = { openConnections: 0, requestsInFlight: 0, lastRequestAt: 0 } + const retire = Promise.withResolvers<{ retirement: string; reason: null }>() + vi.mocked(retireOrcadDaemonIfIdle).mockImplementationOnce(async () => ({ + ...(await retire.promise), + retirement: 'retired', + liveSessions: 0 + })) + const shutdown = vi.fn(() => true) + bindOrcadIdleShutdown(shutdown) + await beginOrcadIdleExit('test-profile').start({ + rpc: { + readClientActivity: () => activity + }, + agentStates: () => [], + hasStagedMigration: () => false, + automationsBusy: () => false, + registerCleanup: () => {} + }) + const installed = vi.mocked(installOrcadManagedIdleExit).mock.calls.at(-1)?.[0] + if (!installed) { + throw new Error('idle monitor was not installed') + } + const stopping = installed.stop({ quietSince: 0, stoppedAt: 900_000, timeoutMs: 900_000 }) + hasLoadedProviders.mockReturnValue(returning === 'provider') + activity.openConnections = returning === 'client' ? 1 : 0 + activity.requestsInFlight = returning === 'request' ? 1 : 0 + activity.lastRequestAt = returning === 'recent request' ? 1 : 0 + retire.resolve({ retirement: 'retired', reason: null }) + + expect(await stopping).toBe(false) + expect(shutdown).not.toHaveBeenCalled() + expect(discardOrcadIdleStopRecord).toHaveBeenCalledWith('test-profile') + } +) diff --git a/src/main/orcad/orcad-managed-idle-exit.test.ts b/src/main/orcad/orcad-managed-idle-exit.test.ts index d14532b2ff0..3092f2c1311 100644 --- a/src/main/orcad/orcad-managed-idle-exit.test.ts +++ b/src/main/orcad/orcad-managed-idle-exit.test.ts @@ -23,6 +23,7 @@ function idlePorts(): OrcadManagedIdleExitPorts { countDaemonSessions: async () => 0, hasDaemon: () => true, agentStates: () => [{ state: 'done' }], + hasChatProviders: () => false, hasStagedMigration: () => false, automationsBusy: () => false, activationFenceExists: async () => false @@ -73,6 +74,7 @@ describe('createOrcadIdleProbes', () => { clients: 'idle', terminals: 'idle', agents: 'idle', + 'chat-providers': 'idle', migration: 'idle', automations: 'idle', activation: 'idle' @@ -97,6 +99,7 @@ describe('createOrcadIdleProbes', () => { ['an in-process terminal', { listTerminals: async () => [{ id: 'pty-1' }] }, 'terminals'], ['a live daemon session', { countDaemonSessions: async () => 2 }, 'terminals'], ['a working agent', { agentStates: () => [{ state: 'working' }] }, 'agents'], + ['a loaded chat provider', { hasChatProviders: () => true }, 'chat-providers'], ['a staged migration', { hasStagedMigration: () => true }, 'migration'], ['an enabled or running automation', { automationsBusy: () => true }, 'automations'], ['a held activation fence', { activationFenceExists: async () => true }, 'activation'] diff --git a/src/main/orcad/orcad-managed-idle-exit.ts b/src/main/orcad/orcad-managed-idle-exit.ts index 74229ef2a65..0963e0ad44a 100644 --- a/src/main/orcad/orcad-managed-idle-exit.ts +++ b/src/main/orcad/orcad-managed-idle-exit.ts @@ -46,6 +46,7 @@ export type OrcadManagedIdleExitPorts = { countDaemonSessions: () => Promise hasDaemon: () => boolean agentStates: () => readonly { state: string }[] + hasChatProviders: () => boolean hasStagedMigration: () => boolean /** An enabled schedule or an unsettled run; nothing would fire either once the host exits. */ automationsBusy: () => boolean @@ -84,6 +85,7 @@ export function createOrcadIdleProbes( name: 'agents', read: () => verdict(ports.agentStates().some((entry) => entry.state === 'working')) }, + { name: 'chat-providers', read: () => verdict(ports.hasChatProviders()) }, { name: 'migration', read: () => verdict(ports.hasStagedMigration()) }, { name: 'automations', read: () => verdict(ports.automationsBusy()) }, { @@ -110,7 +112,7 @@ export function installOrcadManagedIdleExit(input: { config: OrcadManagedIdleExitConfig ports: OrcadManagedIdleExitPorts /** Records the clean stop, then runs the same graceful shutdown as SIGTERM. */ - stop: (evidence: OrcadIdleExitEvidence) => void + stop: (evidence: OrcadIdleExitEvidence) => void | boolean | Promise }): () => void { const monitor = new OrcadIdleExitMonitor({ timeoutMs: input.config.timeoutMs, diff --git a/src/main/orcad/orcad-node-slot-fixture.ts b/src/main/orcad/orcad-node-slot-fixture.ts index 0b02950ff58..f1e3891ef3d 100644 --- a/src/main/orcad/orcad-node-slot-fixture.ts +++ b/src/main/orcad/orcad-node-slot-fixture.ts @@ -73,10 +73,11 @@ export async function writeNodeSlotFixture( return { slotDir, runtime } } -/** Hard-links the built `out/orcad` slot under `/orcad-test` with its pinned runtime beside it. */ +/** Installs a built or materialized slot under `/orcad-test` with its pinned runtime beside it. */ export function installPackagedOrcadSlotForTests( root: string, - pinnedNode: string + pinnedNode: string, + sourceDir = resolve('out/orcad') ): { slotDir: string; runtime: string } { const linkOrCopy = (from: string, to: string): void => { try { @@ -96,7 +97,7 @@ export function installPackagedOrcadSlotForTests( } } const slotDir = join(root, 'orcad-test') - installTree(resolve('out/orcad'), slotDir) + installTree(sourceDir, slotDir) const target = hostServerTarget() const runtime = resolve( slotDir, diff --git a/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts b/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts new file mode 100644 index 00000000000..6c24a299f41 --- /dev/null +++ b/src/main/orcad/orcad-packaged-structured-chat.integration.test.ts @@ -0,0 +1,355 @@ +import { randomUUID } from 'node:crypto' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, dirname, join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { getDefaultPersistedState } from '../../shared/constants' +import { folderWorkspaceKey } from '../../shared/workspace-scope' +import { spawnProcess } from '@orca/process-host' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentSessionSubscribeEvent } from '../../shared/agent-session-wire' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { parsePairingCode } from '../../shared/pairing' +import { + pairedRuntimeConnectionFromOffer, + type PairedRuntimeConnection +} from '../../shared/paired-runtime-connection' +import { + AGENT_SESSION_TURN_ITEM_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' +import { + sendRemoteRuntimeRequest, + subscribeRemoteRuntimeRequest, + type RemoteRuntimeSubscription +} from '../../shared/remote-runtime-client' +import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-session-id' +import { shellEscape } from '../ssh/ssh-connection-utils' +import { materializeOrcadArtifact } from '../ssh/orcad-artifact-materializer' +import { + hostServerTarget, + installPackagedOrcadSlotForTests, + locatePinnedNodeForTests, + skipForMissingInputs +} from './orcad-node-slot-fixture' +import { + killChildAndWait, + killProfileDaemons, + removeTestRoot +} from './orcad-daemon-teardown-fixture' +import { packagedClaudeChatScenario } from './orcad-structured-chat-scenario' + +const pinnedNode = locatePinnedNodeForTests() +const templateDir = process.env.ORCA_ORCAD_TEMPLATE_PATH ?? resolve('out/orcad-template') +const missing = [ + ...(!pinnedNode ? ['a pinned Node runtime'] : []), + ...(!existsSync(templateDir) ? [templateDir] : []) +] +const skip = skipForMissingInputs('artifact', missing) +const capabilities = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY +] +const Ready = z.object({ + pairing: z.object({ available: z.literal(true), url: z.string() }) +}) +const ChildReport = z.object({ + argv: z.array(z.string()), + execPath: z.string(), + cwd: z.string(), + env: z.record(z.string(), z.string()), + userMessages: z.array(z.object({ message: z.object({ content: z.unknown() }) })) +}) + +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) { + await killProfileDaemons(join(root, 'data')) + await removeTestRoot(root) + } +}) + +function launch(slotDir: string, runtime: string, userData: string, home: string) { + const child = spawnProcess({ + program: runtime, + args: [join(slotDir, 'orcad.js'), '--bind', '127.0.0.1', '--port', '0', '--json'], + cwd: home, + env: { + HOME: home, + CODEX_HOME: join(home, '.codex'), + CLAUDE_CONFIG_DIR: join(home, '.claude'), + XDG_CONFIG_HOME: join(home, '.config'), + XDG_DATA_HOME: join(home, '.local', 'share'), + PATH: [dirname(userData), dirname(runtime), '/usr/bin', '/bin'].join(delimiter), + SHELL: '/bin/sh', + LANG: 'en_US.UTF-8', + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_DISABLE_MACOS_LOGIN_SHELL: '1', + ORCA_USER_DATA: userData + }, + timeoutMs: null + }) + let output = '' + let stderr = '' + child.stderr.on('data', (data: Buffer) => (stderr += data.toString())) + const ready = new Promise((resolveReady, reject) => { + const timer = setTimeout(() => reject(new Error(`No server readiness: ${stderr}`)), 30_000) + child.once('error', (error) => { + clearTimeout(timer) + reject(error) + }) + child.once('exit', (code) => { + clearTimeout(timer) + reject(new Error(`Server exited ${code}: ${stderr}`)) + }) + child.stdout.on('data', (data: Buffer) => { + output += data.toString() + if (!output.includes('\n')) { + return + } + clearTimeout(timer) + try { + const parsed = Ready.parse(JSON.parse(output.split('\n')[0]!)) + const pairing = parsePairingCode(parsed.pairing.url) + if (!pairing) { + throw new Error('Invalid server pairing offer') + } + resolveReady(pairedRuntimeConnectionFromOffer(pairing)) + } catch (error) { + reject(error) + } + }) + }) + return { child, ready, stderr: () => stderr } +} + +async function call( + pairing: PairedRuntimeConnection, + method: string, + params: unknown +): Promise { + const response = await sendRemoteRuntimeRequest( + pairing, + method, + params, + 20_000, + undefined, + undefined, + capabilities + ) + if (!response.ok) { + throw new Error(`${method}: ${JSON.stringify(response.error)}`) + } + return response.result +} + +function envelope( + sessionId: string, + method: string, + fields: Record, + fence: number | null +) { + return { + sessionId, + clientOperationId: `${Date.now()}-${randomUUID().replaceAll('-', '')}`, + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, sessionId, fields }) + } +} + +describe.skipIf(skip || process.platform === 'win32')('packaged plain-Node structured chat', () => { + it('creates, streams and settles a Claude turn through the real paired RPC', async () => { + if (!pinnedNode) { + throw new Error('Missing pinned Node runtime') + } + const root = realpathSync( + mkdtempSync(join(process.platform === 'win32' ? tmpdir() : '/tmp', 'orca-chat-')) + ) + roots.push(root) + const home = join(root, 'home') + const userData = join(root, 'data') + mkdirSync(home) + mkdirSync(userData) + const materializedSlot = await materializeOrcadArtifact(hostServerTarget(), { + templateDir, + cacheRoot: join(root, 'artifacts') + }) + const { slotDir, runtime } = installPackagedOrcadSlotForTests( + root, + pinnedNode, + materializedSlot + ) + const script = join(root, 'claude-scripted.mjs') + const fakeCli = join(root, 'claude') + writeFileSync( + script, + readFileSync( + resolve('src/main/claude/__fixtures__/claude-agent-sdk-scripted-cli.mjs'), + 'utf8' + ) + ) + writeFileSync(fakeCli, `#!/bin/sh\nexec ${shellEscape(runtime)} ${shellEscape(script)} "$@"\n`) + chmodSync(fakeCli, 0o700) + const sessionId = randomUUID() + const scenarioPath = join(root, 'scenario.json') + const reportPath = join(root, 'provider-report.json') + const partialReplyAckPath = join(root, 'partial-reply-observed') + writeFileSync( + scenarioPath, + JSON.stringify( + packagedClaudeChatScenario(claudeSessionIdForOrcaSession(sessionId), partialReplyAckPath) + ) + ) + const state = getDefaultPersistedState(home) + state.settings = { + ...state.settings, + agentStatusHooksEnabled: false, + nativeChatInheritShellEnvironment: false, + agentCmdOverrides: { claude: fakeCli }, + agentDefaultEnv: { + claude: { + CLAUDE_CONFIG_DIR: join(home, 'claude-config'), + ORCA_SDK_CONTRACT_SCENARIO_PATH: scenarioPath, + ORCA_SDK_CONTRACT_REPORT_PATH: reportPath + } + } + } + writeFileSync(join(userData, 'orca-data.json'), JSON.stringify(state)) + const server = launch(slotDir, runtime, userData, home) + let subscription: RemoteRuntimeSubscription | undefined + try { + const pairing = await server.ready + const group = await call<{ group: { id: string } }>(pairing, 'projectGroup.create', { + name: 'Test folder' + }) + const folder = await call<{ folderWorkspace: { id: string } }>( + pairing, + 'folderWorkspace.create', + { + projectGroupId: group.group.id, + folderPath: home, + name: 'Chat test' + } + ) + const fields = { worktree: folderWorkspaceKey(folder.folderWorkspace.id), agent: 'claude' } + const created = await call(pairing, 'agentSession.create', { + ...fields, + envelope: envelope(sessionId, 'agentSession.create', fields, null) + }) + expect(created, server.stderr()).toMatchObject({ ok: true }) + const { + value: { fence } + } = z.object({ ok: z.literal(true), value: z.object({ fence: z.number() }) }).parse(created) + const items = new Map() + const observedText: string[] = [] + const errors: Error[] = [] + subscription = await subscribeRemoteRuntimeRequest( + pairing, + 'agentSession.subscribe', + { sessionId }, + 20_000, + { + onResponse(response) { + if (!response.ok) { + errors.push(new Error(JSON.stringify(response.error))) + return + } + const event = response.result + const rows = + event.type === 'batch' + ? event.batch.items + : event.type === 'snapshot' || event.type === 'reset' + ? event.page.items + : [] + for (const row of rows) { + items.set(row.itemId, row) + if (row.body?.kind === 'message' && row.body.role === 'assistant') { + observedText.push( + row.body.blocks.map((block) => (block.type === 'text' ? block.text : '')).join('') + ) + if (observedText.at(-1) === 'Packaged server ') { + writeFileSync(partialReplyAckPath, '') + } + } + } + }, + onError: (error) => errors.push(error) + }, + { clientCapabilities: capabilities } + ) + const body = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Hello packaged server' }] + } + expect( + await call(pairing, 'agentSession.send', { + body, + envelope: envelope(sessionId, 'agentSession.send', { body }, fence) + }), + server.stderr() + ).toMatchObject({ ok: true }) + await vi.waitFor( + () => { + expect(errors).toEqual([]) + expect(observedText).toContain('Packaged server ') + expect(observedText).toContain('Packaged server reply.') + expect([...items.values()].find((row) => row.body?.kind === 'turn')?.body).toMatchObject({ + state: 'completed', + outcome: 'success' + }) + }, + { timeout: 15_000 } + ) + const report = ChildReport.parse(JSON.parse(readFileSync(reportPath, 'utf8'))) + expect(report.argv[0]).toBe(script) + expect(report.argv).toEqual( + expect.arrayContaining([ + '--input-format', + 'stream-json', + '--output-format', + '--include-partial-messages', + '--replay-user-messages', + '--permission-prompt-tool', + 'stdio', + `--session-id=${claudeSessionIdForOrcaSession(sessionId)}`, + '--setting-sources=user,project,local', + '--dangerously-skip-permissions' + ]) + ) + expect(report.execPath).toBe(runtime) + expect(report.cwd).toBe(home) + expect(report.env).toMatchObject({ + HOME: home, + ORCA_AGENT_SESSION_ID: sessionId, + ORCA_STRUCTURED_SESSION: '1', + ORCA_USER_DATA_PATH: userData, + ORCA_SDK_CONTRACT_SCENARIO_PATH: scenarioPath, + ORCA_SDK_CONTRACT_REPORT_PATH: reportPath + }) + expect(report.env.CLAUDE_CONFIG_DIR).toBe(join(home, 'claude-config')) + expect(report.env).not.toHaveProperty('ORCA_PANE_KEY') + expect(report.userMessages).toHaveLength(1) + expect(report.userMessages[0]?.message.content).toEqual([ + { type: 'text', text: 'Hello packaged server' } + ]) + // Follow-up #26464: assert ORCA_CLI_COMMAND executes the packaged host CLI. + } finally { + subscription?.close() + await killChildAndWait(server.child) + } + }, 90_000) +}) diff --git a/src/main/orcad/orcad-structured-chat-scenario.ts b/src/main/orcad/orcad-structured-chat-scenario.ts new file mode 100644 index 00000000000..26699b87392 --- /dev/null +++ b/src/main/orcad/orcad-structured-chat-scenario.ts @@ -0,0 +1,69 @@ +import { randomUUID } from 'node:crypto' + +/** A credential-free reply in the real CLI's partial-message cadence. */ +export function packagedClaudeChatScenario(providerSessionId: string, partialReplyAckPath: string) { + const frame = (fields: Record) => ({ + uuid: randomUUID(), + session_id: providerSessionId, + parent_tool_use_id: null, + ...fields + }) + const event = (fields: Record) => frame({ type: 'stream_event', event: fields }) + const message = { + id: 'packaged-assistant', + role: 'assistant', + content: [{ type: 'text', text: 'Packaged server reply.' }] + } + return { + controlResponses: { + initialize: { + commands: [], + models: [{ value: 'sonnet', displayName: 'Sonnet', description: 'Test model' }] + } + }, + steps: [ + { awaitUserMessage: true }, + { emit: event({ type: 'message_start', message: { ...message, content: [] } }) }, + { + emit: event({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' } + }) + }, + { + emit: event({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Packaged server ' } + }) + }, + { awaitFile: { path: partialReplyAckPath, timeoutMs: 10_000 } }, + { + emit: event({ + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'reply.' } + }) + }, + { emit: frame({ type: 'assistant', message }) }, + { emit: event({ type: 'content_block_stop', index: 0 }) }, + { emit: event({ type: 'message_stop' }) }, + { + emit: frame({ + type: 'result', + subtype: 'success', + is_error: false, + duration_ms: 150, + duration_api_ms: 1, + num_turns: 1, + result: 'Packaged server reply.', + total_cost_usd: 0, + usage: { input_tokens: 1, output_tokens: 4 } + }) + }, + // Context usage is a control request issued after the result. + { delayMs: 250 } + ] + } +} diff --git a/src/main/orcad/orcad-structured-provider-smoke.ts b/src/main/orcad/orcad-structured-provider-smoke.ts new file mode 100644 index 00000000000..7e64c62f38a --- /dev/null +++ b/src/main/orcad/orcad-structured-provider-smoke.ts @@ -0,0 +1,22 @@ +import { openClaudeStreamJsonConnection } from '../claude/claude-stream-json-connection' + +export async function smokeLoadOrcadStructuredProviders(): Promise { + const loaded = new Error('structured provider module loaded') + try { + await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'unused', cwd: process.cwd(), options: {} }, + {}, + undefined, + () => { + // The production connection loads the SDK before calling the query implementation. + throw loaded + } + ) + } catch (error) { + if (error === loaded) { + return + } + throw error + } + throw new Error('structured provider load check did not reach the SDK boundary') +} diff --git a/src/main/persistence-repo-lifecycle.test.ts b/src/main/persistence-repo-lifecycle.test.ts index fdfe9f7a699..e954cd05c20 100644 --- a/src/main/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence-repo-lifecycle.test.ts @@ -784,7 +784,7 @@ describe('Store', () => { it('reassignSshTargetId re-points a repo that only carries connectionId (no executionHostId)', async () => { const store = await createStore() - // SSH repos created via addRemoteRepoFromPath leave executionHostId unset. + // Legacy SSH repo rows carry only connectionId and leave executionHostId unset. store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old' })) const repoIds = store.reassignSshTargetId('ssh-old', 'ssh-new') diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts index c996dc445f2..8099496fabf 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts @@ -75,7 +75,7 @@ export function reassignSshTargetId( if (matchesConnection) { repo.connectionId = newTargetId } - // Why: don't stamp executionHostId where it was unset — addRemoteRepoFromPath repos derive the host from connectionId. + // Why: don't stamp executionHostId where it was unset — legacy SSH repo rows derive the host from connectionId. if (matchesHost) { repo.executionHostId = newHostId } diff --git a/src/main/project-groups/folder-workspace-path-status.test.ts b/src/main/project-groups/folder-workspace-path-status.test.ts index 25314576bc3..dc42919ec88 100644 --- a/src/main/project-groups/folder-workspace-path-status.test.ts +++ b/src/main/project-groups/folder-workspace-path-status.test.ts @@ -2,13 +2,12 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { getFolderWorkspacePathStatus, getFolderWorkspacePathStatusForPath, inferFolderWorkspacePathConnection } from './folder-workspace-path-status' -import type { IFilesystemProvider } from '../providers/types' import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' @@ -48,28 +47,26 @@ describe('folder workspace path status', () => { await writeFile(filePath, 'hello') await expect( - getFolderWorkspacePathStatusForPath( - { - folderPath: root, - projectGroupId: 'group-1', - projectGroups: [makeGroup({ parentPath: root })], - repos: [] - }, - { getSshFilesystemProvider: () => undefined } - ) + getFolderWorkspacePathStatusForPath({ + folderPath: root, + projectGroupId: 'group-1', + projectGroups: [makeGroup({ parentPath: root })], + repos: [] + }) ).resolves.toEqual({ path: root, exists: true }) await expect( - getFolderWorkspacePathStatusForPath( - { - folderPath: filePath, - projectGroupId: 'group-1', - projectGroups: [makeGroup({ parentPath: filePath })], - repos: [] - }, - { getSshFilesystemProvider: () => undefined } - ) - ).resolves.toEqual({ path: filePath, exists: false, reason: 'not-directory' }) + getFolderWorkspacePathStatusForPath({ + folderPath: filePath, + projectGroupId: 'group-1', + projectGroups: [makeGroup({ parentPath: filePath })], + repos: [] + }) + ).resolves.toEqual({ + path: filePath, + exists: false, + reason: 'not-directory' + }) } finally { await rm(root, { recursive: true, force: true }) } @@ -79,69 +76,28 @@ describe('folder workspace path status', () => { const missingPath = join(tmpdir(), `orca-folder-status-missing-${randomUUID()}`) await expect( - getFolderWorkspacePathStatusForPath( - { - folderPath: missingPath, - projectGroupId: 'group-1', - projectGroups: [makeGroup({ parentPath: missingPath })], - repos: [] - }, - { getSshFilesystemProvider: () => undefined } - ) + getFolderWorkspacePathStatusForPath({ + folderPath: missingPath, + projectGroupId: 'group-1', + projectGroups: [makeGroup({ parentPath: missingPath })], + repos: [] + }) ).resolves.toEqual({ path: missingPath, exists: false, reason: 'missing' }) }) - it('routes inferred SSH folder scopes through the SSH filesystem provider', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 }) - } as unknown as IFilesystemProvider - + it('reports unavailable for an inferred SSH scope, which this process does not serve', async () => { await expect( - getFolderWorkspacePathStatusForPath( - { - folderPath: '/workspace/platform', - projectGroupId: 'group-1', - projectGroups: [makeGroup()], - repos: [makeRepo({ connectionId: 'ssh-1' })] - }, - { getSshFilesystemProvider: () => provider } - ) - ).resolves.toEqual({ path: '/workspace/platform', exists: true }) - expect(provider.stat).toHaveBeenCalledWith('/workspace/platform') - }) - - it('routes explicit SSH folder scopes through SSH without child repos', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 }) - } as unknown as IFilesystemProvider - - await expect( - getFolderWorkspacePathStatusForPath( - { - folderPath: '/workspace/platform', - projectGroupId: 'group-1', - connectionId: 'ssh-1', - projectGroups: [makeGroup({ connectionId: 'ssh-1' })], - repos: [] - }, - { getSshFilesystemProvider: () => provider } - ) - ).resolves.toEqual({ path: '/workspace/platform', exists: true }) - expect(provider.stat).toHaveBeenCalledWith('/workspace/platform') - }) - - it('reports unavailable when an inferred SSH provider is missing', async () => { - await expect( - getFolderWorkspacePathStatusForPath( - { - folderPath: '/workspace/platform', - projectGroupId: 'group-1', - projectGroups: [makeGroup()], - repos: [makeRepo({ connectionId: 'ssh-1' })] - }, - { getSshFilesystemProvider: () => undefined } - ) - ).resolves.toEqual({ path: '/workspace/platform', exists: false, reason: 'unavailable' }) + getFolderWorkspacePathStatusForPath({ + folderPath: '/workspace/platform', + projectGroupId: 'group-1', + projectGroups: [makeGroup()], + repos: [makeRepo({ connectionId: 'ssh-1' })] + }) + ).resolves.toEqual({ + path: '/workspace/platform', + exists: false, + reason: 'unavailable' + }) }) it('reports ambiguous connection for mixed SSH scopes', () => { @@ -187,11 +143,7 @@ describe('folder workspace path status', () => { ).toEqual({ kind: 'ambiguous' }) }) - it('supports direct path scope without a persisted project group', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 }) - } as unknown as IFilesystemProvider - + it('reports a direct SSH path scope unavailable without a persisted project group', async () => { await expect( getFolderWorkspacePathStatus( { @@ -199,44 +151,39 @@ describe('folder workspace path status', () => { getProjectGroups: () => [], getFolderWorkspaces: () => [] }, - { scope: 'path', path: '/workspace/platform', connectionId: 'ssh-1' }, - { getSshFilesystemProvider: () => provider } + { scope: 'path', path: '/workspace/platform', connectionId: 'ssh-1' } ) - ).resolves.toEqual({ path: '/workspace/platform', exists: true }) - expect(provider.stat).toHaveBeenCalledWith('/workspace/platform') + ).resolves.toEqual({ + path: '/workspace/platform', + exists: false, + reason: 'unavailable' + }) }) - it('keeps explicit SSH scopes isolated from unrelated same-path SSH repos', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 1 }) - } as unknown as IFilesystemProvider - - await expect( - getFolderWorkspacePathStatusForPath( - { - folderPath: '/workspace/platform', - projectGroupId: 'group-1', - connectionId: 'ssh-1', - projectGroups: [ - makeGroup({ id: 'group-1', connectionId: 'ssh-1' }), - makeGroup({ id: 'group-2', connectionId: 'ssh-2' }) - ], - repos: [ - makeRepo({ id: 'repo-1', path: '/workspace/platform/api', connectionId: 'ssh-1' }), - makeRepo({ - id: 'repo-2', - path: '/workspace/platform/api', - projectGroupId: 'group-2', - connectionId: 'ssh-2' - }) - ] - }, - { - getSshFilesystemProvider: (connectionId) => - connectionId === 'ssh-1' ? provider : undefined - } - ) - ).resolves.toEqual({ path: '/workspace/platform', exists: true }) - expect(provider.stat).toHaveBeenCalledWith('/workspace/platform') + it('keeps explicit SSH scopes isolated from unrelated same-path SSH repos', () => { + expect( + inferFolderWorkspacePathConnection({ + folderPath: '/workspace/platform', + projectGroupId: 'group-1', + connectionId: 'ssh-1', + projectGroups: [ + makeGroup({ id: 'group-1', connectionId: 'ssh-1' }), + makeGroup({ id: 'group-2', connectionId: 'ssh-2' }) + ], + repos: [ + makeRepo({ + id: 'repo-1', + path: '/workspace/platform/api', + connectionId: 'ssh-1' + }), + makeRepo({ + id: 'repo-2', + path: '/workspace/platform/api', + projectGroupId: 'group-2', + connectionId: 'ssh-2' + }) + ] + }) + ).toEqual({ kind: 'ssh', connectionId: 'ssh-1' }) }) }) diff --git a/src/main/project-groups/folder-workspace-path-status.ts b/src/main/project-groups/folder-workspace-path-status.ts index ef146482f32..2e508205bba 100644 --- a/src/main/project-groups/folder-workspace-path-status.ts +++ b/src/main/project-groups/folder-workspace-path-status.ts @@ -8,7 +8,6 @@ import { getProjectGroupSubtreeIds } from '../../shared/project-groups' 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 { IFilesystemProvider } from '../providers/types' type FolderWorkspacePathStatusStore = { getRepos: () => Repo[] @@ -21,10 +20,6 @@ export type FolderWorkspacePathConnectionResolution = | { kind: 'ssh'; connectionId: string } | { kind: 'ambiguous' } -type FolderWorkspacePathStatusDeps = { - getSshFilesystemProvider: (connectionId: string) => IFilesystemProvider | undefined -} - function getFolderScopeCandidateRepos(args: { folderPath: string projectGroupId?: string | null @@ -106,25 +101,14 @@ function pathStatErrorReason(error: unknown): 'missing' | 'unavailable' { async function statFolderPath( path: string, - connection: FolderWorkspacePathConnectionResolution, - deps: FolderWorkspacePathStatusDeps + connection: FolderWorkspacePathConnectionResolution ): Promise { if (connection.kind === 'ambiguous') { return { path, exists: false, reason: 'ambiguous-connection' } } if (connection.kind === 'ssh') { - const provider = deps.getSshFilesystemProvider(connection.connectionId) - if (!provider) { - return { path, exists: false, reason: 'unavailable' } - } - try { - const stats = await provider.stat(path) - return stats.type === 'directory' - ? { path, exists: true } - : { path, exists: false, reason: 'not-directory' } - } catch (error) { - return { path, exists: false, reason: pathStatErrorReason(error) } - } + // Why: this process serves no SSH filesystem. + return { path, exists: false, reason: 'unavailable' } } try { @@ -137,18 +121,15 @@ async function statFolderPath( } } -export async function getFolderWorkspacePathStatusForPath( - args: { - folderPath: string - projectGroupId?: string | null - connectionId?: string | null - projectGroups: readonly ProjectGroup[] - repos: readonly Repo[] - }, - deps: FolderWorkspacePathStatusDeps -): Promise { +export async function getFolderWorkspacePathStatusForPath(args: { + folderPath: string + projectGroupId?: string | null + connectionId?: string | null + projectGroups: readonly ProjectGroup[] + repos: readonly Repo[] +}): Promise { const connection = inferFolderWorkspacePathConnection(args) - return statFolderPath(args.folderPath, connection, deps) + return statFolderPath(args.folderPath, connection) } export function resolveFolderWorkspaceStatusPath(args: { @@ -196,20 +177,16 @@ export function resolveFolderWorkspaceStatusPath(args: { export async function getFolderWorkspacePathStatus( store: FolderWorkspacePathStatusStore, - request: FolderWorkspacePathStatusRequest, - deps: FolderWorkspacePathStatusDeps + request: FolderWorkspacePathStatusRequest ): Promise { const scope = resolveFolderWorkspaceStatusPath({ store, request }) - return getFolderWorkspacePathStatusForPath( - { - folderPath: scope.folderPath, - projectGroupId: scope.projectGroupId, - connectionId: scope.connectionId, - projectGroups: store.getProjectGroups?.() ?? [], - repos: store.getRepos() - }, - deps - ) + return getFolderWorkspacePathStatusForPath({ + folderPath: scope.folderPath, + projectGroupId: scope.projectGroupId, + connectionId: scope.connectionId, + projectGroups: store.getProjectGroups?.() ?? [], + repos: store.getRepos() + }) } /** The code a folder workspace refuses this path with, or null when the path is usable. */ diff --git a/src/main/project-groups/nested-repo-import-target.test.ts b/src/main/project-groups/nested-repo-import-target.test.ts index c630e102fb3..865464e853d 100644 --- a/src/main/project-groups/nested-repo-import-target.test.ts +++ b/src/main/project-groups/nested-repo-import-target.test.ts @@ -4,8 +4,7 @@ import type { GitWorktreeInfo } from '../../shared/worktree/types' import { listWorktreeGraph } from '../git/worktree' import { createNestedRepoImportTargetResolver, - resolveLocalNestedRepoImportTargetPath, - resolveSshNestedRepoImportTargetPath + resolveLocalNestedRepoImportTargetPath } from './nested-repo-import-target' vi.mock('../git/worktree', () => ({ @@ -94,23 +93,4 @@ describe('nested repo import target resolution', () => { await expect(resolveLocalNestedRepoImportTargetPath(selectedPath)).resolves.toBe(selectedPath) }) - - it('uses the SSH provider worktree list for remote import targets', async () => { - const mainPath = join('/srv', 'source', 'demo') - const selectedPath = join('/srv', 'paseo', 'demo', 'brash-binder') - const gitProvider = { - listWorktrees: vi - .fn() - .mockResolvedValue([ - worktree({ path: mainPath, isMainWorktree: true }), - worktree({ path: selectedPath, branch: 'refs/heads/brash-binder' }) - ]) - } - - await expect(resolveSshNestedRepoImportTargetPath(selectedPath, gitProvider)).resolves.toBe( - mainPath - ) - expect(gitProvider.listWorktrees).toHaveBeenCalledWith(selectedPath) - expect(listWorktreeGraph).not.toHaveBeenCalled() - }) }) diff --git a/src/main/project-groups/nested-repo-import-target.ts b/src/main/project-groups/nested-repo-import-target.ts index be722716856..6dbc7f782c0 100644 --- a/src/main/project-groups/nested-repo-import-target.ts +++ b/src/main/project-groups/nested-repo-import-target.ts @@ -2,13 +2,8 @@ import type { GitWorktreeInfo } from '../../shared/worktree/types' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { listWorktreeGraph } from '../git/worktree' -type WorktreeLister = { - listWorktrees: (repoPath: string) => Promise -} - export type NestedRepoImportTargetResolver = { resolveLocal: (repoPath: string) => Promise - resolveSsh: (repoPath: string, gitProvider: WorktreeLister) => Promise } function findImportTarget( @@ -59,29 +54,12 @@ async function resolveWithCache( export function createNestedRepoImportTargetResolver(): NestedRepoImportTargetResolver { const localCache = new Map() - const sshCaches = new WeakMap>() - return { resolveLocal: (repoPath) => - resolveWithCache(repoPath, localCache, (path) => listWorktreeGraph(path)), - resolveSsh: (repoPath, gitProvider) => { - let cache = sshCaches.get(gitProvider) - if (!cache) { - cache = new Map() - sshCaches.set(gitProvider, cache) - } - return resolveWithCache(repoPath, cache, (path) => gitProvider.listWorktrees(path)) - } + resolveWithCache(repoPath, localCache, (path) => listWorktreeGraph(path)) } } export async function resolveLocalNestedRepoImportTargetPath(repoPath: string): Promise { return createNestedRepoImportTargetResolver().resolveLocal(repoPath) } - -export async function resolveSshNestedRepoImportTargetPath( - repoPath: string, - gitProvider: WorktreeLister -): Promise { - return createNestedRepoImportTargetResolver().resolveSsh(repoPath, gitProvider) -} diff --git a/src/main/providers/execution-host-provider-dispatch.test.ts b/src/main/providers/execution-host-provider-dispatch.test.ts index 1197e8cc97d..69b2b2ac235 100644 --- a/src/main/providers/execution-host-provider-dispatch.test.ts +++ b/src/main/providers/execution-host-provider-dispatch.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { ExecutionHostNotDispatchableError, requireReachableFilesystemRoute, @@ -9,23 +9,11 @@ import { } from './execution-host-provider-dispatch' import { createLocalFilesystemProvider } from './local-filesystem-provider' import { createLocalGitProvider } from './local-git-provider' -import { registerSshGitProvider, unregisterSshGitProvider } from './ssh-git-dispatch' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from './ssh-filesystem-dispatch' import { SSH_HOST_NOT_SERVED_MESSAGE, SshHostNotServedError } from './ssh-host-not-served-error' const connectionId = 'host-dispatch-target' -const gitProvider = { listWorktrees: async () => [] } as never -const filesystemProvider = { readDir: async () => [] } as never describe('execution host provider dispatch', () => { - afterEach(() => { - unregisterSshGitProvider(connectionId) - unregisterSshFilesystemProvider(connectionId) - }) - it('routes `local` to a per-call local provider factory, never a cached provider', () => { expect(resolveGitRouteForHost('local')).toEqual({ kind: 'local', @@ -39,11 +27,12 @@ describe('execution host provider dispatch', () => { }) }) - it('routes an ssh host to a provider-less entry, even with a registered provider', () => { - registerSshGitProvider(connectionId, gitProvider) - registerSshFilesystemProvider(connectionId, filesystemProvider) - - const expected = { kind: 'ssh', hostId: `ssh:${connectionId}`, connectionId } + it('routes an ssh host to a provider-less entry', () => { + const expected = { + kind: 'ssh', + hostId: `ssh:${connectionId}`, + connectionId + } expect(resolveGitRouteForHost(`ssh:${connectionId}`)).toStrictEqual(expected) expect(resolveFilesystemRouteForHost(`ssh:${connectionId}`)).toStrictEqual(expected) }) @@ -76,24 +65,17 @@ describe('execution host provider dispatch', () => { }) // Characterization: the message is the one an unreachable ssh host already showed. - it.each([false, true])( - 'throws the typed not-served error for an ssh host (provider registered: %s)', - (registered) => { - if (registered) { - registerSshGitProvider(connectionId, gitProvider) - registerSshFilesystemProvider(connectionId, filesystemProvider) - } - for (const require of [requireReachableGitRoute, requireReachableFilesystemRoute]) { - expect(() => require(`ssh:${connectionId}`)).toThrow(SshHostNotServedError) - expect(() => require(`ssh:${connectionId}`)).toThrow( - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' - ) - } - expect(SSH_HOST_NOT_SERVED_MESSAGE).toBe( + it('throws the typed not-served error for an ssh host', () => { + for (const require of [requireReachableGitRoute, requireReachableFilesystemRoute]) { + expect(() => require(`ssh:${connectionId}`)).toThrow(SshHostNotServedError) + expect(() => require(`ssh:${connectionId}`)).toThrow( 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' ) } - ) + expect(SSH_HOST_NOT_SERVED_MESSAGE).toBe( + 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' + ) + }) it('never hands an ssh host a local provider factory', () => { expect(resolveGitRouteForHost(`ssh:${connectionId}`)).not.toHaveProperty('createProvider') diff --git a/src/main/providers/filesystem-markdown-listing.test.ts b/src/main/providers/filesystem-markdown-listing.test.ts index e010ab9cc95..abdce6de723 100644 --- a/src/main/providers/filesystem-markdown-listing.test.ts +++ b/src/main/providers/filesystem-markdown-listing.test.ts @@ -18,3 +18,24 @@ it('retains the path-list fallback for providers without native markdown listing expect(documents.map((document) => document.relativePath)).toEqual(['a.md', 'b.mdx']) expect(listFiles).toHaveBeenCalledWith('/repo') }) + +it('keeps late Markdown documents from path-list providers with large source inventories', async () => { + const paths = Array.from({ length: 25_002 }, (_, index) => `src/file-${index}.ts`) + paths.push('docs/late.md') + const listFiles = vi.fn(async () => paths) + await expect(listFilesystemMarkdownDocuments({ listFiles }, '/repo')).resolves.toEqual([ + { + filePath: '/repo/docs/late.md', + relativePath: 'docs/late.md', + basename: 'late.md', + name: 'late' + } + ]) +}) + +it('still bounds path-list inventories before constructing Markdown metadata', async () => { + const listFiles = vi.fn(async () => [`${'x'.repeat(65_537)}.ts`, 'README.md']) + await expect(listFilesystemMarkdownDocuments({ listFiles }, '/repo')).rejects.toThrow( + 'File inventory is too large' + ) +}) diff --git a/src/main/providers/ssh-filesystem-dispatch.test.ts b/src/main/providers/ssh-filesystem-dispatch.test.ts deleted file mode 100644 index 9b932be8c38..00000000000 --- a/src/main/providers/ssh-filesystem-dispatch.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { - getSshFilesystemProvider, - onSshFilesystemProviderRegistered, - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from './ssh-filesystem-dispatch' -import type { IFilesystemProvider } from './types' - -const provider = {} as IFilesystemProvider - -describe('onSshFilesystemProviderRegistered', () => { - it('notifies subscribers on every registration, including a reconnect replacing the provider', () => { - const listener = vi.fn() - const unsubscribe = onSshFilesystemProviderRegistered(listener) - - registerSshFilesystemProvider('conn-1', provider) - registerSshFilesystemProvider('conn-1', {} as IFilesystemProvider) - - expect(listener).toHaveBeenCalledTimes(2) - expect(listener).toHaveBeenNthCalledWith(1, 'conn-1') - expect(listener).toHaveBeenNthCalledWith(2, 'conn-1') - - unsubscribe() - unregisterSshFilesystemProvider('conn-1') - }) - - it('exposes the new provider to subscribers while they are being notified', () => { - let seen: IFilesystemProvider | undefined - const unsubscribe = onSshFilesystemProviderRegistered((connectionId) => { - seen = getSshFilesystemProvider(connectionId) - }) - - registerSshFilesystemProvider('conn-2', provider) - - expect(seen).toBe(provider) - unsubscribe() - unregisterSshFilesystemProvider('conn-2') - }) - - it('stops notifying after unsubscribe', () => { - const listener = vi.fn() - onSshFilesystemProviderRegistered(listener)() - - registerSshFilesystemProvider('conn-3', provider) - - expect(listener).not.toHaveBeenCalled() - unregisterSshFilesystemProvider('conn-3') - }) - - it('keeps registration working when a subscriber throws', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const healthy = vi.fn() - const unsubscribeThrower = onSshFilesystemProviderRegistered(() => { - throw new Error('subscriber blew up') - }) - const unsubscribeHealthy = onSshFilesystemProviderRegistered(healthy) - - expect(() => registerSshFilesystemProvider('conn-4', provider)).not.toThrow() - expect(getSshFilesystemProvider('conn-4')).toBe(provider) - expect(healthy).toHaveBeenCalledWith('conn-4') - - unsubscribeThrower() - unsubscribeHealthy() - unregisterSshFilesystemProvider('conn-4') - warnSpy.mockRestore() - }) -}) diff --git a/src/main/providers/ssh-filesystem-dispatch.ts b/src/main/providers/ssh-filesystem-dispatch.ts index 04ef209bef1..8df49d0ceb5 100644 --- a/src/main/providers/ssh-filesystem-dispatch.ts +++ b/src/main/providers/ssh-filesystem-dispatch.ts @@ -1,9 +1,9 @@ import type { IFilesystemProvider } from './types' -const sshProviders = new Map() +// Why still here: only the remote file-watcher subsystem reads it, and nothing in production +// registers a provider; that subsystem and this table go together in their own change. -export const SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE = - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' +const sshProviders = new Map() // Why: a reconnect builds a fresh provider, so anything holding remote state tied to the old // transport (file watches) needs a signal to rebuild it — nothing else marks that boundary. @@ -40,11 +40,3 @@ export function unregisterSshFilesystemProvider(connectionId: string): void { export function getSshFilesystemProvider(connectionId: string): IFilesystemProvider | undefined { return sshProviders.get(connectionId) } - -export function requireSshFilesystemProvider(connectionId: string): IFilesystemProvider { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider -} diff --git a/src/main/providers/ssh-git-dispatch.test.ts b/src/main/providers/ssh-git-dispatch.test.ts deleted file mode 100644 index e20ffe8d19b..00000000000 --- a/src/main/providers/ssh-git-dispatch.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { - _getSshGitProviderGenerationCacheSize, - getSshGitProvider, - getSshGitProviderGeneration, - registerSshGitProvider, - requireSshGitProvider, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE, - unregisterSshGitProvider -} from './ssh-git-dispatch' -import { clearSshPlainSshMode, setSshPlainSshMode } from '../ssh/ssh-plain-ssh-mode' - -describe('SSH Git provider registry', () => { - const connectionId = 'ssh-generation-test' - - afterEach(() => { - unregisterSshGitProvider(connectionId) - }) - - it('names the plain SSH reason instead of a dropped connection when git is relay-only', () => { - expect(() => requireSshGitProvider(connectionId)).toThrow(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - setSshPlainSshMode(connectionId, { reason: 'no_runtime', message: 'm' }) - try { - expect(() => requireSshGitProvider(connectionId)).toThrow( - 'Git needs the Orca remote server, which is not running on this host (no_runtime).' - ) - } finally { - clearSshPlainSshMode(connectionId) - } - }) - - it('keeps provider generations monotonic across unregister and re-register', () => { - const before = getSshGitProviderGeneration(connectionId) - registerSshGitProvider(connectionId, {} as never) - const registered = getSshGitProviderGeneration(connectionId) - unregisterSshGitProvider(connectionId) - const unregistered = getSshGitProviderGeneration(connectionId) - registerSshGitProvider(connectionId, {} as never) - const reRegistered = getSshGitProviderGeneration(connectionId) - - expect(registered).toBe(before + 1) - expect(unregistered).toBe(registered + 1) - expect(reRegistered).toBe(unregistered + 1) - }) - - it('bounds retired connection generations', () => { - registerSshGitProvider(connectionId, {} as never) - const provider = getSshGitProvider(connectionId) - if (!provider) { - throw new Error('test provider was not registered') - } - for (let index = 0; index < 600; index += 1) { - const id = `retired-${index}` - registerSshGitProvider(id, provider) - unregisterSshGitProvider(id) - } - - expect(_getSshGitProviderGenerationCacheSize()).toBeLessThanOrEqual(512) - }) - - it('does not reuse a generation after the target leaves both bounded maps', () => { - registerSshGitProvider(connectionId, {} as never) - const provider = getSshGitProvider(connectionId) - if (!provider) { - throw new Error('test provider was not registered') - } - const beforeChurn = getSshGitProviderGeneration(connectionId) - for (let index = 0; index < 1_100; index += 1) { - const id = `churn-${index}` - registerSshGitProvider(id, provider) - unregisterSshGitProvider(id) - } - unregisterSshGitProvider(connectionId) - registerSshGitProvider(connectionId, {} as never) - - expect(getSshGitProviderGeneration(connectionId)).toBeGreaterThan(beforeChurn) - }) -}) diff --git a/src/main/providers/ssh-git-dispatch.ts b/src/main/providers/ssh-git-dispatch.ts deleted file mode 100644 index 7506ba51658..00000000000 --- a/src/main/providers/ssh-git-dispatch.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { SshGitProvider } from './ssh-git-provider' -import { getSshPlainSshMode, PlainSshUnsupportedError } from '../ssh/ssh-plain-ssh-mode' - -const sshProviders = new Map() -const sshProviderGenerations = new Map() -const SSH_PROVIDER_GENERATION_MAX_ENTRIES = 512 -let providerGenerationSequence = 0 -let evictedProviderGeneration = 0 - -export const SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE = - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' - -export function registerSshGitProvider(connectionId: string, provider: SshGitProvider): void { - sshProviders.set(connectionId, provider) - sshProviderGenerations.set(connectionId, ++providerGenerationSequence) - pruneProviderGenerations() -} - -export function unregisterSshGitProvider(connectionId: string): void { - if (sshProviders.delete(connectionId)) { - sshProviderGenerations.set(connectionId, ++providerGenerationSequence) - pruneProviderGenerations() - } -} - -// Connection ids are externally supplied and can churn across repeated SSH -// sessions. Keep recent generations for cache invalidation without retaining -// every retired target for the lifetime of the main process. -function pruneProviderGenerations(): void { - while (sshProviderGenerations.size > SSH_PROVIDER_GENERATION_MAX_ENTRIES) { - const oldest = sshProviderGenerations.keys().next() - if (oldest.done) { - return - } - evictedProviderGeneration = Math.max( - evictedProviderGeneration, - sshProviderGenerations.get(oldest.value) ?? 0 - ) - sshProviderGenerations.delete(oldest.value) - } -} - -export function getSshGitProviderGeneration(connectionId: string): number { - return sshProviderGenerations.get(connectionId) ?? evictedProviderGeneration -} - -/** @internal - exposed for cache-bound regression tests. */ -export function _getSshGitProviderGenerationCacheSize(): number { - return sshProviderGenerations.size -} - -export function getSshGitProvider(connectionId: string): SshGitProvider | undefined { - return sshProviders.get(connectionId) -} - -/** A plain-SSH host has no git by design, which is "unsupported", not "connection dropped". */ -export function sshGitProviderMissingError(connectionId: string): Error { - const plainMode = getSshPlainSshMode(connectionId) - return plainMode - ? new PlainSshUnsupportedError('Git', plainMode) - : new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) -} - -export function requireSshGitProvider(connectionId: string): SshGitProvider { - const provider = getSshGitProvider(connectionId) - if (!provider) { - throw sshGitProviderMissingError(connectionId) - } - return provider -} diff --git a/src/main/providers/ssh-git-noninteractive-provider.ts b/src/main/providers/ssh-git-noninteractive-provider.ts deleted file mode 100644 index c0d8df0573f..00000000000 --- a/src/main/providers/ssh-git-noninteractive-provider.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { CommitMessagePlan } from '../../shared/commit-message-plan' -import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' -import { SshGitReadProvider } from './ssh-git-read-provider' - -type NonInteractiveExecQueueEntry = { - started: boolean - canceled: boolean - done: Promise - release: () => void -} - -const NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS = 5_000 - -export class SshGitNoninteractiveProvider extends SshGitReadProvider { - private readonly nonInteractiveExecQueues = new Map() - - async executeCommitMessagePlan( - plan: CommitMessagePlan, - cwd: string, - timeoutMs: number, - operation = 'commit-message' - ): Promise { - return this.runQueuedNonInteractiveExec( - cwd, - { - binary: plan.binary, - args: plan.args, - cwd, - stdin: plan.stdinPayload, - ...(plan.env ? { env: plan.env } : {}), - timeoutMs, - operation, - shell: true - }, - undefined, - operation - ) - } - - async execNonInteractive( - binary: string, - args: string[], - cwd: string, - timeoutMs: number, - signal?: AbortSignal, - env?: Record - ): Promise { - return this.runQueuedNonInteractiveExec( - cwd, - { - binary, - args, - cwd, - stdin: null, - timeoutMs, - ...(env ? { env } : {}) - }, - signal - ) - } - - async cancelNonInteractiveExec(cwd: string, operation?: string): Promise { - const queue = this.nonInteractiveExecQueues.get(this.nonInteractiveLaneKey(cwd, operation)) - const queuedEntry = queue?.find((entry) => !entry.started && !entry.canceled) - if (queuedEntry) { - queuedEntry.canceled = true - return - } - await this.cancelActiveNonInteractiveExec(cwd, operation) - } - - async cancelGenerateCommitMessage( - worktreePath: string, - operation = 'commit-message' - ): Promise { - await this.cancelNonInteractiveExec(worktreePath, operation) - } - - private async cancelActiveNonInteractiveExec(cwd: string, operation?: string): Promise { - try { - await this.mux.request('agent.cancelExec', { - cwd, - ...(operation ? { operation } : {}) - }) - } catch { - // Best-effort: callers are already unwinding after cancellation. - } - } - - private nonInteractiveLaneKey(cwd: string, operation?: string): string { - return JSON.stringify([operation || 'default', cwd]) - } - - private async runQueuedNonInteractiveExec( - cwd: string, - payload: { - binary: string - args: string[] - cwd: string - stdin: string | null - timeoutMs: number - env?: Record - operation?: string - shell?: boolean - }, - signal?: AbortSignal, - operation?: string - ): Promise { - const laneKey = this.nonInteractiveLaneKey(cwd, operation) - const queue = this.nonInteractiveExecQueues.get(laneKey) ?? [] - const previous = queue.at(-1)?.done ?? Promise.resolve() - let releaseEntry!: () => void - const entry: NonInteractiveExecQueueEntry = { - started: false, - canceled: false, - done: previous - .catch(() => {}) - .then( - () => - new Promise((resolve) => { - releaseEntry = resolve - }) - ), - release: () => releaseEntry() - } - queue.push(entry) - this.nonInteractiveExecQueues.set(laneKey, queue) - const abortEntry = (): void => { - if (!entry.started) { - entry.canceled = true - return - } - void this.cancelActiveNonInteractiveExec(cwd, operation) - } - if (signal?.aborted) { - entry.canceled = true - } else { - signal?.addEventListener('abort', abortEntry, { once: true }) - } - - await previous.catch(() => {}) - try { - if (entry.canceled) { - return { - stdout: '', - stderr: '', - exitCode: null, - timedOut: false, - canceled: true - } - } - entry.started = true - return (await this.mux.request('agent.execNonInteractive', payload, { - timeoutMs: payload.timeoutMs + NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS - })) as RemoteCommitMessageExecResult - } finally { - signal?.removeEventListener('abort', abortEntry) - entry.release() - const currentQueue = this.nonInteractiveExecQueues.get(laneKey) - const entryIndex = currentQueue?.indexOf(entry) ?? -1 - if (entryIndex >= 0) { - currentQueue?.splice(entryIndex, 1) - } - if (currentQueue?.length === 0) { - this.nonInteractiveExecQueues.delete(laneKey) - } - } - } -} diff --git a/src/main/providers/ssh-git-provider-commit-message.test.ts b/src/main/providers/ssh-git-provider-commit-message.test.ts deleted file mode 100644 index 70d31d6a4f2..00000000000 --- a/src/main/providers/ssh-git-provider-commit-message.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { - createMockMux, - waitForRequestCount, - type MockMultiplexer -} from './ssh-git-provider-test-harness' - -describe('SshGitProvider', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('getStagedCommitContext reads branch, staged summary, and staged patch remotely', async () => { - mux.request.mockImplementation(async (method, payload) => { - expect(method).toBe('git.exec') - if (payload.args[1] === '--show-current') { - return { stdout: 'feature/ai-commit\n' } - } - if (payload.args[2] === '--name-status') { - return { stdout: 'M\tREADME.md\n' } - } - if (payload.args[2] === '--patch') { - return { stdout: 'diff --git a/README.md b/README.md\n+hello' } - } - throw new Error(`unexpected args: ${payload.args.join(' ')}`) - }) - - const result = await provider.getStagedCommitContext('/home/user/repo') - - expect(result).toEqual({ - branch: 'feature/ai-commit', - stagedSummary: 'M\tREADME.md', - stagedPatch: 'diff --git a/README.md b/README.md\n+hello' - }) - expect(mux.request).toHaveBeenCalledWith('git.exec', { - args: ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'], - cwd: '/home/user/repo', - __streamResponse: true - }) - }) - - it('getStagedCommitContext returns null when nothing is staged', async () => { - mux.request.mockImplementation(async (_method, payload) => { - if (payload.args[1] === '--show-current') { - return { stdout: 'main\n' } - } - return { stdout: '' } - }) - - await expect(provider.getStagedCommitContext('/home/user/repo')).resolves.toBeNull() - expect(mux.request).toHaveBeenCalledTimes(2) - }) - - it('getStagedCommitContext falls back when the remote staged patch overflows', async () => { - mux.request.mockImplementation(async (_method, payload) => { - if (payload.args[1] === '--show-current') { - return { stdout: 'feature/ai-commit\n' } - } - if (payload.args[2] === '--name-status') { - return { stdout: 'A\thuge.jsonl\n' } - } - throw Object.assign(new Error('git stdout exceeded maxBuffer.'), { code: 'ENOBUFS' }) - }) - - await expect(provider.getStagedCommitContext('/home/user/repo')).resolves.toEqual({ - branch: 'feature/ai-commit', - stagedSummary: 'A\thuge.jsonl', - stagedPatch: '' - }) - }) - - it('getStagedCommitContext rethrows remote patch failures that are not buffer overflows', async () => { - mux.request.mockImplementation(async (_method, payload) => { - if (payload.args[1] === '--show-current') { - return { stdout: 'feature/ai-commit\n' } - } - if (payload.args[2] === '--name-status') { - return { stdout: 'M\tREADME.md\n' } - } - throw new Error('fatal: bad revision') - }) - - await expect(provider.getStagedCommitContext('/home/user/repo')).rejects.toThrow( - 'fatal: bad revision' - ) - }) - - it('keeps the transport alive for an agent response beyond the default request timeout', async () => { - vi.useFakeTimers() - try { - const execResult = { - stdout: 'Update docs', - stderr: '', - exitCode: 0, - timedOut: false - } - mux.request.mockImplementation((_method, _payload, options) => { - return new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error('transport request timed out')), - options?.timeoutMs ?? 30_000 - ) - setTimeout(() => { - clearTimeout(timeout) - resolve(execResult) - }, 45_000) - }) - }) - - let state: 'pending' | 'resolved' | 'rejected' = 'pending' - const pending = provider - .executeCommitMessagePlan( - { - binary: 'codex', - args: ['exec', 'PROMPT'], - stdinPayload: null, - label: 'Codex' - }, - '/home/user/repo', - 60_000 - ) - .then( - (result) => { - state = 'resolved' - return result - }, - (error) => { - state = 'rejected' - throw error - } - ) - void pending.catch(() => {}) - - await vi.advanceTimersByTimeAsync(30_000) - expect(state).toBe('pending') - await vi.advanceTimersByTimeAsync(15_000) - - await expect(pending).resolves.toEqual(execResult) - expect(mux.request).toHaveBeenCalledWith('agent.execNonInteractive', expect.any(Object), { - timeoutMs: 65_000 - }) - } finally { - vi.useRealTimers() - } - }) - - it('executeCommitMessagePlan delegates the prepared plan to the relay', async () => { - const execResult = { - stdout: 'Update docs', - stderr: '', - exitCode: 0, - timedOut: false - } - mux.request.mockResolvedValue(execResult) - - const result = await provider.executeCommitMessagePlan( - { - binary: 'codex', - args: ['exec', 'PROMPT'], - stdinPayload: null, - label: 'Codex', - env: { FLAG: 'literal $HOME' } - }, - '/home/user/repo', - 60_000 - ) - - expect(mux.request).toHaveBeenCalledWith( - 'agent.execNonInteractive', - { - binary: 'codex', - args: ['exec', 'PROMPT'], - cwd: '/home/user/repo', - stdin: null, - env: { FLAG: 'literal $HOME' }, - timeoutMs: 60_000, - operation: 'commit-message', - shell: true - }, - { timeoutMs: 65_000 } - ) - expect(result).toEqual(execResult) - }) - - it('keeps SSH commit-message and pull-request execution lanes separate', async () => { - const completeRequests: (() => void)[] = [] - mux.request.mockImplementation((method) => { - if (method === 'agent.cancelExec') { - return Promise.resolve({ canceled: true }) - } - return new Promise((resolve) => { - completeRequests.push(() => - resolve({ - stdout: '', - stderr: '', - exitCode: 0, - timedOut: false - }) - ) - }) - }) - const plan = { - binary: 'codex', - args: ['exec', 'PROMPT'], - stdinPayload: null, - label: 'Codex' - } - - const commit = provider.executeCommitMessagePlan(plan, '/home/user/repo', 60_000) - const pullRequest = provider.executeCommitMessagePlan( - plan, - '/home/user/repo', - 60_000, - 'pull-request-fields' - ) - - await waitForRequestCount(mux.request, 2) - expect(mux.request).toHaveBeenNthCalledWith( - 1, - 'agent.execNonInteractive', - { - binary: 'codex', - args: ['exec', 'PROMPT'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 60_000, - operation: 'commit-message', - shell: true - }, - { timeoutMs: 65_000 } - ) - expect(mux.request).toHaveBeenNthCalledWith( - 2, - 'agent.execNonInteractive', - { - binary: 'codex', - args: ['exec', 'PROMPT'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 60_000, - operation: 'pull-request-fields', - shell: true - }, - { timeoutMs: 65_000 } - ) - - await provider.cancelGenerateCommitMessage('/home/user/repo') - await provider.cancelGenerateCommitMessage('/home/user/repo', 'pull-request-fields') - - expect(mux.request).toHaveBeenNthCalledWith(3, 'agent.cancelExec', { - cwd: '/home/user/repo', - operation: 'commit-message' - }) - expect(mux.request).toHaveBeenNthCalledWith(4, 'agent.cancelExec', { - cwd: '/home/user/repo', - operation: 'pull-request-fields' - }) - - completeRequests.shift()?.() - completeRequests.shift()?.() - await Promise.all([commit, pullRequest]) - }) - - it('cancelGenerateCommitMessage sends best-effort relay cancellation', async () => { - await provider.cancelGenerateCommitMessage('/home/user/repo') - - expect(mux.request).toHaveBeenCalledWith('agent.cancelExec', { - cwd: '/home/user/repo', - operation: 'commit-message' - }) - }) -}) diff --git a/src/main/providers/ssh-git-provider-diff.test.ts b/src/main/providers/ssh-git-provider-diff.test.ts deleted file mode 100644 index 0fe5f195e9f..00000000000 --- a/src/main/providers/ssh-git-provider-diff.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { describe, expect, it, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { - createMockMux, - waitForRequestCount, - type MockMultiplexer -} from './ssh-git-provider-test-harness' - -function deferredValue(value: T): { promise: Promise; resolve: () => void } { - let resolve!: (value: T) => void - const promise = new Promise((innerResolve) => { - resolve = innerResolve - }) - return { promise, resolve: () => resolve(value) } -} - -describe('SshGitProvider', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('getDiff sends git.diff request', async () => { - const diffResult = { kind: 'text', originalContent: '', modifiedContent: 'hello' } - mux.request.mockResolvedValue(diffResult) - - const result = await provider.getDiff('/home/user/repo', 'src/index.ts', true) - expect(mux.request).toHaveBeenCalledWith('git.diff', { - worktreePath: '/home/user/repo', - filePath: 'src/index.ts', - staged: true, - // Why: opts into response streaming; a small result still comes back as a - // single frame (relay decides), and old relays ignore the flag. - __streamResponse: true - }) - expect(result).toEqual(diffResult) - }) - - it('getBranchDiff sends git.branchDiff request', async () => { - const diffs = [{ kind: 'text', originalContent: '', modifiedContent: 'new' }] - mux.request.mockResolvedValue(diffs) - - const result = await provider.getBranchDiff('/home/user/repo', 'main') - expect(mux.request).toHaveBeenCalledWith('git.branchDiff', { - worktreePath: '/home/user/repo', - baseRef: 'main', - __streamResponse: true - }) - expect(result).toEqual(diffs) - }) - - it('getBranchDiff forwards a pinned head OID without adding it when omitted', async () => { - const headOid = 'a'.repeat(40) - mux.request.mockResolvedValue([]) - - await provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts', - headOid - }) - await provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/other-file.ts', - headOid: undefined - }) - - expect(mux.request).toHaveBeenNthCalledWith(1, 'git.branchDiff', { - worktreePath: '/home/user/repo', - baseRef: 'main', - includePatch: true, - filePath: 'src/file.ts', - headOid, - __streamResponse: true - }) - expect(mux.request).toHaveBeenNthCalledWith(2, 'git.branchDiff', { - worktreePath: '/home/user/repo', - baseRef: 'main', - includePatch: true, - filePath: 'src/other-file.ts', - __streamResponse: true - }) - }) - - // Why: an unpinned compare snapshot reaches getBranchDiff as null despite the type. - it('omits a null head OID and shares the absent-head dedupe key', async () => { - const diffs = [{ kind: 'text', originalContent: 'old', modifiedContent: 'new' }] - const pendingDiff = deferredValue(diffs) - const headOid = 'a'.repeat(40) - mux.request.mockReturnValue(pendingDiff.promise) - - const reads = [ - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts', - headOid: null as unknown as undefined - }), - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts' - }), - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts', - headOid - }) - ] - - await waitForRequestCount(mux.request, 2) - expect(mux.request).toHaveBeenCalledTimes(2) - expect(mux.request).toHaveBeenNthCalledWith(1, 'git.branchDiff', { - worktreePath: '/home/user/repo', - baseRef: 'main', - includePatch: true, - filePath: 'src/file.ts', - __streamResponse: true - }) - expect(mux.request).toHaveBeenNthCalledWith(2, 'git.branchDiff', { - worktreePath: '/home/user/repo', - baseRef: 'main', - includePatch: true, - filePath: 'src/file.ts', - headOid, - __streamResponse: true - }) - - pendingDiff.resolve() - await expect(Promise.all(reads)).resolves.toEqual(Array.from({ length: 3 }, () => diffs)) - }) - - it('coalesces matching branch diff heads while keeping distinct and absent heads separate', async () => { - const diffs = [{ kind: 'text', originalContent: 'old', modifiedContent: 'new' }] - const pendingDiff = deferredValue(diffs) - const firstHeadOid = 'a'.repeat(40) - const secondHeadOid = 'b'.repeat(40) - mux.request.mockReturnValue(pendingDiff.promise) - - const reads = [ - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts', - headOid: firstHeadOid - }), - provider.getBranchDiff('/home/user/repo', 'main', { - filePath: 'src/file.ts', - headOid: firstHeadOid, - includePatch: true - }), - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts', - headOid: secondHeadOid - }), - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts' - }), - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts', - headOid: undefined - }) - ] - - await waitForRequestCount(mux.request, 3) - expect(mux.request).toHaveBeenCalledTimes(3) - - pendingDiff.resolve() - await expect(Promise.all(reads)).resolves.toEqual(Array.from({ length: 5 }, () => diffs)) - }) - - it('coalesces concurrent identical diff RPCs while in flight', async () => { - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - const pendingDiff = deferredValue(diff) - mux.request.mockReturnValue(pendingDiff.promise) - - const reads = Array.from({ length: 8 }, () => - provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - ) - - await waitForRequestCount(mux.request, 1) - expect(mux.request).toHaveBeenCalledTimes(1) - pendingDiff.resolve() - - await expect(Promise.all(reads)).resolves.toEqual(Array.from({ length: 8 }, () => diff)) - - mux.request.mockReset() - const branchDiffs = [diff] - const pendingBranchDiff = deferredValue(branchDiffs) - mux.request.mockReturnValue(pendingBranchDiff.promise) - - const branchReads = Array.from({ length: 8 }, () => - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts' - }) - ) - - await waitForRequestCount(mux.request, 1) - expect(mux.request).toHaveBeenCalledTimes(1) - pendingBranchDiff.resolve() - await expect(Promise.all(branchReads)).resolves.toEqual( - Array.from({ length: 8 }, () => branchDiffs) - ) - - mux.request.mockReset() - const pendingCommitDiff = deferredValue(diff) - mux.request.mockReturnValue(pendingCommitDiff.promise) - - const commitReads = Array.from({ length: 8 }, () => - provider.getCommitDiff('/home/user/repo', { - commitOid: 'c'.repeat(40), - parentOid: 'b'.repeat(40), - filePath: 'src/file.ts' - }) - ) - - await waitForRequestCount(mux.request, 1) - expect(mux.request).toHaveBeenCalledTimes(1) - pendingCommitDiff.resolve() - await expect(Promise.all(commitReads)).resolves.toEqual(Array.from({ length: 8 }, () => diff)) - }) - - it('retries diff RPCs after an in-flight rejection settles', async () => { - const failure = new Error('transient relay failure') - mux.request.mockRejectedValueOnce(failure) - - const firstBurst = Array.from({ length: 8 }, () => - provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - ) - - await expect(Promise.all(firstBurst)).rejects.toThrow('transient relay failure') - expect(mux.request).toHaveBeenCalledTimes(1) - - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - mux.request.mockResolvedValueOnce(diff) - - await expect(provider.getDiff('/home/user/repo', 'src/file.ts', false, true)).resolves.toBe( - diff - ) - expect(mux.request).toHaveBeenCalledTimes(2) - }) - - it('clears pending diff RPCs when status runs', async () => { - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - const pendingDiff = deferredValue(diff) - mux.request.mockReturnValueOnce(pendingDiff.promise) - - const first = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - await waitForRequestCount(mux.request, 1) - - mux.request.mockResolvedValueOnce({ entries: [], conflictOperation: 'unknown' }) - await provider.getStatus('/home/user/repo') - - mux.request.mockResolvedValueOnce(diff) - const second = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - - pendingDiff.resolve() - await expect(Promise.all([first, second])).resolves.toEqual([diff, diff]) - expect(mux.request).toHaveBeenCalledTimes(3) - }) - - it('clears pending diff RPCs when submodule status runs', async () => { - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - const pendingDiff = deferredValue(diff) - mux.request.mockReturnValueOnce(pendingDiff.promise) - - const first = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - await waitForRequestCount(mux.request, 1) - - mux.request.mockResolvedValueOnce({ entries: [], conflictOperation: 'unknown' }) - await provider.getSubmoduleStatus('/home/user/repo', 'vendor/lib') - - mux.request.mockResolvedValueOnce(diff) - const second = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - - pendingDiff.resolve() - await expect(Promise.all([first, second])).resolves.toEqual([diff, diff]) - expect(mux.request).toHaveBeenCalledTimes(3) - }) - - it('clears pending diff RPCs when a mutation runs', async () => { - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - const pendingDiff = deferredValue(diff) - mux.request.mockReturnValueOnce(pendingDiff.promise) - - const first = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - await waitForRequestCount(mux.request, 1) - - mux.request.mockResolvedValueOnce(undefined) - await provider.stageFile('/home/user/repo', 'src/file.ts') - - mux.request.mockResolvedValueOnce(diff) - const second = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - - pendingDiff.resolve() - await expect(Promise.all([first, second])).resolves.toEqual([diff, diff]) - expect(mux.request).toHaveBeenCalledTimes(3) - }) - - it.each([ - [ - 'clone', - (provider: SshGitProvider) => - provider.clone(['clone', '--', 'https://example.com/repo.git', 'repo'], '/projects') - ], - [ - 'mutating git.exec', - (provider: SshGitProvider) => - provider.exec(['commit', '--allow-empty', '-m', 'initialize'], '/home/user/repo') - ] - ])('clears pending diff RPCs when %s runs', async (_name, mutate) => { - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - const pendingDiff = deferredValue(diff) - mux.request.mockReturnValueOnce(pendingDiff.promise) - - const first = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - await waitForRequestCount(mux.request, 1) - - mux.request.mockResolvedValueOnce({ stdout: '', stderr: '' }) - await mutate(provider) - - mux.request.mockResolvedValueOnce(diff) - const second = provider.getDiff('/home/user/repo', 'src/file.ts', false, true) - - pendingDiff.resolve() - await expect(Promise.all([first, second])).resolves.toEqual([diff, diff]) - expect(mux.request).toHaveBeenCalledTimes(3) - }) - - it('clears pending branch diff RPCs when a ref-moving provider operation runs', async () => { - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - const pendingDiff = deferredValue([diff]) - mux.request.mockReturnValueOnce(pendingDiff.promise) - - const first = provider.getBranchDiff('/home/user/repo', 'origin/main', { - includePatch: true, - filePath: 'src/file.ts' - }) - await waitForRequestCount(mux.request, 1) - - mux.request.mockResolvedValueOnce(undefined) - await provider.fetchRemoteTrackingRef( - '/home/user/repo', - 'origin', - 'main', - 'refs/remotes/origin/main' - ) - - mux.request.mockResolvedValueOnce([diff]) - const second = provider.getBranchDiff('/home/user/repo', 'origin/main', { - includePatch: true, - filePath: 'src/file.ts' - }) - - pendingDiff.resolve() - await expect(Promise.all([first, second])).resolves.toEqual([[diff], [diff]]) - expect(mux.request).toHaveBeenCalledTimes(3) - }) - - it('coalesces logically identical branch and commit diff RPC args regardless of property order', async () => { - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - mux.request.mockResolvedValue([diff]) - - await Promise.all([ - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts', - oldPath: 'src/old-file.ts' - }), - provider.getBranchDiff('/home/user/repo', 'main', { - oldPath: 'src/old-file.ts', - filePath: 'src/file.ts', - includePatch: true - }) - ]) - - expect(mux.request).toHaveBeenCalledTimes(1) - - mux.request.mockReset() - mux.request.mockResolvedValue(diff) - - await Promise.all([ - provider.getCommitDiff('/home/user/repo', { - commitOid: 'c'.repeat(40), - parentOid: 'b'.repeat(40), - filePath: 'src/file.ts', - oldPath: 'src/old-file.ts' - }), - provider.getCommitDiff('/home/user/repo', { - oldPath: 'src/old-file.ts', - filePath: 'src/file.ts', - parentOid: 'b'.repeat(40), - commitOid: 'c'.repeat(40) - }) - ]) - - expect(mux.request).toHaveBeenCalledTimes(1) - }) - - it('keeps distinct diff RPC keys independent', async () => { - const diff = { - kind: 'text', - originalContent: 'old', - modifiedContent: 'new', - originalIsBinary: false, - modifiedIsBinary: false - } - mux.request.mockResolvedValue(diff) - - await Promise.all([ - provider.getDiff('/home/user/repo', 'src/file.ts', false, false), - provider.getDiff('/home/user/repo', 'src/file.ts', true, false), - provider.getDiff('/home/user/repo', 'src/file.ts', false, true), - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts' - }), - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: false, - filePath: 'src/file.ts' - }), - provider.getBranchDiff('/home/user/repo', 'main', { - includePatch: true, - filePath: 'src/file.ts', - oldPath: 'src/old-file.ts' - }), - provider.getBranchDiff('/home/user/repo', 'develop', { - includePatch: true, - filePath: 'src/file.ts' - }), - provider.getCommitDiff('/home/user/repo', { - commitOid: 'c'.repeat(40), - parentOid: 'b'.repeat(40), - filePath: 'src/file.ts' - }), - provider.getCommitDiff('/home/user/repo', { - commitOid: 'c'.repeat(40), - parentOid: 'a'.repeat(40), - filePath: 'src/file.ts' - }), - provider.getCommitDiff('/home/user/repo', { - commitOid: 'c'.repeat(40), - parentOid: 'b'.repeat(40), - filePath: 'src/file.ts', - oldPath: 'src/old-file.ts' - }) - ]) - - expect(mux.request).toHaveBeenCalledTimes(10) - }) -}) diff --git a/src/main/providers/ssh-git-provider-exec.test.ts b/src/main/providers/ssh-git-provider-exec.test.ts deleted file mode 100644 index ef0c3198a8d..00000000000 --- a/src/main/providers/ssh-git-provider-exec.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { - createMockMux, - waitForRequestCount, - type MockMultiplexer -} from './ssh-git-provider-test-harness' - -describe('SshGitProvider', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('clone sends git.clone request and forwards matching progress notifications', async () => { - const unsubscribe = vi.fn() - const onProgress = vi.fn() - mux.onNotificationByMethod.mockReturnValue(unsubscribe) - mux.request.mockImplementationOnce(async (_method, params) => { - const progressHandler = mux.onNotificationByMethod.mock.calls[0][1] - progressHandler({ - progressId: params.progressId, - phase: 'Receiving objects', - percent: 42 - }) - progressHandler({ - progressId: 'other-clone', - phase: 'Receiving objects', - percent: 99 - }) - return { stdout: '', stderr: '' } - }) - - await provider.clone(['clone', '--progress', '--', 'url', 'repo'], '/home/user', { - timeoutMs: 1000, - onProgress - }) - - expect(mux.request).toHaveBeenCalledWith( - 'git.clone', - expect.objectContaining({ - args: ['clone', '--progress', '--', 'url', 'repo'], - cwd: '/home/user', - progressId: expect.stringMatching(/^clone-/) - }), - { signal: undefined, timeoutMs: 1000 } - ) - expect(mux.onNotificationByMethod).toHaveBeenCalledWith( - 'git.cloneProgress', - expect.any(Function) - ) - expect(onProgress).toHaveBeenCalledWith({ phase: 'Receiving objects', percent: 42 }) - expect(onProgress).toHaveBeenCalledTimes(1) - expect(unsubscribe).toHaveBeenCalledTimes(1) - }) - - it('reports an actionable reconnect message when the relay does not support cloning', async () => { - const methodNotFound = new Error('Method not found: git.clone') as Error & { code?: number } - methodNotFound.code = -32601 - mux.request.mockRejectedValueOnce(methodNotFound) - - await expect( - provider.clone(['clone', '--progress', '--', 'url', 'repo'], '/home/user') - ).rejects.toThrow( - 'SSH clone support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' - ) - }) - - it('execNonInteractive delegates fixed binary commands to the relay', async () => { - const execResult = { - stdout: '10.0.0\n', - stderr: '', - exitCode: 0, - timedOut: false - } - mux.request.mockResolvedValue(execResult) - - const result = await provider.execNonInteractive('pnpm', ['--version'], '/home/user/repo', 8000) - - expect(mux.request).toHaveBeenCalledWith( - 'agent.execNonInteractive', - { - binary: 'pnpm', - args: ['--version'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 8000 - }, - { timeoutMs: 13_000 } - ) - expect(result).toEqual(execResult) - }) - - it('execNonInteractive forwards environment variables to the relay', async () => { - const execResult = { - stdout: '', - stderr: '', - exitCode: 0, - timedOut: false - } - mux.request.mockResolvedValue(execResult) - - await provider.execNonInteractive( - '/bin/bash', - ['-lc', 'echo "$ORCA_WORKTREE_PATH"'], - '/home/user/repo', - 120_000, - undefined, - { - ORCA_ROOT_PATH: '/home/user/repo', - ORCA_WORKTREE_PATH: '/home/user/repo-feature' - } - ) - - expect(mux.request).toHaveBeenCalledWith( - 'agent.execNonInteractive', - { - binary: '/bin/bash', - args: ['-lc', 'echo "$ORCA_WORKTREE_PATH"'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 120_000, - env: { - ORCA_ROOT_PATH: '/home/user/repo', - ORCA_WORKTREE_PATH: '/home/user/repo-feature' - } - }, - { timeoutMs: 125_000 } - ) - }) - - it('cancelNonInteractiveExec sends best-effort relay cancellation', async () => { - await provider.cancelNonInteractiveExec('/home/user/repo') - - expect(mux.request).toHaveBeenCalledWith('agent.cancelExec', { cwd: '/home/user/repo' }) - }) - - it('exec forwards abort and timeout options to the relay request', async () => { - const controller = new AbortController() - mux.request.mockResolvedValue({ stdout: '', stderr: '' }) - - await provider.exec( - ['clone', '--progress', '--', 'git@example.com:repo.git', 'repo'], - '/home/user', - { - signal: controller.signal, - timeoutMs: 60_000 - } - ) - - expect(mux.request).toHaveBeenCalledWith( - 'git.exec', - { - args: ['clone', '--progress', '--', 'git@example.com:repo.git', 'repo'], - cwd: '/home/user', - // Why: exec opts into response streaming so a large stdout is chunked - // onto the bulk lane; old relays ignore the flag. - __streamResponse: true - }, - { - signal: controller.signal, - timeoutMs: 60_000 - } - ) - }) - - it('serializes non-interactive relay execs for the same cwd and operation', async () => { - const completeRequests: (() => void)[] = [] - mux.request.mockImplementation( - () => - new Promise((resolve) => { - completeRequests.push(() => - resolve({ - stdout: '', - stderr: '', - exitCode: 0, - timedOut: false - }) - ) - }) - ) - - const first = provider.execNonInteractive('pnpm', ['store', 'prune'], '/home/user/repo', 8000) - const second = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) - - await waitForRequestCount(mux.request, 1) - expect(mux.request).toHaveBeenCalledTimes(1) - - completeRequests.shift()?.() - await first - await waitForRequestCount(mux.request, 2) - - expect(mux.request).toHaveBeenNthCalledWith( - 2, - 'agent.execNonInteractive', - { - binary: 'pnpm', - args: ['install'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 8000 - }, - { timeoutMs: 13_000 } - ) - completeRequests.shift()?.() - await second - }) - - it('isolates non-interactive lanes between provider instances', async () => { - const completeRequests: (() => void)[] = [] - mux.request.mockImplementation( - () => - new Promise((resolve) => { - completeRequests.push(() => - resolve({ stdout: '', stderr: '', exitCode: 0, timedOut: false }) - ) - }) - ) - const otherProvider = new SshGitProvider('conn-2', mux as never) - - const first = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) - const second = otherProvider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) - - await waitForRequestCount(mux.request, 2) - expect(mux.request).toHaveBeenCalledTimes(2) - completeRequests.forEach((complete) => complete()) - await Promise.all([first, second]) - }) - - it('forwards leading global Git options without reordering argv', async () => { - mux.request.mockResolvedValue({ stdout: '', stderr: '' }) - const args = ['-c', 'maintenance.auto=false', 'fetch', 'origin', 'main'] - - await provider.exec(args, '/home/user/repo') - - expect(mux.request).toHaveBeenCalledWith('git.exec', { - args, - cwd: '/home/user/repo', - __streamResponse: true - }) - }) - - it('cancels a queued non-interactive exec without canceling the active relay child', async () => { - const completeRequests: (() => void)[] = [] - mux.request.mockImplementation( - () => - new Promise((resolve) => { - completeRequests.push(() => - resolve({ - stdout: '', - stderr: '', - exitCode: 0, - timedOut: false - }) - ) - }) - ) - - const first = provider.execNonInteractive('pnpm', ['store', 'prune'], '/home/user/repo', 8000) - const second = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) - - await waitForRequestCount(mux.request, 1) - await provider.cancelNonInteractiveExec('/home/user/repo') - - expect(mux.request).toHaveBeenCalledTimes(1) - expect(mux.request).not.toHaveBeenCalledWith('agent.cancelExec', { cwd: '/home/user/repo' }) - - completeRequests.shift()?.() - await first - const secondResult = await second - - expect(mux.request).toHaveBeenCalledTimes(1) - expect(secondResult).toMatchObject({ canceled: true }) - }) - - it('uses an exec abort signal to cancel the matching active relay child with queued work present', async () => { - const completeRequests: (() => void)[] = [] - mux.request.mockImplementation((method) => { - if (method === 'agent.cancelExec') { - return Promise.resolve({ canceled: true }) - } - return new Promise((resolve) => { - completeRequests.push(() => - resolve({ - stdout: '', - stderr: '', - exitCode: 0, - timedOut: false - }) - ) - }) - }) - - const controller = new AbortController() - const first = provider.execNonInteractive( - 'pnpm', - ['store', 'prune'], - '/home/user/repo', - 8000, - controller.signal - ) - const second = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) - - await waitForRequestCount(mux.request, 1) - controller.abort() - await waitForRequestCount(mux.request, 2) - - expect(mux.request).toHaveBeenNthCalledWith(2, 'agent.cancelExec', { cwd: '/home/user/repo' }) - - completeRequests.shift()?.() - await first - await waitForRequestCount(mux.request, 3) - expect(mux.request).toHaveBeenNthCalledWith( - 3, - 'agent.execNonInteractive', - { - binary: 'pnpm', - args: ['install'], - cwd: '/home/user/repo', - stdin: null, - timeoutMs: 8000 - }, - { timeoutMs: 13_000 } - ) - completeRequests.shift()?.() - await second - }) -}) diff --git a/src/main/providers/ssh-git-provider-merge.test.ts b/src/main/providers/ssh-git-provider-merge.test.ts deleted file mode 100644 index 7c7d0aa0717..00000000000 --- a/src/main/providers/ssh-git-provider-merge.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' - -describe('SshGitProvider merge operations', () => { - it('abortMerge sends git.abortMerge request', async () => { - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn(), - onNotification: vi.fn(), - dispose: vi.fn(), - isDisposed: vi.fn().mockReturnValue(false) - } - const provider = new SshGitProvider('conn-1', mux as never) - - await provider.abortMerge('/home/user/repo') - - expect(mux.request).toHaveBeenCalledWith('git.abortMerge', { - worktreePath: '/home/user/repo' - }) - }) - - it('abortRebase sends git.abortRebase request', async () => { - const mux = { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn(), - onNotification: vi.fn(), - dispose: vi.fn(), - isDisposed: vi.fn().mockReturnValue(false) - } - const provider = new SshGitProvider('conn-1', mux as never) - - await provider.abortRebase('/home/user/repo') - - expect(mux.request).toHaveBeenCalledWith('git.abortRebase', { - worktreePath: '/home/user/repo' - }) - }) -}) diff --git a/src/main/providers/ssh-git-provider-remote-sync.test.ts b/src/main/providers/ssh-git-provider-remote-sync.test.ts deleted file mode 100644 index 4a25d54b8a6..00000000000 --- a/src/main/providers/ssh-git-provider-remote-sync.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { describe, expect, it, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness' -import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../shared/git-rebase-source' - -describe('SshGitProvider', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('getUpstreamStatus sends git.upstreamStatus request', async () => { - const upstreamResult = { hasUpstream: true, upstreamName: 'origin/main', ahead: 1, behind: 0 } - mux.request.mockResolvedValue(upstreamResult) - - const result = await provider.getUpstreamStatus('/home/user/repo') - expect(mux.request).toHaveBeenCalledWith('git.upstreamStatus', { - worktreePath: '/home/user/repo' - }) - expect(result).toEqual(upstreamResult) - }) - - it('getUpstreamStatus forwards an explicit push target', async () => { - const upstreamResult = { hasUpstream: true, upstreamName: 'fork/feature', ahead: 0, behind: 1 } - mux.request.mockResolvedValue(upstreamResult) - - const pushTarget = { remoteName: 'fork', branchName: 'feature' } - const result = await provider.getUpstreamStatus('/home/user/repo', pushTarget) - - expect(mux.request).toHaveBeenCalledWith('git.upstreamStatus', { - worktreePath: '/home/user/repo', - pushTarget - }) - expect(result).toEqual(upstreamResult) - }) - - it('pushBranch sends git.push request and forwards publish mode and target', async () => { - await provider.pushBranch('/home/user/repo', true, { - remoteName: 'pr-fork-orca', - branchName: 'contributor/fix' - }) - expect(mux.request).toHaveBeenCalledWith('git.push', { - worktreePath: '/home/user/repo', - publish: true, - pushTarget: { - remoteName: 'pr-fork-orca', - branchName: 'contributor/fix' - } - }) - }) - - it('pushBranch forwards force-with-lease mode', async () => { - await provider.pushBranch('/home/user/repo', false, undefined, { forceWithLease: true }) - - expect(mux.request).toHaveBeenCalledWith('git.push', { - worktreePath: '/home/user/repo', - publish: false, - pushTarget: undefined, - forceWithLease: true - }) - }) - - it('pullBranch sends git.pull request', async () => { - await provider.pullBranch('/home/user/repo') - expect(mux.request).toHaveBeenCalledWith('git.pull', { - worktreePath: '/home/user/repo' - }) - }) - - it('pullBranch forwards an explicit push target', async () => { - const pushTarget = { remoteName: 'fork', branchName: 'feature' } - - await provider.pullBranch('/home/user/repo', pushTarget) - - expect(mux.request).toHaveBeenCalledWith('git.pull', { - worktreePath: '/home/user/repo', - pushTarget - }) - }) - - it('fastForwardBranch sends git.fastForward request', async () => { - await provider.fastForwardBranch('/home/user/repo') - expect(mux.request).toHaveBeenCalledWith('git.fastForward', { - worktreePath: '/home/user/repo' - }) - }) - - it('fastForwardBranch forwards an explicit push target', async () => { - const pushTarget = { remoteName: 'fork', branchName: 'feature' } - - await provider.fastForwardBranch('/home/user/repo', pushTarget) - - expect(mux.request).toHaveBeenCalledWith('git.fastForward', { - worktreePath: '/home/user/repo', - pushTarget - }) - }) - - it('rebaseFromBase sends git.rebaseFromBase request', async () => { - await provider.rebaseFromBase('/home/user/repo', 'upstream/main') - - expect(mux.request).toHaveBeenCalledWith( - 'git.rebaseFromBase', - { - worktreePath: '/home/user/repo', - baseRef: 'upstream/main' - }, - { timeoutMs: REBASE_FROM_BASE_RPC_TIMEOUT_MS } - ) - }) - - it('fetchRemote sends git.fetch request', async () => { - await provider.fetchRemote('/home/user/repo') - expect(mux.request).toHaveBeenCalledWith('git.fetch', { - worktreePath: '/home/user/repo' - }) - }) - - it('fetchRemote forwards an explicit push target', async () => { - const pushTarget = { remoteName: 'fork', branchName: 'feature' } - - await provider.fetchRemote('/home/user/repo', pushTarget) - - expect(mux.request).toHaveBeenCalledWith('git.fetch', { - worktreePath: '/home/user/repo', - pushTarget - }) - }) - - it('syncForkDefaultBranch sends git.forkSync request', async () => { - const syncResult = { - status: 'synced', - originRemote: 'origin', - upstreamRemote: 'upstream', - branchName: 'main', - ahead: 0, - behind: 2 - } - mux.request.mockResolvedValue(syncResult) - - const expectedUpstream = { owner: 'stablyai', repo: 'orca' } - const result = await provider.syncForkDefaultBranch('/home/user/repo', expectedUpstream) - - expect(mux.request).toHaveBeenCalledWith('git.forkSync', { - worktreePath: '/home/user/repo', - expectedUpstream - }) - expect(result).toEqual(syncResult) - }) - - it('fetchRemoteTrackingRef sends git.fetchRemoteTrackingRef request', async () => { - await provider.fetchRemoteTrackingRef( - '/home/user/repo', - 'origin', - 'main', - 'refs/remotes/origin/main', - { skipAutoMaintenance: true } - ) - - expect(mux.request).toHaveBeenCalledWith('git.fetchRemoteTrackingRef', { - worktreePath: '/home/user/repo', - remote: 'origin', - branch: 'main', - ref: 'refs/remotes/origin/main', - skipAutoMaintenance: true - }) - }) - - it('fetchGitLabMergeRequestHead sends the durable-ref git.fetchGitLabMergeRequestHeadRef request', async () => { - mux.request.mockResolvedValueOnce({ - localRef: 'refs/orca/merge-requests/origin-abc/42' - }) - - const localRef = await provider.fetchGitLabMergeRequestHead('/home/user/repo', 'origin', 42) - - expect(mux.request).toHaveBeenCalledWith('git.fetchGitLabMergeRequestHeadRef', { - worktreePath: '/home/user/repo', - remote: 'origin', - mrIid: 42 - }) - expect(localRef).toBe('refs/orca/merge-requests/origin-abc/42') - }) - - it('fetchGitLabMergeRequestHead maps old relays to the reconnect message', async () => { - const methodNotFound = Object.assign( - new Error('Method not found: git.fetchGitLabMergeRequestHeadRef'), - { code: -32601 } - ) - mux.request.mockRejectedValueOnce(methodNotFound) - - await expect( - provider.fetchGitLabMergeRequestHead('/home/user/repo', 'origin', 42) - ).rejects.toThrow( - 'This SSH host is running an older Orca relay that cannot fetch merge request heads. Reconnect to deploy the latest relay, then try again.' - ) - }) - - it('fetchGitLabMergeRequestHead rethrows non-method-not-found errors', async () => { - const error = new Error('fatal: could not read from remote repository') - mux.request.mockRejectedValueOnce(error) - - await expect( - provider.fetchGitLabMergeRequestHead('/home/user/repo', 'origin', 42) - ).rejects.toBe(error) - }) - - it('fetchGitHubPullRequestHead sends git.fetchGitHubPullRequestHead request', async () => { - mux.request.mockResolvedValueOnce({ localRef: 'refs/orca/pull/origin-abc/42' }) - - const localRef = await provider.fetchGitHubPullRequestHead('/home/user/repo', 'origin', 42) - - expect(mux.request).toHaveBeenCalledWith('git.fetchGitHubPullRequestHead', { - worktreePath: '/home/user/repo', - remote: 'origin', - prNumber: 42 - }) - expect(localRef).toBe('refs/orca/pull/origin-abc/42') - }) - - it('fetchGitHubPullRequestHead rejects relays that omit the durable localRef', async () => { - mux.request.mockResolvedValueOnce({}) - - await expect( - provider.fetchGitHubPullRequestHead('/home/user/repo', 'origin', 42) - ).rejects.toThrow('did not return the durable pull request head ref') - }) - - it('fetchGitHubPullRequestHead maps old relays to the reconnect message', async () => { - const methodNotFound = Object.assign( - new Error('Method not found: git.fetchGitHubPullRequestHead'), - { code: -32601 } - ) - mux.request.mockRejectedValueOnce(methodNotFound) - - await expect( - provider.fetchGitHubPullRequestHead('/home/user/repo', 'origin', 42) - ).rejects.toThrow( - 'This SSH host is running an older Orca relay that cannot fetch pull request heads. Reconnect to deploy the latest relay, then try again.' - ) - }) - - it('fetchGitHubPullRequestHead rethrows non-method-not-found errors', async () => { - const error = new Error('fatal: could not read from remote repository') - mux.request.mockRejectedValueOnce(error) - - await expect(provider.fetchGitHubPullRequestHead('/home/user/repo', 'origin', 42)).rejects.toBe( - error - ) - }) -}) diff --git a/src/main/providers/ssh-git-provider-staging.test.ts b/src/main/providers/ssh-git-provider-staging.test.ts deleted file mode 100644 index 1ad3d973902..00000000000 --- a/src/main/providers/ssh-git-provider-staging.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness' -import { GIT_STAGE_WORKTREE_SCOPE_UNSUPPORTED_MESSAGE } from '../../shared/git-stage-worktree-scope' - -describe('SshGitProvider', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('commit sends git.commit request', async () => { - const commitResult = { success: true } - mux.request.mockResolvedValue(commitResult) - - const result = await provider.commit('/home/user/repo', 'feat: add source control commit') - - expect(mux.request).toHaveBeenCalledWith('git.commit', { - worktreePath: '/home/user/repo', - message: 'feat: add source control commit' - }) - expect(result).toEqual(commitResult) - }) - - it('stageFile sends git.stage request', async () => { - await provider.stageFile('/home/user/repo', 'src/file.ts') - expect(mux.request).toHaveBeenCalledWith('git.stage', { - worktreePath: '/home/user/repo', - filePath: 'src/file.ts' - }) - }) - - it('unstageFile sends git.unstage request', async () => { - await provider.unstageFile('/home/user/repo', 'src/file.ts') - expect(mux.request).toHaveBeenCalledWith('git.unstage', { - worktreePath: '/home/user/repo', - filePath: 'src/file.ts' - }) - }) - - it('bulkStageFiles sends git.bulkStage request', async () => { - await provider.bulkStageFiles('/home/user/repo', ['a.ts', 'b.ts']) - expect(mux.request).toHaveBeenCalledWith('git.bulkStage', { - worktreePath: '/home/user/repo', - filePaths: ['a.ts', 'b.ts'] - }) - }) - - it('bulkStageFiles forwards a whole-worktree scope and accepts the relay receipt', async () => { - mux.request.mockResolvedValue({ stagedScope: 'all' }) - await provider.bulkStageFiles('/home/user/repo', [], 'all') - expect(mux.request).toHaveBeenCalledWith('git.bulkStage', { - worktreePath: '/home/user/repo', - filePaths: [], - scope: 'all' - }) - }) - - it('bulkStageFiles rejects a scoped stage an older relay answered without a receipt', async () => { - // An older relay strips `scope`, stages the empty path list, and replies with nothing. - mux.request.mockResolvedValue(undefined) - await expect(provider.bulkStageFiles('/home/user/repo', [], 'all')).rejects.toThrow( - GIT_STAGE_WORKTREE_SCOPE_UNSUPPORTED_MESSAGE - ) - mux.request.mockResolvedValue({ stagedScope: 'tracked' }) - await expect(provider.bulkStageFiles('/home/user/repo', [], 'all')).rejects.toThrow( - GIT_STAGE_WORKTREE_SCOPE_UNSUPPORTED_MESSAGE - ) - }) - - it('bulkUnstageFiles sends git.bulkUnstage request', async () => { - await provider.bulkUnstageFiles('/home/user/repo', ['a.ts', 'b.ts']) - expect(mux.request).toHaveBeenCalledWith('git.bulkUnstage', { - worktreePath: '/home/user/repo', - filePaths: ['a.ts', 'b.ts'] - }) - }) - - it('discardChanges sends git.discard request', async () => { - await provider.discardChanges('/home/user/repo', 'src/file.ts') - expect(mux.request).toHaveBeenCalledWith('git.discard', { - worktreePath: '/home/user/repo', - filePath: 'src/file.ts' - }) - }) - - it('bulkDiscardChanges sends git.bulkDiscard request', async () => { - await provider.bulkDiscardChanges('/home/user/repo', ['a.ts', 'b.ts']) - expect(mux.request).toHaveBeenCalledWith('git.bulkDiscard', { - worktreePath: '/home/user/repo', - filePaths: ['a.ts', 'b.ts'] - }) - }) -}) diff --git a/src/main/providers/ssh-git-provider-status-lease.test.ts b/src/main/providers/ssh-git-provider-status-lease.test.ts deleted file mode 100644 index 9f436ba1a1a..00000000000 --- a/src/main/providers/ssh-git-provider-status-lease.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, expect, it, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { - createMockMux, - waitForRequestCount, - type MockMultiplexer -} from './ssh-git-provider-test-harness' - -function deferredPromise(): { - promise: Promise - resolve: (value: T) => void - reject: (error: unknown) => void -} { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((innerResolve, innerReject) => { - resolve = innerResolve - reject = innerReject - }) - return { promise, resolve, reject } -} - -describe('SshGitProvider status read leases', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('isolates status reads by worktree and output-affecting options', async () => { - const pendingRequests = Array.from({ length: 8 }, () => - deferredPromise<{ entries: never[]; conflictOperation: 'unknown' }>() - ) - mux.request.mockImplementation( - () => pendingRequests[mux.request.mock.calls.length - 1]?.promise - ) - - const reads = [ - provider.getStatus('/home/user/repo'), - provider.getStatus('/home/user/other'), - provider.getStatus('/home/user/repo', { includeIgnored: true }), - provider.getStatus('/home/user/repo', { includeLineStats: false }), - provider.getStatus('/home/user/repo', { - bypassEffectiveUpstreamNegativeCache: true - }), - provider.getStatus('/home/user/repo', { reuseLineStats: true }), - provider.getStatus('/home/user/repo', { admissionTier: 'background' }), - provider.getStatus('/home/user/repo', { admissionTier: 'interactive' }) - ] - await waitForRequestCount(mux.request, 8) - - expect(mux.request.mock.calls.map(([, payload]) => payload)).toEqual([ - { worktreePath: '/home/user/repo' }, - { worktreePath: '/home/user/other' }, - { worktreePath: '/home/user/repo', includeIgnored: true }, - { worktreePath: '/home/user/repo', includeLineStats: false }, - { - worktreePath: '/home/user/repo', - bypassEffectiveUpstreamNegativeCache: true - }, - { worktreePath: '/home/user/repo', reuseLineStats: true }, - { worktreePath: '/home/user/repo', admissionTier: 'background' }, - { worktreePath: '/home/user/repo', admissionTier: 'interactive' } - ]) - pendingRequests.forEach((pending) => - pending.resolve({ entries: [], conflictOperation: 'unknown' }) - ) - await Promise.all(reads) - }) - - it('isolates status reads by branch-line-total fork point', async () => { - const pendingRequests = Array.from({ length: 3 }, () => - deferredPromise<{ entries: never[]; conflictOperation: 'unknown' }>() - ) - mux.request.mockImplementation( - () => pendingRequests[mux.request.mock.calls.length - 1]?.promise - ) - - // Why: the response shape differs per fork point, so a poll that omitted the base - // must never serve a refresh that asked for it (the chip would blank or go stale). - const reads = [ - provider.getStatus('/home/user/repo'), - provider.getStatus('/home/user/repo', { branchLineTotalMergeBase: 'abc123' }), - provider.getStatus('/home/user/repo', { branchLineTotalMergeBase: 'def456' }) - ] - await waitForRequestCount(mux.request, 3) - - expect(mux.request.mock.calls.map(([, payload]) => payload)).toEqual([ - { worktreePath: '/home/user/repo' }, - { worktreePath: '/home/user/repo', branchLineTotalMergeBase: 'abc123' }, - { worktreePath: '/home/user/repo', branchLineTotalMergeBase: 'def456' } - ]) - pendingRequests.forEach((pending) => - pending.resolve({ entries: [], conflictOperation: 'unknown' }) - ) - await Promise.all(reads) - }) - - it('keeps status leases isolated per provider and relay incarnation', async () => { - const pendingRequests = Array.from({ length: 3 }, () => - deferredPromise<{ entries: never[]; conflictOperation: 'unknown' }>() - ) - mux.request.mockImplementation( - () => pendingRequests[mux.request.mock.calls.length - 1]?.promise - ) - const replacement = new SshGitProvider('conn-1', mux as never) - const otherConnection = new SshGitProvider('conn-2', mux as never) - - const reads = [ - provider.getStatus('/home/user/repo'), - replacement.getStatus('/home/user/repo'), - otherConnection.getStatus('/home/user/repo') - ] - await waitForRequestCount(mux.request, 3) - - expect(mux.request).toHaveBeenCalledTimes(3) - pendingRequests.forEach((pending) => - pending.resolve({ entries: [], conflictOperation: 'unknown' }) - ) - await Promise.all(reads) - }) - - it('fences status reads before, during, and after an SSH mutation', async () => { - const statusRequests = Array.from({ length: 3 }, () => - deferredPromise<{ entries: never[]; conflictOperation: 'unknown' }>() - ) - const mutation = deferredPromise() - let statusRequestIndex = 0 - mux.request.mockImplementation((method) => { - if (method === 'git.status') { - return statusRequests[statusRequestIndex++]?.promise - } - if (method === 'git.stage') { - return mutation.promise - } - return Promise.resolve(undefined) - }) - - const beforeMutation = provider.getStatus('/home/user/repo') - await waitForRequestCount(mux.request, 1) - const mutating = provider.stageFile('/home/user/repo', 'src/file.ts') - await waitForRequestCount(mux.request, 2) - const duringMutation = provider.getStatus('/home/user/repo') - await waitForRequestCount(mux.request, 3) - mutation.resolve(undefined) - await mutating - const afterMutation = provider.getStatus('/home/user/repo') - await waitForRequestCount(mux.request, 4) - - statusRequests.forEach((pending) => - pending.resolve({ entries: [], conflictOperation: 'unknown' }) - ) - await Promise.all([beforeMutation, duringMutation, afterMutation]) - expect(mux.request.mock.calls.filter(([method]) => method === 'git.status')).toHaveLength(3) - }) -}) diff --git a/src/main/providers/ssh-git-provider-status.test.ts b/src/main/providers/ssh-git-provider-status.test.ts deleted file mode 100644 index bb5d8adde2e..00000000000 --- a/src/main/providers/ssh-git-provider-status.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { describe, expect, it, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness' - -describe('SshGitProvider', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('getStatus sends git.status request', async () => { - const statusResult = { - entries: [{ path: 'generated/a.ts', status: 'untracked', area: 'untracked' }], - conflictOperation: 'unknown', - didHitLimit: true, - statusLength: 1_001 - } - mux.request.mockResolvedValue(statusResult) - - const result = await provider.getStatus('/home/user/repo') - expect(mux.request).toHaveBeenCalledWith( - 'git.status', - { worktreePath: '/home/user/repo' }, - { signal: expect.any(AbortSignal) } - ) - expect(result).toEqual(statusResult) - }) - - it('getStatus forwards includeIgnored only when requested', async () => { - const statusResult = { entries: [], conflictOperation: 'unknown', ignoredPaths: ['dist/'] } - mux.request.mockResolvedValue(statusResult) - - await provider.getStatus('/home/user/repo', { includeIgnored: true }) - await provider.getStatus('/home/user/repo', { includeIgnored: false }) - - expect(mux.request).toHaveBeenNthCalledWith( - 1, - 'git.status', - { - worktreePath: '/home/user/repo', - includeIgnored: true - }, - { signal: expect.any(AbortSignal) } - ) - expect(mux.request).toHaveBeenNthCalledWith( - 2, - 'git.status', - { worktreePath: '/home/user/repo' }, - { signal: expect.any(AbortSignal) } - ) - }) - - it('getStatus forwards a false line-stats request', async () => { - mux.request.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) - - await provider.getStatus('/home/user/repo', { includeLineStats: false }) - - expect(mux.request).toHaveBeenCalledWith( - 'git.status', - { worktreePath: '/home/user/repo', includeLineStats: false }, - { signal: expect.any(AbortSignal) } - ) - }) - - it('getStatus forwards upstream-negative-cache bypass only when requested', async () => { - const statusResult = { entries: [], conflictOperation: 'unknown' } - mux.request.mockResolvedValue(statusResult) - - await provider.getStatus('/home/user/repo', { bypassEffectiveUpstreamNegativeCache: true }) - await provider.getStatus('/home/user/repo', { bypassEffectiveUpstreamNegativeCache: false }) - - expect(mux.request).toHaveBeenNthCalledWith( - 1, - 'git.status', - { - worktreePath: '/home/user/repo', - bypassEffectiveUpstreamNegativeCache: true - }, - { signal: expect.any(AbortSignal) } - ) - expect(mux.request).toHaveBeenNthCalledWith( - 2, - 'git.status', - { worktreePath: '/home/user/repo' }, - { signal: expect.any(AbortSignal) } - ) - }) - - it('getStatus forwards line-stat reuse and cancellation to the relay', async () => { - const controller = new AbortController() - mux.request.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) - - await provider.getStatus('/home/user/repo', { - reuseLineStats: true, - signal: controller.signal - }) - - expect(mux.request).toHaveBeenCalledWith( - 'git.status', - { worktreePath: '/home/user/repo', reuseLineStats: true }, - { signal: expect.any(AbortSignal) } - ) - expect(mux.request.mock.calls[0][2].signal).not.toBe(controller.signal) - }) - - it('getSubmoduleStatus sends git.submoduleStatus request', async () => { - const statusResult = { entries: [], conflictOperation: 'unknown' } - mux.request.mockResolvedValue(statusResult) - - const result = await provider.getSubmoduleStatus('/home/user/repo', 'vendor/lib') - - expect(mux.request).toHaveBeenCalledWith('git.submoduleStatus', { - worktreePath: '/home/user/repo', - submodulePath: 'vendor/lib', - area: 'unstaged' - }) - expect(result).toEqual(statusResult) - }) - - it('getSubmoduleStatus forwards the requested source-control area', async () => { - const statusResult = { entries: [], conflictOperation: 'unknown' } - mux.request.mockResolvedValue(statusResult) - - await provider.getSubmoduleStatus('/home/user/repo', 'vendor/lib', 'staged') - - expect(mux.request).toHaveBeenCalledWith('git.submoduleStatus', { - worktreePath: '/home/user/repo', - submodulePath: 'vendor/lib', - area: 'staged' - }) - }) - - it('reports an actionable reconnect message when the relay lacks submodule status', async () => { - const methodNotFound = new Error('Method not found: git.submoduleStatus') as Error & { - code?: number - } - methodNotFound.code = -32601 - mux.request.mockRejectedValueOnce(methodNotFound) - - await expect(provider.getSubmoduleStatus('/home/user/repo', 'vendor/lib')).rejects.toThrow( - 'SSH submodule diff support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' - ) - }) - - it('rethrows non-method-not-found submodule status errors unchanged', async () => { - mux.request.mockRejectedValueOnce(new Error('fatal: not a submodule')) - - await expect(provider.getSubmoduleStatus('/home/user/repo', 'vendor/lib')).rejects.toThrow( - 'fatal: not a submodule' - ) - }) - - it('checkIgnoredPaths sends git.checkIgnored request', async () => { - mux.request.mockResolvedValue(['dist/bundle.js']) - - const result = await provider.checkIgnoredPaths('/home/user/repo', ['dist/bundle.js']) - - expect(mux.request).toHaveBeenCalledWith('git.checkIgnored', { - worktreePath: '/home/user/repo', - paths: ['dist/bundle.js'] - }) - expect(result).toEqual(['dist/bundle.js']) - }) - - it('getHistory sends git.history request', async () => { - const historyResult = { - items: [], - hasIncomingChanges: false, - hasOutgoingChanges: false, - hasMore: false, - limit: 50 - } - mux.request.mockResolvedValue(historyResult) - - const result = await provider.getHistory('/home/user/repo', { - limit: 25, - baseRef: 'origin/main' - }) - - expect(mux.request).toHaveBeenCalledWith('git.history', { - worktreePath: '/home/user/repo', - limit: 25, - baseRef: 'origin/main' - }) - expect(result).toEqual(historyResult) - }) - - it('detectConflictOperation sends git.conflictOperation request', async () => { - mux.request.mockResolvedValue('rebase') - const result = await provider.detectConflictOperation('/home/user/repo') - expect(mux.request).toHaveBeenCalledWith('git.conflictOperation', { - worktreePath: '/home/user/repo' - }) - expect(result).toBe('rebase') - }) - - it('getBranchCompare sends git.branchCompare request', async () => { - const compareResult = { summary: { ahead: 2, behind: 0 }, entries: [] } - mux.request.mockResolvedValue(compareResult) - - const result = await provider.getBranchCompare('/home/user/repo', 'main', { - admissionTier: 'background' - }) - expect(mux.request).toHaveBeenCalledWith('git.branchCompare', { - worktreePath: '/home/user/repo', - baseRef: 'main', - admissionTier: 'background' - }) - expect(result).toEqual(compareResult) - }) - - it('isGitRepo always returns true for remote paths', () => { - expect(provider.isGitRepo('/any/path')).toBe(true) - }) -}) diff --git a/src/main/providers/ssh-git-provider-test-harness.ts b/src/main/providers/ssh-git-provider-test-harness.ts deleted file mode 100644 index 86ef0a0e9b0..00000000000 --- a/src/main/providers/ssh-git-provider-test-harness.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { vi } from 'vitest' - -export type MockMultiplexer = { - request: ReturnType - notify: ReturnType - onNotification: ReturnType - onNotificationByMethod: ReturnType - onDispose: ReturnType - dispose: ReturnType - isDisposed: ReturnType -} - -export function createMockMux(): MockMultiplexer { - return { - request: vi.fn().mockResolvedValue(undefined), - notify: vi.fn(), - onNotification: vi.fn(), - onNotificationByMethod: vi.fn().mockReturnValue(vi.fn()), - // Why: requestGitStreamable subscribes to onDispose before awaiting the - // response so it can reject in-flight reassembly if the link drops. - onDispose: vi.fn().mockReturnValue(vi.fn()), - dispose: vi.fn(), - isDisposed: vi.fn().mockReturnValue(false) - } -} - -export async function waitForRequestCount( - mock: ReturnType, - count: number -): Promise { - for (let i = 0; i < 20; i++) { - if (mock.mock.calls.length >= count) { - return - } - await new Promise((resolve) => setTimeout(resolve, 0)) - } -} diff --git a/src/main/providers/ssh-git-provider-upstream-lease.test.ts b/src/main/providers/ssh-git-provider-upstream-lease.test.ts deleted file mode 100644 index 5717be064c4..00000000000 --- a/src/main/providers/ssh-git-provider-upstream-lease.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect, it, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { - createMockMux, - waitForRequestCount, - type MockMultiplexer -} from './ssh-git-provider-test-harness' - -function deferredPromise(): { - promise: Promise - resolve: (value: T) => void - reject: (error: unknown) => void -} { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((innerResolve, innerReject) => { - resolve = innerResolve - reject = innerReject - }) - return { promise, resolve, reject } -} - -describe('SshGitProvider upstream status read leases', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('isolates upstream-status RPCs by worktree and every target field', async () => { - mux.request.mockResolvedValue({ - hasUpstream: true, - upstreamName: 'fork/feature', - ahead: 0, - behind: 0 - }) - const baseTarget = { remoteName: 'fork', branchName: 'feature' } - - await Promise.all([ - provider.getUpstreamStatus('/repo-a'), - provider.getUpstreamStatus('/repo-b'), - provider.getUpstreamStatus('/repo-a', baseTarget), - provider.getUpstreamStatus('/repo-a', { ...baseTarget, remoteName: 'origin' }), - provider.getUpstreamStatus('/repo-a', { ...baseTarget, branchName: 'other' }), - provider.getUpstreamStatus('/repo-a', { - ...baseTarget, - remoteUrl: 'https://github.com/example/fork.git' - }), - provider.getUpstreamStatus('/repo-a', { ...baseTarget, remoteCreated: false }), - provider.getUpstreamStatus('/repo-a', { ...baseTarget, remoteCreated: true }) - ]) - - expect(mux.request).toHaveBeenCalledTimes(8) - }) - - it('keeps upstream-status reads isolated per provider instance', async () => { - const otherProvider = new SshGitProvider('conn-1', mux as never) - mux.request.mockResolvedValue({ - hasUpstream: true, - upstreamName: 'origin/main', - ahead: 0, - behind: 0 - }) - - await Promise.all([ - provider.getUpstreamStatus('/home/user/repo'), - otherProvider.getUpstreamStatus('/home/user/repo') - ]) - - expect(mux.request).toHaveBeenCalledTimes(2) - }) - - it('fences upstream-status reads before, during, and after an SSH mutation', async () => { - const upstreamRequests = Array.from({ length: 3 }, () => - deferredPromise<{ hasUpstream: false; ahead: 0; behind: 0 }>() - ) - const mutation = deferredPromise() - let upstreamRequestIndex = 0 - mux.request.mockImplementation((method) => { - if (method === 'git.upstreamStatus') { - return upstreamRequests[upstreamRequestIndex++]?.promise - } - if (method === 'git.stage') { - return mutation.promise - } - return Promise.resolve(undefined) - }) - - const beforeMutation = provider.getUpstreamStatus('/home/user/repo') - await waitForRequestCount(mux.request, 1) - const mutating = provider.stageFile('/home/user/repo', 'src/file.ts') - await waitForRequestCount(mux.request, 2) - const duringMutation = provider.getUpstreamStatus('/home/user/repo') - await waitForRequestCount(mux.request, 3) - mutation.resolve(undefined) - await mutating - const afterMutation = provider.getUpstreamStatus('/home/user/repo') - await waitForRequestCount(mux.request, 4) - - upstreamRequests.forEach((pending) => - pending.resolve({ hasUpstream: false, ahead: 0, behind: 0 }) - ) - await Promise.all([beforeMutation, duringMutation, afterMutation]) - expect( - mux.request.mock.calls.filter(([method]) => method === 'git.upstreamStatus') - ).toHaveLength(3) - }) -}) diff --git a/src/main/providers/ssh-git-provider-worktree.test.ts b/src/main/providers/ssh-git-provider-worktree.test.ts deleted file mode 100644 index cc9a8024104..00000000000 --- a/src/main/providers/ssh-git-provider-worktree.test.ts +++ /dev/null @@ -1,465 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { - createMockMux, - waitForRequestCount, - type MockMultiplexer -} from './ssh-git-provider-test-harness' - -function methodNotFound(method: string): Error & { code: number } { - return Object.assign(new Error(`Method not found: ${method}`), { code: -32601 }) -} - -const CLEAN_STATUS = { entries: [], conflictOperation: 'unknown' } - -describe('SshGitProvider', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider('conn-1', mux as never) - }) - - it('listWorktrees sends git.listWorktrees request', async () => { - const worktrees = [ - { - path: '/home/user/repo', - head: 'abc123', - branch: 'main', - isBare: false, - isMainWorktree: true - } - ] - mux.request.mockResolvedValue(worktrees) - - const controller = new AbortController() - const result = await provider.listWorktrees('/home/user/repo', { signal: controller.signal }) - expect(mux.request).toHaveBeenCalledWith( - 'git.listWorktrees', - { repoPath: '/home/user/repo' }, - { signal: controller.signal } - ) - expect(result).toEqual(worktrees) - }) - - it('addWorktree sends git.addWorktree request', async () => { - await provider.addWorktree('/home/user/repo', 'feature', '/home/user/feat', { - base: 'main', - noCheckout: true - }) - expect(mux.request).toHaveBeenCalledWith('git.addWorktree', { - repoPath: '/home/user/repo', - branchName: 'feature', - targetDir: '/home/user/feat', - base: 'main', - noCheckout: true - }) - }) - - it('removeWorktree sends git.removeWorktree request', async () => { - await provider.removeWorktree('/home/user/feat', true) - expect(mux.request).toHaveBeenCalledWith('git.removeWorktree', { - worktreePath: '/home/user/feat', - force: true - }) - }) - - it('worktreeIsClean sends git.worktreeIsClean request', async () => { - const cleanResult = { clean: false, stdout: '?? scratch.txt\n' } - mux.request.mockResolvedValue(cleanResult) - - const result = await provider.worktreeIsClean('/home/user/feat') - - expect(mux.request).toHaveBeenCalledWith('git.worktreeIsClean', { - worktreePath: '/home/user/feat' - }) - expect(result).toEqual(cleanResult) - }) - - it('keeps supported clean checks on the preferred relay RPC', async () => { - let resolveProbe!: (result: { clean: boolean }) => void - let resolveRemaining!: (result: { clean: boolean }) => void - const probe = new Promise<{ clean: boolean }>((resolve) => { - resolveProbe = resolve - }) - const remaining = new Promise<{ clean: boolean }>((resolve) => { - resolveRemaining = resolve - }) - let preferredRequestCount = 0 - mux.request.mockImplementation((method) => { - expect(method).toBe('git.worktreeIsClean') - preferredRequestCount += 1 - return preferredRequestCount === 1 ? probe : remaining - }) - const checks = Array.from({ length: 10 }, (_, index) => - provider.worktreeIsClean(`/repo/worktree-${index}`) - ) - - await waitForRequestCount(mux.request, 1) - expect(mux.request).toHaveBeenCalledOnce() - resolveProbe({ clean: true }) - await waitForRequestCount(mux.request, 10) - expect(mux.request).toHaveBeenCalledTimes(10) - resolveRemaining({ clean: true }) - await expect(Promise.all(checks)).resolves.toEqual( - Array.from({ length: 10 }, () => ({ clean: true })) - ) - expect( - mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean') - ).toHaveLength(10) - expect(mux.request.mock.calls.some(([method]) => method === 'git.status')).toBe(false) - }) - - it('worktreeIsClean can ignore untracked files', async () => { - const cleanResult = { clean: true } - mux.request.mockResolvedValue(cleanResult) - - const result = await provider.worktreeIsClean('/home/user/feat', { includeUntracked: false }) - - expect(mux.request).toHaveBeenCalledWith('git.worktreeIsClean', { - worktreePath: '/home/user/feat', - includeUntracked: false - }) - expect(result).toEqual(cleanResult) - }) - - it('worktreeIsClean filters untracked stdout when old relays ignore the option', async () => { - mux.request.mockResolvedValue({ clean: false, stdout: '?? scratch.txt\n' }) - - const result = await provider.worktreeIsClean('/home/user/feat', { includeUntracked: false }) - - expect(result).toEqual({ clean: true }) - }) - - it('worktreeIsClean keeps dirty results without stdout dirty for tracked-only checks', async () => { - mux.request.mockResolvedValue({ clean: false }) - - const result = await provider.worktreeIsClean('/home/user/feat', { includeUntracked: false }) - - expect(result).toEqual({ clean: false }) - }) - - it('refreshLocalBaseRefForWorktreeCreate sends the narrow refresh request and returns its outcome', async () => { - const refs = { - repoPath: '/home/user/repo', - fullRef: 'refs/heads/main', - remoteTrackingRef: 'refs/remotes/origin/main' - } - mux.request.mockResolvedValueOnce({ - status: 'skipped_dirty_worktree', - ownerWorktreePath: '/home/user/repo' - }) - - await expect(provider.refreshLocalBaseRefForWorktreeCreate(refs)).resolves.toEqual({ - status: 'skipped_dirty_worktree', - ownerWorktreePath: '/home/user/repo' - }) - expect(mux.request).toHaveBeenCalledWith('git.refreshLocalBaseRefForWorktreeCreate', refs) - }) - - it('refreshLocalBaseRefForWorktreeCreate reads a malformed relay reply as an error', async () => { - mux.request.mockResolvedValueOnce(undefined) - - await expect( - provider.refreshLocalBaseRefForWorktreeCreate({ - repoPath: '/home/user/repo', - fullRef: 'refs/heads/main', - remoteTrackingRef: 'refs/remotes/origin/main' - }) - ).resolves.toEqual({ status: 'skipped_error' }) - }) - - it('getLocalBaseRefFastForwardableBehind asks the relay to inspect without moving anything', async () => { - const refs = { - repoPath: '/home/user/repo', - fullRef: 'refs/heads/main', - remoteTrackingRef: 'refs/remotes/origin/main' - } - mux.request - .mockResolvedValueOnce({ status: 'behind', behind: 4, localOid: 'a', remoteOid: 'b' }) - .mockResolvedValueOnce({ status: 'skipped_dirty_worktree', ownerWorktreePath: '/x' }) - - await expect(provider.getLocalBaseRefFastForwardableBehind(refs)).resolves.toBe(4) - await expect(provider.getLocalBaseRefFastForwardableBehind(refs)).resolves.toBeUndefined() - expect(mux.request).toHaveBeenCalledWith('git.inspectLocalBaseRefForWorktreeCreate', refs) - expect(mux.request).not.toHaveBeenCalledWith( - 'git.refreshLocalBaseRefForWorktreeCreate', - expect.anything() - ) - }) - - it('worktreeIsClean falls back to git.status for old relays', async () => { - mux.request.mockRejectedValueOnce(methodNotFound('git.worktreeIsClean')).mockResolvedValueOnce({ - entries: [{ path: 'scratch.txt', status: 'untracked', area: 'untracked' }], - conflictOperation: 'unknown' - }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - try { - const result = await provider.worktreeIsClean('/home/user/feat') - - expect(mux.request).toHaveBeenNthCalledWith(1, 'git.worktreeIsClean', { - worktreePath: '/home/user/feat' - }) - expect(mux.request).toHaveBeenNthCalledWith( - 2, - 'git.status', - { worktreePath: '/home/user/feat' }, - { signal: expect.any(AbortSignal) } - ) - expect(result).toEqual({ clean: false, stdout: 'untracked untracked: scratch.txt' }) - } finally { - warnSpy.mockRestore() - } - }) - - it('probes an old relay once across sequential clean checks', async () => { - mux.request.mockImplementation(async (method) => { - if (method === 'git.worktreeIsClean') { - throw methodNotFound(method) - } - return CLEAN_STATUS - }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const worktreePaths = Array.from({ length: 10 }, (_, index) => `/repo/worktree-${index}`) - - try { - for (const worktreePath of worktreePaths) { - await expect(provider.worktreeIsClean(worktreePath)).resolves.toEqual({ clean: true }) - } - - expect( - mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean') - ).toHaveLength(1) - expect(mux.request.mock.calls.filter(([method]) => method === 'git.status')).toHaveLength(10) - expect(mux.request).toHaveBeenCalledTimes(11) - expect(warnSpy).toHaveBeenCalledOnce() - } finally { - warnSpy.mockRestore() - } - }) - - it('shares one old-relay probe across concurrent clean checks', async () => { - let rejectProbe!: (error: Error) => void - const probe = new Promise((_resolve, reject) => { - rejectProbe = reject - }) - mux.request.mockImplementation((method) => { - if (method === 'git.worktreeIsClean') { - return probe - } - return Promise.resolve(CLEAN_STATUS) - }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const checks = Array.from({ length: 10 }, (_, index) => - provider.worktreeIsClean(`/repo/worktree-${index}`) - ) - - try { - await waitForRequestCount(mux.request, 1) - rejectProbe(methodNotFound('git.worktreeIsClean')) - await expect(Promise.all(checks)).resolves.toEqual( - Array.from({ length: 10 }, () => ({ clean: true })) - ) - - expect( - mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean') - ).toHaveLength(1) - expect(mux.request.mock.calls.filter(([method]) => method === 'git.status')).toHaveLength(10) - expect(mux.request).toHaveBeenCalledTimes(11) - expect(warnSpy).toHaveBeenCalledOnce() - } finally { - warnSpy.mockRestore() - } - }) - - it('retries the preferred clean RPC after a transport failure', async () => { - const transportError = Object.assign(new Error('Method not found: git.worktreeIsClean'), { - code: -32602 - }) - mux.request.mockRejectedValueOnce(transportError).mockResolvedValueOnce({ clean: true }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - try { - await expect(provider.worktreeIsClean('/repo/first')).rejects.toBe(transportError) - await expect(provider.worktreeIsClean('/repo/second')).resolves.toEqual({ clean: true }) - expect(mux.request.mock.calls.map(([method]) => method)).toEqual([ - 'git.worktreeIsClean', - 'git.worktreeIsClean' - ]) - expect(warnSpy).not.toHaveBeenCalled() - } finally { - warnSpy.mockRestore() - } - }) - - it('lets concurrent waiters retry after a transient probe failure', async () => { - const transportError = new Error('connection closed') - let rejectProbe!: (error: Error) => void - let resolveRetries!: (result: { clean: boolean }) => void - const probe = new Promise((_resolve, reject) => { - rejectProbe = reject - }) - const retries = new Promise<{ clean: boolean }>((resolve) => { - resolveRetries = resolve - }) - let preferredRequestCount = 0 - mux.request.mockImplementation((method) => { - expect(method).toBe('git.worktreeIsClean') - preferredRequestCount += 1 - return preferredRequestCount === 1 ? probe : retries - }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const checks = Array.from({ length: 3 }, (_, index) => - provider.worktreeIsClean(`/repo/worktree-${index}`) - ) - const settledChecks = Promise.allSettled(checks) - - try { - await waitForRequestCount(mux.request, 1) - rejectProbe(transportError) - await waitForRequestCount(mux.request, 3) - resolveRetries({ clean: true }) - - const results = await settledChecks - expect(results[0]).toEqual({ status: 'rejected', reason: transportError }) - expect(results.slice(1)).toEqual([ - { status: 'fulfilled', value: { clean: true } }, - { status: 'fulfilled', value: { clean: true } } - ]) - await expect(provider.worktreeIsClean('/repo/later')).resolves.toEqual({ clean: true }) - expect(mux.request).toHaveBeenCalledTimes(4) - expect(warnSpy).not.toHaveBeenCalled() - } finally { - warnSpy.mockRestore() - } - }) - - it('re-probes clean support after the provider is replaced', async () => { - mux.request.mockImplementation(async (method) => { - if (method === 'git.worktreeIsClean') { - throw methodNotFound(method) - } - return CLEAN_STATUS - }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - try { - await expect(provider.worktreeIsClean('/repo/first')).resolves.toEqual({ clean: true }) - const replacement = new SshGitProvider('conn-1', mux as never) - await expect(replacement.worktreeIsClean('/repo/second')).resolves.toEqual({ clean: true }) - - expect( - mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean') - ).toHaveLength(2) - expect(warnSpy).toHaveBeenCalledTimes(2) - } finally { - warnSpy.mockRestore() - } - }) - - it('worktreeIsClean filters untracked entries in old-relay fallback', async () => { - mux.request.mockImplementation(async (method) => { - if (method === 'git.worktreeIsClean') { - throw methodNotFound(method) - } - return { - entries: [{ path: 'scratch.txt', status: 'untracked', area: 'untracked' }], - conflictOperation: 'unknown' - } - }) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - try { - await expect(provider.worktreeIsClean('/home/user/feat')).resolves.toEqual({ - clean: false, - stdout: 'untracked untracked: scratch.txt' - }) - await expect( - provider.worktreeIsClean('/home/user/feat', { includeUntracked: false }) - ).resolves.toEqual({ clean: true }) - expect(mux.request).toHaveBeenNthCalledWith( - 3, - 'git.status', - { worktreePath: '/home/user/feat' }, - { signal: expect.any(AbortSignal) } - ) - expect( - mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean') - ).toHaveLength(1) - } finally { - warnSpy.mockRestore() - } - }) - - it('renameCurrentBranch sends the narrow branch-rename request', async () => { - await provider.renameCurrentBranch('/home/user/feat', 'you/fix-auth') - expect(mux.request).toHaveBeenCalledWith('git.renameCurrentBranch', { - worktreePath: '/home/user/feat', - newBranch: 'you/fix-auth' - }) - }) - - it('forceDeletePreservedBranch sends the preserved-branch delete request', async () => { - await provider.forceDeletePreservedBranch('/home/user/repo', 'you/fix-auth', 'abc123') - expect(mux.request).toHaveBeenCalledWith('git.forceDeletePreservedBranch', { - repoPath: '/home/user/repo', - branchName: 'you/fix-auth', - expectedHead: 'abc123' - }) - }) - - it('forceDeletePreservedBranch maps old relays to the reconnect message', async () => { - mux.request.mockRejectedValueOnce(methodNotFound('git.forceDeletePreservedBranch')) - - await expect( - provider.forceDeletePreservedBranch('/home/user/repo', 'you/fix-auth', 'abc123') - ).rejects.toThrow( - 'This SSH host is running an older Orca relay that cannot delete preserved branches. Reconnect to deploy the latest relay, then try again.' - ) - }) - - it('forceDeletePreservedBranch rethrows non-method-not-found errors', async () => { - const error = new Error('remote update-ref failed') - mux.request.mockRejectedValueOnce(error) - - await expect( - provider.forceDeletePreservedBranch('/home/user/repo', 'you/fix-auth', 'abc123') - ).rejects.toBe(error) - }) - - it('markRemoteOrcaCreated sends the narrow provenance-marker request', async () => { - await provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') - expect(mux.request).toHaveBeenCalledWith('git.markRemoteOrcaCreated', { - repoPath: '/home/user/repo', - remoteName: 'pr-contributor-orca' - }) - }) - - it('markRemoteOrcaCreated degrades to a one-time warning for an older relay', async () => { - mux.request.mockRejectedValue(methodNotFound('git.markRemoteOrcaCreated')) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - try { - await expect( - provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') - ).resolves.toBeUndefined() - await expect( - provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') - ).resolves.toBeUndefined() - expect(warnSpy).toHaveBeenCalledTimes(1) - } finally { - warnSpy.mockRestore() - } - }) - - it('markRemoteOrcaCreated rethrows non-method-not-found errors', async () => { - const error = new Error('remote config write failed') - mux.request.mockRejectedValueOnce(error) - - await expect( - provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') - ).rejects.toBe(error) - }) -}) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts deleted file mode 100644 index b2f8f88d76c..00000000000 --- a/src/main/providers/ssh-git-provider.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' -import { gitExecMutatesRepository } from '../../shared/git-exec-mutation' -import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from '../git/hosted-remote-url' -import { - describeMaxBufferOverflowError, - isMaxBufferOverflowError -} from '../git/max-buffer-overflow' -import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' -import type { IGitProvider } from './types' -import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' -import { SshGitWorktreeProvider } from './ssh-git-worktree-provider' -import { ReviewDraftContextError } from '../../shared/review-draft-context-error' - -export class SshGitProvider extends SshGitWorktreeProvider implements IGitProvider { - async readReviewDiff( - worktreePath: string, - mergeBase: string, - format: 'name-status' | 'patch', - options?: { timeoutMs?: number } - ): Promise<{ stdout: string; stderr: string }> { - let result: unknown - try { - result = await requestGitStreamable( - this.mux, - 'git.reviewDiff', - { worktreePath, mergeBase, format }, - options - ) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - throw new ReviewDraftContextError( - 'SSH review draft support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' - ) - } - throw new ReviewDraftContextError( - error instanceof Error ? error.message : 'SSH review diff failed.', - { cause: error } - ) - } - if ( - !result || - typeof result !== 'object' || - !('stdout' in result) || - !('stderr' in result) || - typeof result.stdout !== 'string' || - typeof result.stderr !== 'string' - ) { - throw new ReviewDraftContextError('Invalid SSH review diff response.') - } - return { stdout: result.stdout, stderr: result.stderr } - } - - async getStagedCommitContext(worktreePath: string): Promise { - const branchPromise = this.exec(['branch', '--show-current'], worktreePath).catch(() => ({ - stdout: '' - })) - const [branchResult, summaryResult] = await Promise.all([ - branchPromise, - this.exec(['diff', '--cached', '--name-status'], worktreePath) - ]) - const stagedSummary = summaryResult.stdout.trim() - if (!stagedSummary) { - return null - } - let stagedPatch = '' - try { - const patchResult = await this.exec( - ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'], - worktreePath - ) - stagedPatch = patchResult.stdout - } catch (error) { - if (!isMaxBufferOverflowError(error)) { - throw error - } - console.warn( - '[ssh-git] Staged patch too large to read; using file summary only:', - describeMaxBufferOverflowError(error) - ) - } - return { - branch: branchResult.stdout.trim() || null, - stagedSummary, - stagedPatch - } - } - - async exec( - args: string[], - cwd: string, - options?: { signal?: AbortSignal; timeoutMs?: number } - ): Promise<{ stdout: string; stderr: string }> { - const run = () => - options - ? requestGitStreamable(this.mux, 'git.exec', { args, cwd }, options) - : requestGitStreamable(this.mux, 'git.exec', { args, cwd }) - const result = gitExecMutatesRepository(args) - ? await this.runWithGitReadInvalidation(run) - : await run() - return result as { stdout: string; stderr: string } - } - - async clone( - args: string[], - cwd: string, - options?: { - signal?: AbortSignal - timeoutMs?: number - onProgress?: (progress: { phase: string; percent: number }) => void - } - ): Promise<{ stdout: string; stderr: string }> { - return this.runWithGitReadInvalidation(async () => { - const progressId = `clone-${Date.now()}-${Math.random().toString(36).slice(2)}` - const unsubscribe = options?.onProgress - ? this.mux.onNotificationByMethod('git.cloneProgress', (params) => { - if (params.progressId !== progressId) { - return - } - const phase = params.phase - const percent = params.percent - if (typeof phase === 'string' && typeof percent === 'number') { - options.onProgress?.({ phase, percent }) - } - }) - : undefined - try { - const result = await this.mux.request( - 'git.clone', - { args, cwd, progressId }, - { signal: options?.signal, timeoutMs: options?.timeoutMs } - ) - return result as { stdout: string; stderr: string } - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - throw new Error( - 'SSH clone support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' - ) - } - throw error - } finally { - unsubscribe?.() - } - }) - } - - async isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }> { - return (await this.mux.request('git.isGitRepo', { dirPath })) as { - isRepo: boolean - rootPath: string | null - } - } - - // Remote paths are validated asynchronously by the relay before registration. - isGitRepo(_path: string): boolean { - return true - } - - private async readOriginRemoteUrl(worktreePath: string): Promise { - try { - const result = await this.exec(['remote', 'get-url', 'origin'], worktreePath) - return result.stdout.trim() || null - } catch { - return null - } - } - - async getRemoteFileUrl( - worktreePath: string, - relativePath: string, - line: number - ): Promise { - const remoteUrl = await this.readOriginRemoteUrl(worktreePath) - if (!remoteUrl) { - return null - } - - let defaultBranch = 'main' - try { - const refResult = await this.exec( - ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], - worktreePath - ) - const ref = refResult.stdout.trim() - if (ref) { - defaultBranch = ref.replace(/^refs\/remotes\/origin\//, '') - } - } catch { - // Fall back to 'main'. - } - - return buildHostedRemoteFileUrl(remoteUrl, relativePath, defaultBranch, line) - } - - async getRemoteCommitUrl(worktreePath: string, sha: string): Promise { - const remoteUrl = await this.readOriginRemoteUrl(worktreePath) - if (!remoteUrl) { - return null - } - return buildHostedRemoteCommitUrl(remoteUrl, sha) - } -} diff --git a/src/main/providers/ssh-git-read-provider.ts b/src/main/providers/ssh-git-read-provider.ts deleted file mode 100644 index cbf20271003..00000000000 --- a/src/main/providers/ssh-git-read-provider.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' -import type { GitDiffResult } from '../../shared/git-diff-compare-types' -import type { - GitStagingArea, - GitStatusResult, - GitUpstreamStatus -} from '../../shared/git-status-types' -import type { GitPushTarget } from '../../shared/worktree/types' -import { GitStatusReadLeaseOwner } from '../git/git-status-read-lease-owner' -import { GitUpstreamStatusReadOwner } from '../git/git-upstream-status-read-owner' -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' -import type { GitProviderStatusOptions } from './types' -import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' - -const ABSENT_BRANCH_DIFF_HEAD_OID = { absent: true } as const - -export class SshGitReadProvider { - private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() - private readonly statusReadLeaseOwner = new GitStatusReadLeaseOwner() - private readonly upstreamStatusReadOwner = new GitUpstreamStatusReadOwner() - - constructor( - private readonly connectionId: string, - protected readonly mux: SshChannelMultiplexer, - private readonly hostPlatform: RemoteHostPlatform | null = null - ) {} - - getConnectionId(): string { - return this.connectionId - } - - getHostPlatform(): RemoteHostPlatform | null { - return this.hostPlatform - } - - protected async runWithGitReadInvalidation(run: () => Promise): Promise { - this.invalidateGitReads() - try { - return await run() - } finally { - this.invalidateGitReads() - } - } - - /** Overridden by subclasses that own additional read caches (worktree listings). */ - protected invalidateGitReads(): void { - this.gitDiffReadDedupe.clear() - this.statusReadLeaseOwner.invalidate() - this.upstreamStatusReadOwner.invalidate() - } - - async getStatus( - worktreePath: string, - options?: GitProviderStatusOptions - ): Promise { - this.gitDiffReadDedupe.clear() - const request = { - worktreePath, - ...(options?.admissionTier ? { admissionTier: options.admissionTier } : {}), - ...(options?.includeIgnored ? { includeIgnored: true } : {}), - ...(options?.includeLineStats === false ? { includeLineStats: false } : {}), - ...(options?.bypassEffectiveUpstreamNegativeCache - ? { bypassEffectiveUpstreamNegativeCache: true } - : {}), - ...(options?.reuseLineStats ? { reuseLineStats: true } : {}), - ...(options?.branchLineTotalMergeBase === undefined - ? {} - : { branchLineTotalMergeBase: options.branchLineTotalMergeBase }) - } - const key = stableInFlightKey([ - worktreePath, - options?.admissionTier ?? 'status', - options?.includeIgnored === true, - options?.includeLineStats !== false, - options?.bypassEffectiveUpstreamNegativeCache === true, - options?.reuseLineStats === true, - options?.branchLineTotalMergeBase ?? '' - ]) - return this.statusReadLeaseOwner.lease(key, options?.signal, async (sharedSignal) => { - return (await this.mux.request('git.status', request, { - signal: sharedSignal - })) as GitStatusResult - }) - } - - async getSubmoduleStatus( - worktreePath: string, - submodulePath: string, - area: GitStagingArea = 'unstaged' - ): Promise { - this.gitDiffReadDedupe.clear() - try { - return (await this.mux.request('git.submoduleStatus', { - worktreePath, - submodulePath, - area - })) as GitStatusResult - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - throw new Error( - 'SSH submodule diff support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' - ) - } - throw error - } - } - - async getDiff( - worktreePath: string, - filePath: string, - staged: boolean, - compareAgainstHead?: boolean - ): Promise { - return this.gitDiffReadDedupe.run( - stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), - async () => - (await requestGitStreamable(this.mux, 'git.diff', { - worktreePath, - filePath, - staged, - compareAgainstHead - })) as GitDiffResult - ) as Promise - } - - async getBranchDiff( - worktreePath: string, - baseRef: string, - options?: { includePatch?: boolean; filePath?: string; oldPath?: string; headOid?: string } - ): Promise { - const keyOptions = options ?? {} - const { headOid: rawHeadOid, ...relayOptions } = keyOptions - const headOid = rawHeadOid == null ? undefined : rawHeadOid - return this.gitDiffReadDedupe.run( - stableInFlightKey([ - 'branchDiff', - worktreePath, - baseRef, - keyOptions.includePatch ?? null, - keyOptions.filePath ?? null, - keyOptions.oldPath ?? null, - headOid === undefined ? ABSENT_BRANCH_DIFF_HEAD_OID : headOid - ]), - async () => - (await requestGitStreamable(this.mux, 'git.branchDiff', { - worktreePath, - baseRef, - ...relayOptions, - ...(headOid === undefined ? {} : { headOid }) - })) as GitDiffResult[] - ) as Promise - } - - async getCommitDiff( - worktreePath: string, - args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } - ): Promise { - return this.gitDiffReadDedupe.run( - stableInFlightKey([ - 'commitDiff', - worktreePath, - args.commitOid, - args.parentOid ?? null, - args.filePath, - args.oldPath ?? null - ]), - async () => - (await requestGitStreamable(this.mux, 'git.commitDiff', { - worktreePath, - ...args - })) as GitDiffResult - ) as Promise - } - - async getUpstreamStatus( - worktreePath: string, - pushTarget?: GitPushTarget - ): Promise { - return this.upstreamStatusReadOwner.read( - { kind: 'ssh-provider' }, - worktreePath, - pushTarget, - async () => - (await this.mux.request('git.upstreamStatus', { - worktreePath, - ...(pushTarget ? { pushTarget } : {}) - })) as GitUpstreamStatus - ) - } -} diff --git a/src/main/providers/ssh-git-relay-errors.ts b/src/main/providers/ssh-git-relay-errors.ts deleted file mode 100644 index d8bd8fe9016..00000000000 --- a/src/main/providers/ssh-git-relay-errors.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { JsonRpcErrorCode } from '../ssh/relay-protocol' - -export function isJsonRpcMethodNotFoundError(error: unknown): boolean { - if (!error || typeof error !== 'object') { - return false - } - return (error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound -} diff --git a/src/main/providers/ssh-git-remote-sync-provider.ts b/src/main/providers/ssh-git-remote-sync-provider.ts deleted file mode 100644 index bead9414f3b..00000000000 --- a/src/main/providers/ssh-git-remote-sync-provider.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' -import type { GitPushTarget } from '../../shared/worktree/types' -import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../shared/git-rebase-source' -import { SshGitWorkingTreeProvider } from './ssh-git-working-tree-provider' - -export class SshGitRemoteSyncProvider extends SshGitWorkingTreeProvider { - async pushBranch( - worktreePath: string, - publish = false, - pushTarget?: GitPushTarget, - options: { forceWithLease?: boolean } = {} - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.push', { - worktreePath, - publish, - pushTarget, - ...(options.forceWithLease === true ? { forceWithLease: true } : {}) - }) - }) - } - - async pullBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.pull', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) - }) - } - - async fastForwardBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fastForward', { - worktreePath, - ...(pushTarget ? { pushTarget } : {}) - }) - }) - } - - async rebaseFromBase(worktreePath: string, baseRef: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request( - 'git.rebaseFromBase', - { worktreePath, baseRef }, - { timeoutMs: REBASE_FROM_BASE_RPC_TIMEOUT_MS } - ) - }) - } - - async fetchRemote(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fetch', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) - }) - } - - async syncForkDefaultBranch( - worktreePath: string, - expectedUpstream: GitForkSyncExpectedUpstream - ): Promise { - return this.runWithGitReadInvalidation( - async () => - (await this.mux.request('git.forkSync', { - worktreePath, - ...(expectedUpstream ? { expectedUpstream } : {}) - })) as GitForkSyncResult - ) - } - - async fetchRemoteTrackingRef( - worktreePath: string, - remote: string, - branch: string, - ref: string, - options?: { skipAutoMaintenance?: boolean } - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fetchRemoteTrackingRef', { - worktreePath, - remote, - branch, - ref, - ...(options?.skipAutoMaintenance ? { skipAutoMaintenance: true } : {}) - }) - }) - } -} diff --git a/src/main/providers/ssh-git-review-head-provider.ts b/src/main/providers/ssh-git-review-head-provider.ts deleted file mode 100644 index 5ff5cc69048..00000000000 --- a/src/main/providers/ssh-git-review-head-provider.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' -import { SshGitRemoteSyncProvider } from './ssh-git-remote-sync-provider' - -function readDurableReviewHeadLocalRef( - result: unknown, - kind: 'pull request' | 'merge request' -): string { - if (result && typeof result === 'object' && 'localRef' in result) { - const localRef = (result as { localRef: unknown }).localRef - if (typeof localRef === 'string') { - const trimmed = localRef.trim() - if (trimmed.startsWith('refs/orca/')) { - return trimmed - } - } - } - throw new Error( - `This SSH host did not return the durable ${kind} head ref. Reconnect to deploy the latest relay, then try again.` - ) -} - -export class SshGitReviewHeadProvider extends SshGitRemoteSyncProvider { - async fetchGitLabMergeRequestHead( - worktreePath: string, - remote: string, - mrIid: number - ): Promise { - try { - return await this.runWithGitReadInvalidation(async () => { - const result = await this.mux.request('git.fetchGitLabMergeRequestHeadRef', { - worktreePath, - remote, - mrIid - }) - return readDurableReviewHeadLocalRef(result, 'merge request') - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - throw new Error( - 'This SSH host is running an older Orca relay that cannot fetch merge request heads. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } - - async fetchGitHubPullRequestHead( - worktreePath: string, - remote: string, - prNumber: number - ): Promise { - try { - return await this.runWithGitReadInvalidation(async () => { - const result = await this.mux.request('git.fetchGitHubPullRequestHead', { - worktreePath, - remote, - prNumber - }) - return readDurableReviewHeadLocalRef(result, 'pull request') - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - throw new Error( - 'This SSH host is running an older Orca relay that cannot fetch pull request heads. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } -} diff --git a/src/main/providers/ssh-git-working-tree-provider.ts b/src/main/providers/ssh-git-working-tree-provider.ts deleted file mode 100644 index b26da56be4d..00000000000 --- a/src/main/providers/ssh-git-working-tree-provider.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { - GitBranchCompareResult, - GitCommitCompareResult -} from '../../shared/git-diff-compare-types' -import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' -import type { GitConflictOperation } from '../../shared/git-status-types' -import { - requireGitStageWorktreeScopeReceipt, - type GitStageWorktreeScope -} from '../../shared/git-stage-worktree-scope' -import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' -import { SshGitNoninteractiveProvider } from './ssh-git-noninteractive-provider' - -export class SshGitWorkingTreeProvider extends SshGitNoninteractiveProvider { - async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise { - return (await this.mux.request('git.checkIgnored', { - worktreePath, - paths: relativePaths - })) as string[] - } - - async getHistory( - worktreePath: string, - options: GitHistoryOptions = {} - ): Promise { - return (await this.mux.request('git.history', { - worktreePath, - ...options - })) as GitHistoryResult - } - - async commit( - worktreePath: string, - message: string - ): Promise<{ success: boolean; error?: string }> { - return this.runWithGitReadInvalidation( - async () => - (await this.mux.request('git.commit', { - worktreePath, - message - })) as { success: boolean; error?: string } - ) - } - - async stageFile(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.stage', { worktreePath, filePath }) - }) - } - - async unstageFile(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.unstage', { worktreePath, filePath }) - }) - } - - async bulkStageFiles( - worktreePath: string, - filePaths: string[], - scope?: GitStageWorktreeScope - ): Promise { - await this.runWithGitReadInvalidation(async () => { - const reply = await this.mux.request('git.bulkStage', { - worktreePath, - filePaths, - ...(scope ? { scope } : {}) - }) - if (scope) { - requireGitStageWorktreeScopeReceipt(reply, scope) - } - }) - } - - async bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.bulkUnstage', { worktreePath, filePaths }) - }) - } - - async discardChanges(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.discard', { worktreePath, filePath }) - }) - } - - async bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.bulkDiscard', { worktreePath, filePaths }) - }) - } - - async detectConflictOperation(worktreePath: string): Promise { - return (await this.mux.request('git.conflictOperation', { - worktreePath - })) as GitConflictOperation - } - - async abortMerge(worktreePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.abortMerge', { worktreePath }) - }) - } - - async abortRebase(worktreePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.abortRebase', { worktreePath }) - }) - } - - async checkoutBranch(worktreePath: string, branch: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.checkout', { worktreePath, branch }) - }) - } - - async listLocalBranches( - worktreePath: string - ): Promise<{ current: string | null; branches: string[] }> { - return (await this.mux.request('git.localBranches', { worktreePath })) as { - current: string | null - branches: string[] - } - } - - async getBranchCompare( - worktreePath: string, - baseRef: string, - options: { admissionTier?: GitAdmissionTier } = {} - ): Promise { - return (await this.mux.request('git.branchCompare', { - worktreePath, - baseRef, - ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) - })) as GitBranchCompareResult - } - - async getCommitCompare(worktreePath: string, commitId: string): Promise { - return (await this.mux.request('git.commitCompare', { - worktreePath, - commitId - })) as GitCommitCompareResult - } -} diff --git a/src/main/providers/ssh-git-worktree-list-dedupe.test.ts b/src/main/providers/ssh-git-worktree-list-dedupe.test.ts deleted file mode 100644 index 4030dbe87e4..00000000000 --- a/src/main/providers/ssh-git-worktree-list-dedupe.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Local repos coalesce concurrent `git worktree list` scans (`shareWorktreeScan`); the SSH path - * branched away from that and paid one relay round trip per independent caller (`worktrees:list`, - * `worktrees:listAll`, the space repo scan, provisioned-root adoption). These are call counters. - */ -import { describe, expect, it } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness' - -const REPO_PATH = '/home/user/repo' - -const WORKTREES = [ - { path: REPO_PATH, head: 'abc123', branch: 'main', isBare: false, isMainWorktree: true } -] - -type Deferred = { resolve: (value: unknown) => void; reject: (error: unknown) => void } - -/** Holds `git.listWorktrees` open so overlap is deterministic; answers everything else at once. */ -function createPendingListMux(): { mux: MockMultiplexer; listDeferreds: Deferred[] } { - const mux = createMockMux() - const listDeferreds: Deferred[] = [] - mux.request.mockImplementation((method: string) => { - if (method !== 'git.listWorktrees') { - return Promise.resolve(undefined) - } - return new Promise((resolve, reject) => { - listDeferreds.push({ resolve, reject }) - }) - }) - return { mux, listDeferreds } -} - -const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)) - -function countListRequests(mux: MockMultiplexer): number { - return mux.request.mock.calls.filter((call) => call[0] === 'git.listWorktrees').length -} - -describe('SSH git.listWorktrees in-flight dedupe', () => { - it('collapses concurrent listings of one repo into a single relay request', async () => { - const { mux, listDeferreds } = createPendingListMux() - const provider = new SshGitProvider('conn-1', mux as never) - - const listings = Array.from({ length: 6 }, () => provider.listWorktrees(REPO_PATH)) - await flush() - - expect(countListRequests(mux)).toBe(1) - expect(mux.request).toHaveBeenCalledWith( - 'git.listWorktrees', - { repoPath: REPO_PATH }, - { signal: undefined } - ) - - listDeferreds[0].resolve(WORKTREES) - expect(await Promise.all(listings)).toEqual(Array.from({ length: 6 }, () => WORKTREES)) - }) - - it('does not share across repos or connections', async () => { - const { mux } = createPendingListMux() - const provider = new SshGitProvider('conn-1', mux as never) - - void provider.listWorktrees(REPO_PATH) - void provider.listWorktrees('/home/user/other') - await flush() - expect(countListRequests(mux)).toBe(2) - - const second = createPendingListMux() - void new SshGitProvider('conn-2', second.mux as never).listWorktrees(REPO_PATH) - await flush() - - expect(countListRequests(second.mux)).toBe(1) - expect(countListRequests(mux)).toBe(2) - }) - - it('keeps a signalled listing on its own request', async () => { - const { mux } = createPendingListMux() - const provider = new SshGitProvider('conn-1', mux as never) - - void provider.listWorktrees(REPO_PATH) - await flush() - const controller = new AbortController() - void provider.listWorktrees(REPO_PATH, { signal: controller.signal }) - await flush() - - expect(countListRequests(mux)).toBe(2) - expect(mux.request).toHaveBeenCalledWith( - 'git.listWorktrees', - { repoPath: REPO_PATH }, - { signal: controller.signal } - ) - }) - - it('re-requests after the shared listing settles instead of caching it', async () => { - const { mux, listDeferreds } = createPendingListMux() - const provider = new SshGitProvider('conn-1', mux as never) - - const first = provider.listWorktrees(REPO_PATH) - await flush() - listDeferreds[0].resolve(WORKTREES) - await first - - void provider.listWorktrees(REPO_PATH) - await flush() - - expect(countListRequests(mux)).toBe(2) - }) - - it.each([ - ['addWorktree', (p: SshGitProvider) => p.addWorktree(REPO_PATH, 'feature', '/home/user/feat')], - ['removeWorktree', (p: SshGitProvider) => p.removeWorktree('/home/user/feat')] - ])('invalidates the shared listing after %s', async (_name, mutate) => { - const { mux } = createPendingListMux() - const provider = new SshGitProvider('conn-1', mux as never) - - void provider.listWorktrees(REPO_PATH) - await flush() - expect(countListRequests(mux)).toBe(1) - - await mutate(provider) - - // The catalog moved, so a joiner must not inherit the pre-mutation scan. - void provider.listWorktrees(REPO_PATH) - await flush() - expect(countListRequests(mux)).toBe(2) - }) - - it('shares a failed listing with its joiners and re-requests afterwards', async () => { - const { mux, listDeferreds } = createPendingListMux() - const provider = new SshGitProvider('conn-1', mux as never) - - const listings = [provider.listWorktrees(REPO_PATH), provider.listWorktrees(REPO_PATH)] - await flush() - expect(countListRequests(mux)).toBe(1) - - const failure = new Error('relay request failed') - listDeferreds[0].reject(failure) - await expect(listings[0]).rejects.toBe(failure) - await expect(listings[1]).rejects.toBe(failure) - - void provider.listWorktrees(REPO_PATH) - await flush() - expect(countListRequests(mux)).toBe(2) - }) - - it('refuses an unauthoritative relay answer for every joiner (#14004)', async () => { - const { mux, listDeferreds } = createPendingListMux() - const provider = new SshGitProvider('conn-1', mux as never) - - const listings = [provider.listWorktrees(REPO_PATH), provider.listWorktrees(REPO_PATH)] - await flush() - listDeferreds[0].resolve([]) - - await expect(listings[0]).rejects.toThrow() - await expect(listings[1]).rejects.toThrow() - }) -}) diff --git a/src/main/providers/ssh-git-worktree-provider.ts b/src/main/providers/ssh-git-worktree-provider.ts deleted file mode 100644 index 4e1e0f3a057..00000000000 --- a/src/main/providers/ssh-git-worktree-provider.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { GitStatusResult } from '../../shared/git-status-types' -import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' -import type { GitWorktreeInfo } from '../../shared/worktree/types' -import { CapabilityProbeCache } from '../../shared/capability-probe-cache' -import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' -import { assertAuthoritativeWorktreeCatalog } from '../../shared/worktree/worktree-catalog-availability' -import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' -import { - parseLocalBaseBranchFastForwardOutcome, - readFastForwardableBehindCount, - type LocalBaseBranchFastForwardOutcome, - type LocalBaseBranchRefs -} from '../../shared/worktree/local-base-branch-fast-forward' -import { SshGitReviewHeadProvider } from './ssh-git-review-head-provider' - -const WORKTREE_IS_CLEAN_CAPABILITY = 'git.worktreeIsClean' as const - -function formatStatusEntriesForCleanCheck(entries: GitStatusResult['entries']): string | undefined { - if (entries.length === 0) { - return undefined - } - return entries.map((entry) => `${entry.area} ${entry.status}: ${entry.path}`).join('\n') -} - -function filterUntrackedPorcelainStatus(stdout: string | undefined): string | undefined { - const trackedLines = (stdout ?? '') - .split(/\r?\n/) - .filter((line) => line.trim().length > 0 && !line.startsWith('?? ')) - return trackedLines.length > 0 ? trackedLines.join('\n') : undefined -} - -export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { - private loggedWorktreeIsCleanFallback = false - private loggedMarkRemoteOrcaCreatedFallback = false - // Why: reconnect replaces this provider, so an upgraded relay is naturally re-probed. - private readonly worktreeIsCleanCapabilityCache = new CapabilityProbeCache< - typeof WORKTREE_IS_CLEAN_CAPABILITY - >(Number.POSITIVE_INFINITY) - // Scoped to this provider instance, so two SSH hosts never share an entry. - private readonly worktreeListDedupe = new InFlightPromiseDedupe() - - protected override invalidateGitReads(): void { - super.invalidateGitReads() - this.worktreeListDedupe.clear() - } - - /** Un-signalled reads of one repo coalesce onto the request already in flight; nothing is cached. */ - async listWorktrees( - repoPath: string, - options?: { signal?: AbortSignal } - ): Promise { - // Why: same rule as shareWorktreeScan — one caller's abort must not cancel the scan its - // joiners are still waiting on, so a signalled read keeps its own request. - if (options?.signal) { - return this.requestWorktreeList(repoPath, options.signal) - } - return this.worktreeListDedupe.run(stableInFlightKey(['listWorktrees', repoPath]), () => - this.requestWorktreeList(repoPath) - ) - } - - /** The one real relay round trip a coalesced read's joiners all wait on. */ - private async requestWorktreeList( - repoPath: string, - signal?: AbortSignal - ): Promise { - const response = await this.mux.request('git.listWorktrees', { repoPath }, { signal }) - // Why (#14004): relays before this fix answered a failed worktree scan with `[]`. Mixed versions are - // normal, so refuse the shape here too — a Git repo always lists its own checkout. - return assertAuthoritativeWorktreeCatalog(response, repoPath) - } - - async addWorktree( - repoPath: string, - branchName: string, - targetDir: string, - options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean } - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.addWorktree', { - repoPath, - branchName, - targetDir, - ...options - }) - }) - } - - async removeWorktree( - worktreePath: string, - force?: boolean, - options?: { deleteBranch?: boolean; forceBranchDelete?: boolean } - ): Promise { - return this.runWithGitReadInvalidation( - async () => - ((await this.mux.request('git.removeWorktree', { - worktreePath, - force, - ...options - })) ?? {}) as RemoveWorktreeResult - ) - } - - async worktreeIsClean( - worktreePath: string, - options: { includeUntracked?: boolean } = {} - ): Promise<{ clean: boolean; stdout?: string }> { - return this.worktreeIsCleanCapabilityCache.runWithFallback( - WORKTREE_IS_CLEAN_CAPABILITY, - async () => { - const result = (await this.mux.request('git.worktreeIsClean', { - worktreePath, - ...(options.includeUntracked === false ? { includeUntracked: false } : {}) - })) as { clean: boolean; stdout?: string } - if (options.includeUntracked === false) { - if (!result.clean && result.stdout === undefined) { - return result - } - const trackedStdout = filterUntrackedPorcelainStatus(result.stdout) - return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) } - } - return result - }, - async () => { - if (!this.loggedWorktreeIsCleanFallback) { - this.loggedWorktreeIsCleanFallback = true - console.warn( - '[ssh-git] Relay does not implement git.worktreeIsClean; falling back to git.status clean check' - ) - } - const status = await this.getStatus(worktreePath) - const entries = - options.includeUntracked === false - ? status.entries.filter((entry) => entry.area !== 'untracked') - : status.entries - const clean = entries.length === 0 - return { clean, stdout: formatStatusEntriesForCleanCheck(entries) } - }, - isJsonRpcMethodNotFoundError - ) - } - - /** The relay inspects and moves the branch host-side, one run per branch at a time. */ - async refreshLocalBaseRefForWorktreeCreate( - args: LocalBaseBranchRefs - ): Promise { - return this.runWithGitReadInvalidation(async () => - parseLocalBaseBranchFastForwardOutcome( - await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args) - ) - ) - } - - /** Commits local is behind when the relay could fast-forward it; read-only. */ - async getLocalBaseRefFastForwardableBehind( - args: LocalBaseBranchRefs - ): Promise { - return readFastForwardableBehindCount( - await this.mux.request('git.inspectLocalBaseRefForWorktreeCreate', args) - ) - } - - async renameCurrentBranch(worktreePath: string, newBranch: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch }) - }) - } - - // Why: git.exec blocks config writes outright, so the deferred fork-remote provenance - // marker (#17828) needs its own RPC. Non-essential to push/pull, so an older relay - // that hasn't shipped it yet degrades to no marker rather than failing materialization. - async markRemoteOrcaCreated(repoPath: string, remoteName: string): Promise { - try { - await this.mux.request('git.markRemoteOrcaCreated', { repoPath, remoteName }) - } catch (error) { - if (!isJsonRpcMethodNotFoundError(error)) { - throw error - } - if (!this.loggedMarkRemoteOrcaCreatedFallback) { - this.loggedMarkRemoteOrcaCreatedFallback = true - console.warn( - "[ssh-git] Relay does not implement git.markRemoteOrcaCreated; this remote will lack a git-config provenance marker permanently (reconnecting does not retroactively add it -- only a newer relay deployment does, for remotes added after that). The store's remoteCreated flag remains the fallback ownership signal for cleanup." - ) - } - } - } - - async forceDeletePreservedBranch( - repoPath: string, - branchName: string, - expectedHead: string - ): Promise { - try { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.forceDeletePreservedBranch', { - repoPath, - branchName, - expectedHead - }) - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - throw new Error( - 'This SSH host is running an older Orca relay that cannot delete preserved branches. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } -} diff --git a/src/main/providers/ssh-host-not-served-error.ts b/src/main/providers/ssh-host-not-served-error.ts index 5830556b306..e8b9bde6a79 100644 --- a/src/main/providers/ssh-host-not-served-error.ts +++ b/src/main/providers/ssh-host-not-served-error.ts @@ -5,11 +5,24 @@ export const SSH_HOST_NOT_SERVED_MESSAGE = 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' +/** The wording SSH review-head fetches have always used. */ +export const SSH_GIT_RECONNECT_MESSAGE = + 'SSH Git provider is not available. Reconnect to this target and try again.' + +/** The wording project registration on an SSH target has always used. */ +export function sshTargetNotConnectedMessage(connectionId: string): string { + return `SSH connection "${connectionId}" not found or not connected` +} + +// Why no custom `name`: Electron IPC rejects with `${error}`, so the renderer keeps seeing +// `Error: `, the text these paths showed before the typed error existed. export class SshHostNotServedError extends Error { readonly code = 'ssh_host_not_served' - constructor(readonly connectionId: string) { - super(SSH_HOST_NOT_SERVED_MESSAGE) - this.name = 'SshHostNotServedError' + constructor( + readonly connectionId: string, + message: string = SSH_HOST_NOT_SERVED_MESSAGE + ) { + super(message) } } diff --git a/src/main/providers/ssh-quick-open-discovery-options.ts b/src/main/providers/ssh-quick-open-discovery-options.ts deleted file mode 100644 index c0ac6652eac..00000000000 --- a/src/main/providers/ssh-quick-open-discovery-options.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { IFilesystemProvider } from './types' - -export async function resolveSshQuickOpenDiscoveryOptions( - provider: IFilesystemProvider, - options: { - includeIgnored?: boolean - followSymlinks?: boolean - allowLegacyIncludeIgnored?: boolean - }, - signal?: AbortSignal -): Promise<{ includeIgnored?: boolean; followSymlinks?: boolean }> { - const supported = - (options.includeIgnored !== false && !options.followSymlinks) || - (await provider.supportsQuickOpenSearch?.({ signal, minimumVersion: 2 })) - if (!supported && (options.followSymlinks || !options.allowLegacyIncludeIgnored)) { - throw new Error('Update the remote host to use Quick Open listing options.') - } - return { - ...(options.includeIgnored === undefined || !supported - ? {} - : { includeIgnored: options.includeIgnored }), - ...(options.followSymlinks === undefined ? {} : { followSymlinks: options.followSymlinks }) - } -} diff --git a/src/main/providers/ssh-review-draft-context.ts b/src/main/providers/ssh-review-draft-context.ts deleted file mode 100644 index b3d7a913ab0..00000000000 --- a/src/main/providers/ssh-review-draft-context.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { getPullRequestDraftContext } from '../text-generation/pull-request-context' -import type { SshGitProvider } from './ssh-git-provider' - -type DraftExecOptions = Parameters[0]>[1] - -export async function execSshReviewDraft( - provider: Pick, - args: string[], - cwd: string, - commandOptions?: DraftExecOptions -): Promise<{ stdout: string; stderr: string }> { - const timeoutMs = commandOptions?.timeoutMs ?? commandOptions?.timeout - const options = timeoutMs === undefined ? undefined : { timeoutMs } - if (args.length === 4 && args[0] === 'fetch' && args[1] === '--no-tags') { - const match = /^\+refs\/heads\/([^:]+):(.+)$/.exec(args[3]) - if (match && match[2] === `refs/remotes/${args[2]}/${match[1]}`) { - await provider.fetchRemoteTrackingRef(cwd, args[2], match[1], match[2]) - return { stdout: '', stderr: '' } - } - } - const range = /^([0-9a-f]{40}(?:[0-9a-f]{24})?)\.\.HEAD$/i.exec(args.at(-1) ?? '') - if (args[0] === 'diff' && range) { - const flags = args.slice(1, -1).join('\0') - const format = - flags === '--name-status' - ? 'name-status' - : flags === '--patch\0--minimal\0--no-color\0--no-ext-diff' - ? 'patch' - : null - if (format) { - return provider.readReviewDiff(cwd, range[1], format, options) - } - } - return options ? provider.exec(args, cwd, options) : provider.exec(args, cwd) -} diff --git a/src/main/providers/ssh-worktree-catalog-authority.test.ts b/src/main/providers/ssh-worktree-catalog-authority.test.ts deleted file mode 100644 index 4ae6530e664..00000000000 --- a/src/main/providers/ssh-worktree-catalog-authority.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Issue #14004: an SSH worktree catalog Orca could not read must never surface as an authoritative - * empty catalog. Covers the whole client-side chain — provider response guard, the repo-level - * listing, and the detected-worktree result whose `authoritative` flag gates renderer terminal - * teardown (`teardownMissingWorktreeTerminalsBestEffort`). - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SshGitProvider } from './ssh-git-provider' -import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness' -import { isWorktreeCatalogUnavailableError } from '../../shared/worktree/worktree-catalog-availability' -import { listRepoWorktrees } from '../repo-worktrees' -import { listDetectedWorktreesForCapturedRepo } from '../ipc/worktrees/listing/detected-provider-listing' -import type { Repo } from '../../shared/repo-types' -import type { Store } from '../persistence/loading-store/store' - -const { getSshGitProviderMock } = vi.hoisted(() => ({ getSshGitProviderMock: vi.fn() })) - -vi.mock('./ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - requireSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: () => 1 -})) - -const CONNECTION_ID = 'conn-1' -const REPO_PATH = '/home/user/repo' -const WORKTREE_PATH = '/home/user/feature' - -const repo: Repo = { - id: 'repo-1', - path: REPO_PATH, - displayName: 'repo', - connectionId: CONNECTION_ID -} as Repo - -const worktreeId = `${repo.id}::${WORKTREE_PATH}` - -function createStore(): Store { - const meta: Record = { - [worktreeId]: { instanceId: 'instance-1' } - } - return { - getRepos: () => [repo], - getRepo: () => repo, - getAllWorktreeMeta: () => meta, - getWorktreeMeta: (id: string) => meta[id], - setWorktreeMeta: vi.fn(), - getAllWorktreeLineage: () => ({}), - getProjectHostSetups: () => [], - getSettings: () => ({}) - } as unknown as Store -} - -describe('SSH worktree catalog authority (#14004)', () => { - let mux: MockMultiplexer - let provider: SshGitProvider - - beforeEach(() => { - mux = createMockMux() - provider = new SshGitProvider(CONNECTION_ID, mux as never) - getSshGitProviderMock.mockReset() - getSshGitProviderMock.mockReturnValue(provider) - }) - - it('refuses an empty relay response instead of publishing an empty catalog', async () => { - // An older relay converted a failed `git worktree list` into `[]`; mixed versions are the normal state. - mux.request.mockResolvedValue([]) - - await expect(provider.listWorktrees(REPO_PATH)).rejects.toSatisfy( - isWorktreeCatalogUnavailableError - ) - }) - - it('refuses a malformed relay response', async () => { - mux.request.mockResolvedValue(undefined) - - await expect(provider.listWorktrees(REPO_PATH)).rejects.toSatisfy( - isWorktreeCatalogUnavailableError - ) - }) - - it('reports an unreachable SSH host as unavailable, not as an empty repo listing', async () => { - getSshGitProviderMock.mockReturnValue(undefined) - - await expect(listRepoWorktrees(repo)).rejects.toSatisfy(isWorktreeCatalogUnavailableError) - }) - - it('does not authorize missing-worktree teardown for an SSH host this process does not serve', async () => { - const result = await listDetectedWorktreesForCapturedRepo(createStore(), repo, () => true) - - expect(result).toMatchObject({ authoritative: false, source: 'metadata-fallback' }) - // The persisted workspace survives, so the renderer has nothing to reconcile away. - expect( - (result as { worktrees: { id: string }[] }).worktrees.map((worktree) => worktree.id) - ).toContain(worktreeId) - }) -}) diff --git a/src/main/repo-git-remote-identity.test.ts b/src/main/repo-git-remote-identity.test.ts index 67e84a70e3f..74a22730295 100644 --- a/src/main/repo-git-remote-identity.test.ts +++ b/src/main/repo-git-remote-identity.test.ts @@ -1,6 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { gitExecFileAsync } from './git/runner' -import { registerSshGitProvider, unregisterSshGitProvider } from './providers/ssh-git-dispatch' import { probeGitRemoteIdentity } from './repo-git-remote-identity' vi.mock('./git/runner', () => ({ gitExecFileAsync: vi.fn() })) @@ -12,25 +11,10 @@ const gitlabIdentity = { remoteUrl: 'git@gitlab.example.com:team/orca.git' } -const registered: string[] = [] - -function registerHost(connectionId: string, stdout = gitlabRemote) { - const exec = vi.fn().mockResolvedValue({ stdout, stderr: '' }) - registerSshGitProvider(connectionId, { exec } as never) - registered.push(connectionId) - return exec -} - beforeEach(() => { vi.clearAllMocks() }) -afterEach(() => { - for (const connectionId of registered.splice(0)) { - unregisterSshGitProvider(connectionId) - } -}) - describe('probeGitRemoteIdentity', () => { it('resolves the canonical identity for a non-GitHub remote', async () => { vi.mocked(gitExecFileAsync).mockResolvedValue({ stdout: gitlabRemote, stderr: '' }) @@ -49,26 +33,20 @@ describe('probeGitRemoteIdentity', () => { }) }) - it('reports unavailable for an SSH host, even with a registered git provider', async () => { - const exec = registerHost('builder') - + it('reports unavailable for an SSH host', async () => { await expect(probeGitRemoteIdentity('/repos/orca', 'ssh:builder')).resolves.toEqual({ status: 'unavailable' }) - expect(exec).not.toHaveBeenCalled() expect(gitExecFileAsync).not.toHaveBeenCalled() }) // A runtime host's Git is executed by that environment's own server, and the SSH target on its // repo row lives in that server's namespace. Dialing a same-named target here answers for // another machine's repository. - it('refuses a runtime host even when its nested SSH target is registered on this client', async () => { - const nested = registerHost('nested-1') - + it('refuses a runtime host', async () => { await expect(probeGitRemoteIdentity('/repos/orca', 'runtime:env-a')).resolves.toEqual({ status: 'unavailable' }) - expect(nested).not.toHaveBeenCalled() expect(gitExecFileAsync).not.toHaveBeenCalled() }) diff --git a/src/main/repo-git-username-enrichment.ts b/src/main/repo-git-username-enrichment.ts index aa9bb7dd415..74c82e1c674 100644 --- a/src/main/repo-git-username-enrichment.ts +++ b/src/main/repo-git-username-enrichment.ts @@ -44,8 +44,7 @@ async function enrichRepoGitUsernamesInBackground( const candidates = repos.filter( (repo) => repo.kind !== 'folder' && - // Why: SSH repo paths are remote; local git cannot inspect them. The - // SSH username path (getSshGitUsername) stays caller-driven. + // Why: SSH repo paths are remote; local git cannot inspect them. !repo.connectionId && !attemptedLocations.has(getRepoLocationKey(repo)) ) diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index 008d6684615..6b8eaf30e7b 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -1,13 +1,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { gitExecFileAsync } from './git/runner' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from './providers/ssh-filesystem-dispatch' -import type { IFilesystemProvider } from './providers/types' import { detectRepoIcon, detectRepoIconAndUpstream } from './repo-icon-autodetect' const PNG_1X1_BASE64 = @@ -21,30 +16,7 @@ async function makeTempRepoDir(): Promise { return dir } -const registeredHosts: string[] = [] - -/** A remote host whose only readable file is a package.json naming a host-specific homepage. */ -function registerHomepageHost(connectionId: string, homepage: string) { - const stat = vi.fn(async (filePath: string) => { - if (!filePath.endsWith('/package.json')) { - throw new Error('ENOENT') - } - return { type: 'file', size: 64, mtime: 0 } - }) - const readFile = vi.fn(async () => ({ - content: JSON.stringify({ homepage }), - isBinary: false, - mimeType: 'application/json' - })) - registerSshFilesystemProvider(connectionId, { stat, readFile } as unknown as IFilesystemProvider) - registeredHosts.push(connectionId) - return { stat, readFile } -} - afterEach(async () => { - for (const connectionId of registeredHosts.splice(0)) { - unregisterSshFilesystemProvider(connectionId) - } await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) @@ -212,30 +184,16 @@ describe('detectRepoIcon', () => { ).resolves.toMatchObject({ source: 'file', label: 'favicon.png' }) }) - it('reads nothing for an SSH host, even with a registered filesystem provider', async () => { - const repoPath = await makeTempRepoDir() - // On disk on this machine, so a host-blind probe would answer with it. - await writeFile(join(repoPath, 'favicon.png'), Buffer.from(PNG_1X1_BASE64, 'base64')) - const host = registerHomepageHost('m4air', 'https://m4air.example.com') - - await expect( - detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'ssh:m4air' }) - ).resolves.toBeUndefined() - expect(host.stat).not.toHaveBeenCalled() - }) - - it('reads nothing for a runtime host even when its nested SSH target is registered here', async () => { + it('reads nothing for a runtime host', async () => { // Why: a `runtime:` repo row's `connectionId` names a target in that server's namespace. Both // spellings of the incumbent shape are wrong here — a null one reads this machine's copy of // the path, and the nested id dials a same-named box of ours. const repoPath = await makeTempRepoDir() await writeFile(join(repoPath, 'favicon.png'), Buffer.from(PNG_1X1_BASE64, 'base64')) - const nested = registerHomepageHost('nested-1', 'https://nested.example.com') await expect( detectRepoIcon({ repoPath, kind: 'folder', executionHostId: 'runtime:env-a' }) ).resolves.toBeUndefined() - expect(nested.stat).not.toHaveBeenCalled() }) it('falls back to the GitHub owner avatar for GitHub repos', async () => { diff --git a/src/main/runtime/device-entry.ts b/src/main/runtime/device-entry.ts index 41804c1163d..ac05b74f120 100644 --- a/src/main/runtime/device-entry.ts +++ b/src/main/runtime/device-entry.ts @@ -35,6 +35,8 @@ export type DeviceEntry = { devicePublicKeyB64?: string // The token row this installation migrated from; that row stays for its other holders. forkedFrom?: string + // Session-only browser key (memory only); the row is dropped on host restart and after idling. + ephemeral?: true } /** A row that authenticates by bearer token: every pairing offer and delegated child. */ diff --git a/src/main/runtime/device-key-fork.ts b/src/main/runtime/device-key-fork.ts index 00099a31bdb..cfa95d464f0 100644 --- a/src/main/runtime/device-key-fork.ts +++ b/src/main/runtime/device-key-fork.ts @@ -29,7 +29,8 @@ export function planDeviceKeyFork( legacyDeviceId: string, devicePublicKeyB64: string, label: string, - now: number + now: number, + options: { ephemeral?: boolean } = {} ): DeviceKeyFork { const legacy = devices.find((device) => device.deviceId === legacyDeviceId) if (!legacy) { @@ -70,6 +71,7 @@ export function planDeviceKeyFork( credentialKind: 'device-key', devicePublicKeyB64, forkedFrom: legacy.deviceId, + ...(options.ephemeral ? { ephemeral: true as const } : {}), // Why: orchestration work owned under the token keeps its owner after migration. callerFingerprint: devicePrincipalOf(legacy).callerFingerprint, pairedAt: now, diff --git a/src/main/runtime/device-registry-file-read.ts b/src/main/runtime/device-registry-file-read.ts new file mode 100644 index 00000000000..30bab126e83 --- /dev/null +++ b/src/main/runtime/device-registry-file-read.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs' +import { hardenExistingSecureFile, isUnreadableError } from '../../shared/secure-file' +import { corruptRegistryCopyExists, isMissingFileError } from './device-registry-corruption' +import type { DeviceEntry } from './device-entry' +import { normalizeLoadedDeviceRow } from './device-registry-row-validation' + +export type DeviceRegistryFileRead = { + devices: DeviceEntry[] + /** The registry exists but could not be read, which makes `devices` a lie to save from. */ + unreadable: boolean + /** A parse failure, now or in an earlier run, means an unlisted device may still exist. */ + doubtful: boolean + /** A corrupt file must be set aside before the first rewrite. */ + corruptFilePending: boolean +} + +export function readDeviceRegistryFile(registryPath: string): DeviceRegistryFileRead { + // Why: a set-aside corrupt copy keeps revocation verdicts off until the owner removes it, even + // when the rewrite after quarantine never landed and the registry file itself is missing. + const doubtful = corruptRegistryCopyExists(registryPath) + try { + hardenExistingSecureFile(registryPath) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: normalizeLoadedDeviceRow re-validates every field; a non-array throws below and reads as corrupt. + const parsed = JSON.parse(readFileSync(registryPath, 'utf-8')) as DeviceEntry[] + return { + devices: parsed.map(normalizeLoadedDeviceRow), + unreadable: false, + doubtful, + corruptFilePending: false + } + } catch (error) { + if (isMissingFileError(error)) { + return { devices: [], unreadable: false, doubtful, corruptFilePending: false } + } + // "Cannot read" is not "is empty". Saving an empty list over a registry we were merely + // denied would erase every paired device's bearer token, and the write would succeed. + const unreadable = isUnreadableError(error) + // Why: a corrupt file is not evidence any device was revoked; never tell a keyed client so. + return { devices: [], unreadable, doubtful: true, corruptFilePending: !unreadable } + } +} diff --git a/src/main/runtime/device-registry-row-validation.ts b/src/main/runtime/device-registry-row-validation.ts index 591732b98ca..04966feec63 100644 --- a/src/main/runtime/device-registry-row-validation.ts +++ b/src/main/runtime/device-registry-row-validation.ts @@ -76,15 +76,21 @@ export function normalizeLoadedDeviceRow(device: DeviceEntry): DeviceEntry { // Why: a malformed keyed row stays keyed (so it never falls back to a token) but cannot authenticate. function validDeviceKeyFields( device: DeviceEntry -): Pick { +): Pick { if (device.credentialKind !== 'device-key') { - return { credentialKind: undefined, devicePublicKeyB64: undefined, forkedFrom: undefined } + return { + credentialKind: undefined, + devicePublicKeyB64: undefined, + forkedFrom: undefined, + ephemeral: undefined + } } return { credentialKind: 'device-key', devicePublicKeyB64: isDevicePublicKeyB64(device.devicePublicKeyB64) ? device.devicePublicKeyB64 : undefined, - forkedFrom: typeof device.forkedFrom === 'string' ? device.forkedFrom : undefined + forkedFrom: typeof device.forkedFrom === 'string' ? device.forkedFrom : undefined, + ephemeral: device.ephemeral === true ? true : undefined } } diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 677d5b189b7..cf312bc79d7 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -2,24 +2,16 @@ // (mobile) connections. Each paired device gets its own revocable token so // compromising one device doesn't expose others. The registry is a simple // JSON file with hardened permissions matching the runtime metadata pattern. -import { readFileSync } from 'node:fs' import { join } from 'node:path' -import { - hardenExistingSecureFile, - isUnreadableError, - writeDurableSecureJsonFile, - writeSecureJsonFile -} from '../../shared/secure-file' +import { writeDurableSecureJsonFile, writeSecureJsonFile } from '../../shared/secure-file' import type { DeviceScope } from '../../shared/runtime-types' import { removeStaleDurableWriteTempFiles } from '../durable-file-write' import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' import { mintDeviceCredential } from './device-credential' import { planDeviceKeyFork } from './device-key-fork' -import { - corruptRegistryCopyExists, - isMissingFileError, - quarantineCorruptRegistry -} from './device-registry-corruption' +import { planIdleEphemeralPrune, withoutEphemeralRows } from './ephemeral-device-rows' +import { quarantineCorruptRegistry } from './device-registry-corruption' +import { readDeviceRegistryFile } from './device-registry-file-read' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' @@ -29,7 +21,7 @@ import { type DelegatedMobileDeviceEntry } from './delegated-mobile-devices' import type { RuntimeDeviceGrant } from './rpc/rpc-method-permission' -import { normalizeLoadedDeviceRow, validGrants } from './device-registry-row-validation' +import { validGrants } from './device-registry-row-validation' import { isPendingTokenRow, mobilePairingConnectionModeOf, @@ -247,13 +239,19 @@ export class DeviceRegistry { } /** Forks `legacyDeviceId` into a keyed row; durable before it returns (security transition). */ - forkDeviceKey(legacyDeviceId: string, devicePublicKeyB64: string, label: string): DeviceEntry { + forkDeviceKey( + legacyDeviceId: string, + devicePublicKeyB64: string, + label: string, + options: { ephemeral?: boolean } = {} + ): DeviceEntry { const fork = planDeviceKeyFork( this.devices, legacyDeviceId, devicePublicKeyB64, label, - Date.now() + Date.now(), + options ) if (fork.created) { this.save(fork.nextDevices, { durable: true }) @@ -262,6 +260,16 @@ export class DeviceRegistry { return fork.entry } + /** Drops idle session-only rows; durable, since a removed row is a revocation. */ + pruneIdleEphemeralDevices(now: number, isLive: (deviceId: string) => boolean): string[] { + const plan = planIdleEphemeralPrune(this.devices, now, isLive) + if (plan.removed.length > 0) { + this.save(plan.nextDevices, { durable: true }) + this.devices = plan.nextDevices + } + return plan.removed + } + /** The row an authenticated socket is still entitled to, or null once revoked or re-credentialed. */ resolveAuthenticatedPrincipal(principal: AuthenticatedPrincipalBinding): DeviceEntry | null { const device = this.getDevice(principal.deviceId) @@ -306,11 +314,15 @@ export class DeviceRegistry { return } this.cancelPendingLastSeenFlush() + // Why: matches the async hardening path — a failed bookkeeping write must not take down the runtime. + this.saveOrLog({}, 'persist device lastSeen') + } + + private saveOrLog(options: { durable?: boolean }, action: string): void { try { - this.save(this.devices) + this.save(this.devices, options) } catch (error) { - // Why: matches the async hardening path — a failed bookkeeping write must not take down the runtime. - console.error('[mobile] Failed to persist device lastSeen:', error) + console.error(`[runtime] Failed to ${action}:`, error) } } @@ -322,25 +334,14 @@ export class DeviceRegistry { } private load(): void { - // Why: a set-aside corrupt copy keeps revocation verdicts off until the owner removes it, even - // when the rewrite after quarantine never landed and the registry file itself is missing. - this.registryDoubtful = corruptRegistryCopyExists(this.registryPath) - try { - hardenExistingSecureFile(this.registryPath) - const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[] - this.devices = parsed.map(normalizeLoadedDeviceRow) - this.registryUnreadable = false - } catch (error) { - this.devices = [] - if (isMissingFileError(error)) { - return - } - // "Cannot read" is not "is empty". Saving an empty list over a registry we were merely - // denied would erase every paired device's bearer token, and the write would succeed. - this.registryUnreadable = isUnreadableError(error) - // Why: a corrupt file is not evidence any device was revoked; never tell a keyed client so. - this.registryDoubtful = true - this.corruptFilePending = !this.registryUnreadable + const read = readDeviceRegistryFile(this.registryPath) + this.registryUnreadable = read.unreadable + this.registryDoubtful = read.doubtful + this.corruptFilePending = read.corruptFilePending + this.devices = withoutEphemeralRows(read.devices) + // Why: a session-only browser's key died with the last host process; its row must not outlive it. + if (this.devices.length !== read.devices.length) { + this.saveOrLog({ durable: true }, 'drop session-only devices after restart') } } diff --git a/src/main/runtime/ephemeral-device-rows.test.ts b/src/main/runtime/ephemeral-device-rows.test.ts new file mode 100644 index 00000000000..d213a73aba0 --- /dev/null +++ b/src/main/runtime/ephemeral-device-rows.test.ts @@ -0,0 +1,107 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { planDeviceKeyFork } from './device-key-fork' +import { DeviceRegistry, type DeviceEntry } from './device-registry' +import { EPHEMERAL_DEVICE_IDLE_MS, planIdleEphemeralPrune } from './ephemeral-device-rows' +import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' + +const KEY_A = Buffer.alloc(32, 1).toString('base64') +const dirs: string[] = [] + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tokenRow(overrides: Partial = {}): DeviceEntry { + return { + deviceId: 'legacy', + name: 'Browser', + token: 'token', + scope: 'runtime', + pairedAt: 1, + lastSeenAt: 2, + ...overrides + } +} + +function keyedRow(overrides: Partial = {}): DeviceEntry { + return { + deviceId: 'keyed', + name: 'Browser', + scope: 'runtime', + pairedAt: 1, + lastSeenAt: 1_000, + credentialKind: 'device-key', + devicePublicKeyB64: KEY_A, + forkedFrom: 'legacy', + ephemeral: true, + ...overrides + } +} + +function registryWith(rows: DeviceEntry[]): { dir: string; registry: DeviceRegistry } { + const dir = mkdtempSync(join(tmpdir(), 'orca-ephemeral-rows-')) + dirs.push(dir) + writeFileSync(join(dir, DEVICE_REGISTRY_FILENAME), JSON.stringify(rows)) + return { dir, registry: new DeviceRegistry(dir) } +} + +describe('ephemeral device rows', () => { + it('marks a session-only fork ephemeral and keeps persistent forks unmarked', () => { + const session = planDeviceKeyFork([tokenRow()], 'legacy', KEY_A, 'Tab', 9, { + ephemeral: true + }) + expect(session.entry.ephemeral).toBe(true) + expect(planDeviceKeyFork([tokenRow()], 'legacy', KEY_A, 'Tab', 9).entry.ephemeral).toBe( + undefined + ) + }) + + it('drops ephemeral rows when the host restarts and persists that', () => { + const { dir, registry } = registryWith([tokenRow(), keyedRow()]) + expect(registry.getDevice('keyed')).toBeNull() + expect(registry.lookupDeviceKey('keyed')).toEqual({ kind: 'unknown' }) + expect(registry.getDevice('legacy')).not.toBeNull() + const onDisk = JSON.parse(readFileSync(join(dir, DEVICE_REGISTRY_FILENAME), 'utf8')) + expect(onDisk.map((row: DeviceEntry) => row.deviceId)).toEqual(['legacy']) + }) + + it('ignores an ephemeral flag on a token row', () => { + const { registry } = registryWith([tokenRow({ ephemeral: true })]) + expect(registry.getDevice('legacy')?.ephemeral).toBeUndefined() + }) + + it('prunes only idle, disconnected ephemeral rows', () => { + const rows = [ + tokenRow(), + keyedRow(), + keyedRow({ deviceId: 'live', lastSeenAt: 1_000 }), + keyedRow({ deviceId: 'recent', lastSeenAt: 1_000 + EPHEMERAL_DEVICE_IDLE_MS }), + keyedRow({ deviceId: 'persistent', ephemeral: undefined }) + ] + const now = 2_000 + EPHEMERAL_DEVICE_IDLE_MS + const plan = planIdleEphemeralPrune(rows, now, (deviceId) => deviceId === 'live') + expect(plan.removed).toEqual(['keyed']) + expect(plan.nextDevices.map((row) => row.deviceId)).toEqual([ + 'legacy', + 'live', + 'recent', + 'persistent' + ]) + }) + + it('removes idle rows from the registry durably', () => { + const { registry } = registryWith([tokenRow()]) + const fork = registry.forkDeviceKey('legacy', KEY_A, 'Tab', { ephemeral: true }) + const removed = registry.pruneIdleEphemeralDevices( + fork.lastSeenAt + EPHEMERAL_DEVICE_IDLE_MS + 1, + () => false + ) + expect(removed).toEqual([fork.deviceId]) + expect(registry.lookupDeviceKey(fork.deviceId)).toEqual({ kind: 'unknown' }) + }) +}) diff --git a/src/main/runtime/ephemeral-device-rows.ts b/src/main/runtime/ephemeral-device-rows.ts new file mode 100644 index 00000000000..4249a44493c --- /dev/null +++ b/src/main/runtime/ephemeral-device-rows.ts @@ -0,0 +1,30 @@ +import type { DeviceEntry } from './device-entry' + +// Why: a session-only browser keeps its key in memory; an idle row is an abandoned tab, not a device. +export const EPHEMERAL_DEVICE_IDLE_MS = 12 * 60 * 60 * 1000 +export const EPHEMERAL_DEVICE_SWEEP_INTERVAL_MS = 5 * 60 * 1000 + +/** Rows that survive a host restart: a session-only browser's key did not. */ +export function withoutEphemeralRows(devices: readonly DeviceEntry[]): DeviceEntry[] { + return devices.filter((device) => device.ephemeral !== true) +} + +/** Idle is measured from the last connect or disconnect; a row with a live socket never expires. */ +export function planIdleEphemeralPrune( + devices: readonly DeviceEntry[], + now: number, + isLive: (deviceId: string) => boolean +): { nextDevices: DeviceEntry[]; removed: string[] } { + const removed = devices + .filter( + (device) => + device.ephemeral === true && + now - device.lastSeenAt > EPHEMERAL_DEVICE_IDLE_MS && + !isLive(device.deviceId) + ) + .map((device) => device.deviceId) + return { + nextDevices: devices.filter((device) => !removed.includes(device.deviceId)), + removed + } +} diff --git a/src/main/runtime/orca-runtime-files-mock-registry.ts b/src/main/runtime/orca-runtime-files-mock-registry.ts index d66f7caa800..276c16cf90b 100644 --- a/src/main/runtime/orca-runtime-files-mock-registry.ts +++ b/src/main/runtime/orca-runtime-files-mock-registry.ts @@ -41,7 +41,6 @@ export const wslAwareSpawnMock: Mock< export const watchMock: Mock< (rootPath: string, options: unknown, callback: WatchCallback) => unknown > = vi.fn() -export const getSshFilesystemProviderMock: Mock<(...args: unknown[]) => unknown> = vi.fn() export function enoent(): Error { return Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) @@ -103,22 +102,6 @@ export const localWorktreeRuntimeOptionsMock = { getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock } -const SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE = - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' - -export const sshFilesystemDispatchMock = { - getSshFilesystemProvider: getSshFilesystemProviderMock, - requireSshFilesystemProvider: (connectionId: string) => { - const provider = getSshFilesystemProviderMock(connectionId) - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider - }, - onSshFilesystemProviderRegistered: () => () => undefined, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE -} - export function resetRuntimeFileMocks(): void { lstatMock.mockReset() openMock.mockReset() @@ -130,7 +113,6 @@ export function resetRuntimeFileMocks(): void { closeWatcherInWatcherProcessMock.mockReset() watchMock.mockReset() bundledRipgrepCommandMock.mockReset() - getSshFilesystemProviderMock.mockReset() getLocalGitOptionsForRegisteredWorktreeMock.mockReset() wslAwareSpawnMock.mockReset() getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) diff --git a/src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts b/src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts index f2266ae8526..015dee81d05 100644 --- a/src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts +++ b/src/main/runtime/orca-runtime-files-terminal-artifact-swap-detection.test.ts @@ -30,10 +30,6 @@ vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock -) /** * Replays the first stat seen for a path on every later open of it. That is what Linux hands the diff --git a/src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts b/src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts index a6b729b465e..39e00dc46a3 100644 --- a/src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts +++ b/src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts @@ -19,7 +19,6 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' -import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { getRepoExecutionHostId, getRepoSshConnectionId, @@ -166,7 +165,7 @@ export class OrcaRuntimeWithListKnownResolvedWorktreesForExplicitTarget extends ? projectRuntime.runtime.cacheKey : projectRuntime.repair.cacheKey : sshConnectionId - ? `ssh:${sshConnectionId}:${getSshGitProviderGeneration(sshConnectionId)}` + ? `ssh:${sshConnectionId}` : 'local:default' const now = Date.now() const scanScopeKey = `${repo.id}\0${getRepoExecutionHostId(repo)}` diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 43454af902a..167fdb2c440 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -31,7 +31,6 @@ import { RuntimeAccountController } from './runtime-account-controller' import { RuntimeMobileSpeechCatalog } from './runtime-mobile-speech-catalog' import { RuntimeMobileDictationController } from './runtime-mobile-dictation-controller' import { RuntimeProjectHostSetupController } from './runtime-project-host-setup-controller' -import { addRemoteRepoFromPath } from '../ipc/repos/remote-repo-registration' import { RuntimeProjectGroupController } from './runtime-project-group-controller' import { RuntimeNestedRepoImport } from './runtime-nested-repo-import' import { RuntimeRepositoryRegistrationController } from './runtime-repository-registration-controller' @@ -249,17 +248,6 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin listRepos: () => this.listRepos(), addRepo: (path, kind, hostId) => (this as RuntimeCommandSurfaceHost).addRepo(path, kind, hostId), - addRemoteRepo: async (remote) => { - // The same registration the desktop IPC handler uses, so both surfaces agree on SSH hosts. - const result = await addRemoteRepoFromPath(this.requireStore(), remote) - if ('error' in result) { - throw new Error(result.error) - } - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(result.repo.id) - this.notifyReposChanged() - return result.repo - }, cloneRepo: (url, destination, hostId) => (this as RuntimeCommandSurfaceHost).cloneRepo(url, destination, hostId), invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(), diff --git a/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts b/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts index afe0c752a10..fdc6bb95b89 100644 --- a/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts +++ b/src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts @@ -14,7 +14,6 @@ import { readRepoWorktreeAdminFingerprint } from './repo-worktree-admin-fingerpr import type { RuntimeWorktreeScanResult } from './repo-worktree-resolution-scan' import { scanLocalRepoWorktreesForResolution } from './repo-worktree-resolution-scan' import { dropRepeatedWorktreeRows } from './repeated-worktree-rows' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' import type { GitWorktreeInfo } from '../../shared/worktree/types' import { listStoredWorktreeRowsForRepo } from './repo-worktree-row-resolution' import type { ResolvedWorktree } from './runtime-worktree-path-identity' @@ -161,14 +160,10 @@ export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListK getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime) ) } - const provider = getSshGitProvider(sshConnectionId) - if (!provider) { - return { ok: false, worktrees: this.listStoredWorktreesForResolution(repo) } - } - try { - return { ok: true, worktrees: await provider.listWorktrees(repo.path) } - } catch { - return { ok: false, worktrees: this.listStoredWorktreesForResolution(repo) } + // Why: this process serves no SSH git, so an SSH repo answers from its stored rows. + return { + ok: false, + worktrees: this.listStoredWorktreesForResolution(repo) } } diff --git a/src/main/runtime/orca-runtime-test-lifecycle.spec.ts b/src/main/runtime/orca-runtime-test-lifecycle.spec.ts index 1822cdf7e75..4f955b26e87 100644 --- a/src/main/runtime/orca-runtime-test-lifecycle.spec.ts +++ b/src/main/runtime/orca-runtime-test-lifecycle.spec.ts @@ -25,7 +25,7 @@ const { electronMocks, ensurePathWithinWorkspaceMock } = mocks const { findExistingWorktreeSymlinkPathsMock, forceDeleteLocalBranchMock } = mocks const { forgetLocalWatcherRemovalSnapshotMock, forgetRemoteWatcherRemovalSnapshotMock } = mocks const { describeCreatedWorktree } = mocks -const { getActiveMultiplexerMock, getDefaultTabsLaunch, getEffectiveHooks } = mocks +const { getDefaultTabsLaunch, getEffectiveHooks } = mocks const { getEffectiveHooksFromConfig, getGitHubPRCheckDetailsMock, getGitHubPRChecksMock } = mocks const { getGitHubPRCommentsMock, getGitHubPRFileContentsMock, getGitHubWorkItemByOwnerRepoMock } = mocks @@ -34,7 +34,7 @@ const { getGitLabProjectRefForRemoteMock, getGitLabWorkItemByProjectRefMock } = const { getGitLabWorkItemDetailsMock, getGlabKnownHostsMock } = mocks const { getHostedReviewCreationEligibilityMock, getHostedReviewForBranchMock, getIssueMock } = mocks const { getPRForBranchMock, getPRForBranchOutcomeMock, getPullRequestPushTargetMock } = mocks -const { getRepoSlugMock, getRepoUpstreamMock, getSshGitProviderMock, hasHooksFile } = mocks +const { getRepoSlugMock, getRepoUpstreamMock, hasHooksFile } = mocks const { installFakeAppEnvironment, invalidateAuthorizedRootsCacheMock } = mocks const { listGitHubAssignableUsersMock, listGitHubIssuesMock, listGitHubLabelsMock } = mocks const { listGitHubWorkItemsMock, listGitLabIssuesMock, listGitLabLabelsMock } = mocks @@ -47,9 +47,8 @@ const { } = mocks const { listWorktreesStrict, loadHooks, markCodexProjectTrustedMock } = mocks const { markCopilotFolderTrustedMock, markCursorWorkspaceTrustedMock, mergeGitHubPRMock } = mocks -const { mergeGitLabMRMock, muxRequestMock, parseOrcaYaml, prepareLocalWorktreeRootForRepoMock } = - mocks -const { registerSshGitProviderMock, removeGitHubPRReviewersMock, removeWorktree } = mocks +const { mergeGitLabMRMock, parseOrcaYaml, prepareLocalWorktreeRootForRepoMock } = mocks +const { removeGitHubPRReviewersMock, removeWorktree } = mocks const { removeWorktreeLinkedPathsMock, reopenGitLabMRMock, requestGitHubPRReviewersMock } = mocks const { rerunGitHubPRChecksMock, resetPlatform, resolveGitHubReviewThreadMock } = mocks const { resolveGitLabMRDiscussionMock, resolveLocalGitUsernameMock, resolveSetupRunnerShell } = @@ -60,8 +59,8 @@ const { retryGitLabJobMock, runHook, scanLocalRepoWorktreesForResolutionMock } = const { setGitHubPRAutoMergeMock, setGitHubPRFileViewedMock, setRuntimeBrowserCommandsFactory } = mocks const { setRuntimeBrowserUnavailableCause, setRuntimeDesktopSurface } = mocks -const { setRuntimeTerminalUnavailableCause, shouldRunSetupForCreate, sshGitProviders } = mocks -const { sshProviderGenerations, unregisterSshGitProviderMock, updateGitHubIssueMock } = mocks +const { setRuntimeTerminalUnavailableCause, shouldRunSetupForCreate } = mocks +const { updateGitHubIssueMock } = mocks const { updateGitHubPRDetailsMock, updateGitHubPRStateMock, updateGitHubPRTitleMock } = mocks const { updateGitLabIssueMock, updateGitLabMRMock, updateGitLabMRReviewersMock, vi } = mocks @@ -147,28 +146,7 @@ function resetRuntimeTestMocks(): void { resolveLocalGitUsernameMock.mockReset().mockResolvedValue('') vi.mocked(forceDeleteLocalBranchMock).mockReset() vi.mocked(forceDeleteLocalBranchMock).mockResolvedValue(undefined) - sshGitProviders.clear() - sshProviderGenerations.clear() - getSshGitProviderMock.mockReset() - getSshGitProviderMock.mockImplementation((connectionId: string) => - sshGitProviders.get(connectionId) - ) - registerSshGitProviderMock.mockReset() - registerSshGitProviderMock.mockImplementation((connectionId: string, provider: unknown) => { - sshGitProviders.set(connectionId, provider) - sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1) - }) - unregisterSshGitProviderMock.mockReset() - unregisterSshGitProviderMock.mockImplementation((connectionId: string) => { - if (sshGitProviders.delete(connectionId)) { - sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1) - } - }) - muxRequestMock.mockReset() - muxRequestMock.mockResolvedValue(undefined) applyAgentStatusHooksEnabledMock.mockReset().mockResolvedValue([]) - getActiveMultiplexerMock.mockReset() - getActiveMultiplexerMock.mockReturnValue({ request: muxRequestMock, notify: vi.fn() }) vi.mocked(createSetupRunnerScript).mockReset() vi.mocked(getEffectiveHooks).mockReset() vi.mocked(getEffectiveHooksFromConfig).mockReset() diff --git a/src/main/runtime/orca-runtime-test-mocks.spec.ts b/src/main/runtime/orca-runtime-test-mocks.spec.ts index a13942dd873..5238736627c 100644 --- a/src/main/runtime/orca-runtime-test-mocks.spec.ts +++ b/src/main/runtime/orca-runtime-test-mocks.spec.ts @@ -118,8 +118,6 @@ export const recentTerminalOutputIncludesPath = export const recentTerminalPathCandidatesIncludePath = importedValues.exportedRecentTerminalPathCandidatesIncludePath export const registerLocalPtyMemoryRow = importedValues.exportedRegisterLocalPtyMemoryRow -export const registerSshFilesystemProvider = importedValues.exportedRegisterSshFilesystemProvider -export const registerSshGitProvider = importedValues.exportedRegisterSshGitProvider export const removeWorktree = importedValues.exportedRemoveWorktree export const resolveSetupRunnerShell = importedValues.exportedResolveSetupRunnerShell export const resolveWorktreeScanCacheTtlMs = importedValues.exportedResolveWorktreeScanCacheTtlMs @@ -138,9 +136,6 @@ export const setWorktreeWatcherRemoval = importedValues.exportedSetWorktreeWatch export const shouldRunSetupForCreate = importedValues.exportedShouldRunSetupForCreate export const tmpdir = importedValues.exportedTmpdir export const unregisterLocalPtyMemoryRow = importedValues.exportedUnregisterLocalPtyMemoryRow -export const unregisterSshFilesystemProvider = - importedValues.exportedUnregisterSshFilesystemProvider -export const unregisterSshGitProvider = importedValues.exportedUnregisterSshGitProvider export const vi = importedValues.exportedVi export const win32 = importedValues.exportedWin32 export const worktreePathComparison = importedValues.exportedWorktreePathComparison @@ -179,15 +174,7 @@ export { computeWorktreePathMock, ensurePathWithinWorkspaceMock } from './orca-runtime-test-mocks/setup.spec' -export const sshGitProviders = setup.sshGitProviders -export const sshProviderGenerations = setup.sshProviderGenerations export { - getSshGitProviderMock, - getSshGitProviderGenerationMock, - registerSshGitProviderMock, - unregisterSshGitProviderMock, - getActiveMultiplexerMock, - muxRequestMock, invalidateAuthorizedRootsCacheMock, prepareLocalWorktreeRootForRepoMock, createHostedReviewMock, diff --git a/src/main/runtime/orca-runtime-test-mocks/imported-values.spec.ts b/src/main/runtime/orca-runtime-test-mocks/imported-values.spec.ts index 058fbbe4a6d..3acab50ffd4 100644 --- a/src/main/runtime/orca-runtime-test-mocks/imported-values.spec.ts +++ b/src/main/runtime/orca-runtime-test-mocks/imported-values.spec.ts @@ -93,11 +93,6 @@ import { } from '../../../shared/agent-prompt-injection' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../shared/clipboard-text' import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../../providers/ssh-filesystem-dispatch' -import { registerSshGitProvider, unregisterSshGitProvider } from '../../providers/ssh-git-dispatch' import { registerPty as registerLocalPtyMemoryRow, unregisterPty as unregisterLocalPtyMemoryRow @@ -232,8 +227,6 @@ export const exportedRecentTerminalOutputIncludesPath = recentTerminalOutputIncl export const exportedRecentTerminalPathCandidatesIncludePath = recentTerminalPathCandidatesIncludePath export const exportedRegisterLocalPtyMemoryRow = registerLocalPtyMemoryRow -export const exportedRegisterSshFilesystemProvider = registerSshFilesystemProvider -export const exportedRegisterSshGitProvider = registerSshGitProvider export const exportedRemoveWorktree = removeWorktree export const exportedResolveSetupRunnerShell = resolveSetupRunnerShell export const exportedResolveWorktreeScanCacheTtlMs = resolveWorktreeScanCacheTtlMs @@ -249,8 +242,6 @@ export const exportedSetWorktreeWatcherRemoval = setWorktreeWatcherRemoval export const exportedShouldRunSetupForCreate = shouldRunSetupForCreate export const exportedTmpdir = tmpdir export const exportedUnregisterLocalPtyMemoryRow = unregisterLocalPtyMemoryRow -export const exportedUnregisterSshFilesystemProvider = unregisterSshFilesystemProvider -export const exportedUnregisterSshGitProvider = unregisterSshGitProvider export const exportedVi = vi export const exportedWin32 = win32 export const exportedWorktreePathComparison = worktreePathComparison 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 547006c56c0..08d28c87152 100644 --- a/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts +++ b/src/main/runtime/orca-runtime-test-mocks/setup.spec.ts @@ -115,14 +115,6 @@ const { forceDeleteLocalBranchMock, computeWorktreePathMock, ensurePathWithinWorkspaceMock, - sshGitProviders, - sshProviderGenerations, - getSshGitProviderMock, - getSshGitProviderGenerationMock, - registerSshGitProviderMock, - unregisterSshGitProviderMock, - getActiveMultiplexerMock, - muxRequestMock, invalidateAuthorizedRootsCacheMock, prepareLocalWorktreeRootForRepoMock, createHostedReviewMock, @@ -191,10 +183,6 @@ const { getIssueMock, deleteWorktreeHistoryDirMock } = vi.hoisted(() => { - // Why: SSH runtime tests register providers via the public dispatcher API, so the mock needs the same registry semantics as the real module. - const sshGitProviders = new Map() - const sshProviderGenerations = new Map() - return { MOCK_GIT_WORKTREES: [ { @@ -211,26 +199,6 @@ const { forceDeleteLocalBranchMock: vi.fn() as TestMock, computeWorktreePathMock: vi.fn() as TestMock, ensurePathWithinWorkspaceMock: vi.fn() as TestMock, - sshGitProviders, - sshProviderGenerations, - getSshGitProviderMock: vi.fn((connectionId: string) => sshGitProviders.get(connectionId)), - getSshGitProviderGenerationMock: vi.fn( - (connectionId: string) => sshProviderGenerations.get(connectionId) ?? 0 - ), - registerSshGitProviderMock: vi.fn((connectionId: string, provider: unknown) => { - sshGitProviders.set(connectionId, provider) - sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1) - }), - unregisterSshGitProviderMock: vi.fn((connectionId: string) => { - if (sshGitProviders.delete(connectionId)) { - sshProviderGenerations.set( - connectionId, - (sshProviderGenerations.get(connectionId) ?? 0) + 1 - ) - } - }), - getActiveMultiplexerMock: vi.fn() as TestMock, - muxRequestMock: vi.fn() as TestMock, invalidateAuthorizedRootsCacheMock: vi.fn() as TestMock, prepareLocalWorktreeRootForRepoMock: vi.fn() as TestMock, createHostedReviewMock: vi.fn() as TestMock, @@ -323,26 +291,7 @@ vi.mock('../../terminal-history-deletion', () => ({ deleteWorktreeHistoryDir: deleteWorktreeHistoryDirMock })) -vi.mock('../../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: getSshGitProviderGenerationMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.', - requireSshGitProvider: (connectionId: string) => { - const provider = getSshGitProviderMock(connectionId) - if (!provider) { - throw new Error( - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' - ) - } - return provider - }, - registerSshGitProvider: registerSshGitProviderMock, - unregisterSshGitProvider: unregisterSshGitProviderMock -})) - vi.mock('../../ssh/ssh-target-registry', () => ({ - getActiveMultiplexer: getActiveMultiplexerMock, getRegisteredSshState: () => ({ remotePlatform: 'linux' }) })) @@ -593,14 +542,6 @@ export { forceDeleteLocalBranchMock, computeWorktreePathMock, ensurePathWithinWorkspaceMock, - sshGitProviders, - sshProviderGenerations, - getSshGitProviderMock, - getSshGitProviderGenerationMock, - registerSshGitProviderMock, - unregisterSshGitProviderMock, - getActiveMultiplexerMock, - muxRequestMock, invalidateAuthorizedRootsCacheMock, prepareLocalWorktreeRootForRepoMock, createHostedReviewMock, diff --git a/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review-part-02.spec.ts index bd86392b0e9..73682b3e45d 100644 --- a/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/hooks-and-hosted-review-part-02.spec.ts @@ -19,7 +19,6 @@ import { getPRForBranchOutcomeMock, listWorktrees, mergeGitHubPRMock, - registerSshGitProvider, removeGitHubPRReviewersMock, requestGitHubPRReviewersMock, rerunGitHubPRChecksMock, @@ -27,12 +26,16 @@ import { setGitHubPRAutoMergeMock, setGitHubPRFileViewedMock, setPlatform, - unregisterSshGitProvider, updateGitHubPRDetailsMock, updateGitHubPRStateMock, updateGitHubPRTitleMock } from '../orca-runtime-test-mocks.spec' -import { TEST_REPO_ID, TEST_REPO_PATH, store } from '../orca-runtime-test-fixtures.spec' +import { + TEST_REPO_ID, + TEST_REPO_PATH, + makeWorktreeMeta, + store +} from '../orca-runtime-test-fixtures.spec' describe('OrcaRuntimeService', () => { it('routes runtime GitHub PR details and actions through the selected WSL project runtime', async () => { @@ -583,29 +586,16 @@ describe('OrcaRuntimeService', () => { connectionId: 'ssh-1' } ], + // Why: this process serves no SSH git, so the SSH repo resolves from its stored rows. + getAllWorktreeMeta: () => ({ + [`${TEST_REPO_ID}::/remote/repo`]: makeWorktreeMeta() + }), getWorktreeMeta: () => null } - const gitProvider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/repo', - head: 'abc', - branch: 'feature/foo', - isBare: false, - isMainWorktree: true - } - ]) - } - registerSshGitProvider('ssh-1', gitProvider as never) const runtime = new OrcaRuntimeService(remoteStore as never) - try { - await expect(runtime.probeWorktreeDrift('path:/remote/repo')).resolves.toBeNull() - } finally { - unregisterSshGitProvider('ssh-1') - } + await expect(runtime.probeWorktreeDrift('path:/remote/repo')).resolves.toBeNull() - expect(gitProvider.listWorktrees).toHaveBeenCalledWith('/remote/repo') expect(getBaseRefDefault).not.toHaveBeenCalled() expect(listWorktrees).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-03.spec.ts index 3fe67dab719..64706d755e8 100644 --- a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-03.spec.ts @@ -1,12 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import * as repoWorktreeAdminFingerprint from '../repo-worktree-admin-fingerprint' -import { - MOCK_GIT_WORKTREES, - OrcaRuntimeService, - listWorktrees, - registerSshGitProvider, - unregisterSshGitProvider -} from '../orca-runtime-test-mocks.spec' +import { OrcaRuntimeService, listWorktrees } from '../orca-runtime-test-mocks.spec' import type { Worktree, WorktreeLineage, WorktreeMeta } from '../orca-runtime-test-mocks.spec' import { TEST_REPO_ID, @@ -102,43 +96,6 @@ describe('OrcaRuntimeService', () => { expect(listWorktrees).toHaveBeenCalledTimes(1) }) - it('worktree scan cache: keeps colliding local and SSH owners warm independently', async () => { - vi.mocked(listWorktrees).mockClear() - const localRepo = store.getRepo(TEST_REPO_ID)! - const remoteRepo = { ...localRepo, path: '/remote/repo', connectionId: 'ssh-1' } - const provider = { - listWorktrees: vi.fn().mockResolvedValue([makeWorktreeInfo('/remote/worktree')]) - } - registerSshGitProvider('ssh-1', provider as never) - const runtime = new OrcaRuntimeService({ - ...store, - getRepos: () => [localRepo, remoteRepo] - } as never) - vi.mocked(listWorktrees).mockResolvedValue([makeWorktreeInfo(TEST_WORKTREE_PATH)]) - - try { - await runtime.listManagedWorktrees() - runtime.notifyWorktreesChangedForRemoteClients(TEST_REPO_ID) - await runtime.listManagedWorktrees() - - expect(listWorktrees).toHaveBeenCalledTimes(1) - expect(provider.listWorktrees).toHaveBeenCalledTimes(1) - - unregisterSshGitProvider('ssh-1') - const replacementProvider = { - listWorktrees: vi.fn().mockResolvedValue([makeWorktreeInfo('/remote/replacement')]) - } - registerSshGitProvider('ssh-1', replacementProvider as never) - runtime.notifyWorktreesChangedForRemoteClients(TEST_REPO_ID) - await runtime.listManagedWorktrees() - - expect(listWorktrees).toHaveBeenCalledTimes(1) - expect(replacementProvider.listWorktrees).toHaveBeenCalledTimes(1) - } finally { - unregisterSshGitProvider('ssh-1') - } - }) - it('keeps detected metadata scoped to the selected colliding host', async () => { const localRepo = store.getRepo(TEST_REPO_ID)! const remoteRepo = { ...localRepo, path: '/remote/repo', connectionId: 'ssh-1' } @@ -157,25 +114,12 @@ describe('OrcaRuntimeService', () => { getAllWorktreeMeta: () => ({ [worktreeId]: localMeta }), getWorktreeMeta: (id: string) => (id === worktreeId ? localMeta : undefined) } - const provider = { listWorktrees: vi.fn().mockResolvedValue([makeWorktreeInfo(worktreePath)]) } - registerSshGitProvider('ssh-1', provider as never) const runtime = new OrcaRuntimeService(runtimeStore as never) - try { - const result = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`, 'ssh-1') + // Why: the SSH host lists only its own stored rows, so the local row never surfaces there. + const result = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`, 'ssh-1') - expect(result.worktrees).toEqual([ - expect.objectContaining({ - id: worktreeId, - hostId: 'ssh:ssh-1', - comment: '', - isPinned: false - }) - ]) - expect(result.worktrees[0]?.displayName).not.toBe('local-only-name') - } finally { - unregisterSshGitProvider('ssh-1') - } + expect(result.worktrees).toEqual([]) }) it('does not project one colliding host lineage onto another host rows', async () => { @@ -209,26 +153,20 @@ describe('OrcaRuntimeService', () => { } const rows = [makeWorktreeInfo(childPath), makeWorktreeInfo(parentPath)] vi.mocked(listWorktrees).mockResolvedValue(rows) - const provider = { listWorktrees: vi.fn().mockResolvedValue(rows) } - registerSshGitProvider('ssh-1', provider as never) const runtime = new OrcaRuntimeService(runtimeStore as never) - try { - const resolved = await ( - runtime as unknown as { listResolvedWorktrees: () => Promise } - ).listResolvedWorktrees() - const localChild = resolved.find( - (worktree) => worktree.id === childId && worktree.hostId === 'local' - ) - const remoteChild = resolved.find( - (worktree) => worktree.id === childId && worktree.hostId === 'ssh:ssh-1' - ) + const resolved = await ( + runtime as unknown as { listResolvedWorktrees: () => Promise } + ).listResolvedWorktrees() + const localChild = resolved.find( + (worktree) => worktree.id === childId && worktree.hostId === 'local' + ) + const remoteChild = resolved.find( + (worktree) => worktree.id === childId && worktree.hostId === 'ssh:ssh-1' + ) - expect(localChild).toMatchObject({ parentWorktreeId: null, lineage: null }) - expect(remoteChild).toMatchObject({ parentWorktreeId: parentId }) - } finally { - unregisterSshGitProvider('ssh-1') - } + expect(localChild).toMatchObject({ parentWorktreeId: null, lineage: null }) + expect(remoteChild).toMatchObject({ parentWorktreeId: parentId }) }) it('worktree scan cache: rescans immediately after worktree invalidation', async () => { @@ -370,58 +308,6 @@ describe('OrcaRuntimeService', () => { } }) - it('worktree scan cache: does not cache non-authoritative SSH scan failures', async () => { - const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } - const remoteStore = { - ...store, - getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getRepos: () => [remoteRepo] - } - const provider = { listWorktrees: vi.fn() } - provider.listWorktrees - .mockRejectedValueOnce(new Error('git unavailable')) - .mockResolvedValueOnce([makeWorktreeInfo(TEST_WORKTREE_PATH)]) - registerSshGitProvider('ssh-1', provider as never) - const runtime = new OrcaRuntimeService(remoteStore as never) - - try { - await expect( - runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) - ).resolves.toMatchObject({ authoritative: false }) - await expect( - runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) - ).resolves.toMatchObject({ authoritative: true }) - expect(provider.listWorktrees).toHaveBeenCalledTimes(2) - } finally { - unregisterSshGitProvider('ssh-1') - } - }) - - it('worktree scan cache: invalidates when SSH availability changes', async () => { - const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } - const remoteStore = { - ...store, - getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getRepos: () => [remoteRepo] - } - const provider = { listWorktrees: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES) } - registerSshGitProvider('ssh-1', provider as never) - const runtime = new OrcaRuntimeService(remoteStore as never) - - try { - await expect( - runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) - ).resolves.toMatchObject({ authoritative: true }) - unregisterSshGitProvider('ssh-1') - await expect( - runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) - ).resolves.toMatchObject({ authoritative: false }) - expect(provider.listWorktrees).toHaveBeenCalledTimes(1) - } finally { - unregisterSshGitProvider('ssh-1') - } - }) - it('worktree scan cache: SSH state changes do not evict local repo scans', async () => { vi.mocked(listWorktrees).mockClear() const localRepo = { ...store.getRepo(TEST_REPO_ID)!, id: 'local-repo' } @@ -432,53 +318,20 @@ describe('OrcaRuntimeService', () => { getRepo: (id: string) => repos.find((repo) => repo.id === id), getRepos: () => repos } - const provider = { listWorktrees: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES) } - registerSshGitProvider('ssh-1', provider as never) const runtime = new OrcaRuntimeService(remoteStore as never) - try { - await runtime.listDetectedManagedWorktrees('id:local-repo') - await runtime.listDetectedManagedWorktrees('id:remote-repo') - runtime.notifySshStateChanged('ssh-1', { - targetId: 'ssh-1', - status: 'disconnected', - error: null, - reconnectAttempt: 0 - }) - await runtime.listDetectedManagedWorktrees('id:local-repo') - await runtime.listDetectedManagedWorktrees('id:remote-repo') + await runtime.listDetectedManagedWorktrees('id:local-repo') + await runtime.listDetectedManagedWorktrees('id:remote-repo') + runtime.notifySshStateChanged('ssh-1', { + targetId: 'ssh-1', + status: 'disconnected', + error: null, + reconnectAttempt: 0 + }) + await runtime.listDetectedManagedWorktrees('id:local-repo') + await runtime.listDetectedManagedWorktrees('id:remote-repo') - expect(listWorktrees).toHaveBeenCalledTimes(1) - expect(provider.listWorktrees).toHaveBeenCalledTimes(2) - } finally { - unregisterSshGitProvider('ssh-1') - } - }) - - it('worktree scan cache: SSH state changes invalidate empty resolved snapshots', async () => { - const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-empty' } - const remoteStore = { - ...store, - getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getRepos: () => [remoteRepo] - } - const provider = { listWorktrees: vi.fn().mockResolvedValue([]) } - registerSshGitProvider('ssh-empty', provider as never) - const runtime = new OrcaRuntimeService(remoteStore as never) - - try { - await expect(runtime.listManagedWorktrees()).resolves.toMatchObject({ totalCount: 0 }) - runtime.notifySshStateChanged('ssh-empty', { - targetId: 'ssh-empty', - status: 'connected', - error: null, - reconnectAttempt: 0 - }) - await expect(runtime.listManagedWorktrees()).resolves.toMatchObject({ totalCount: 0 }) - expect(provider.listWorktrees).toHaveBeenCalledTimes(2) - } finally { - unregisterSshGitProvider('ssh-empty') - } + expect(listWorktrees).toHaveBeenCalledTimes(1) }) it('worktree scan cache: SSH state changes for unknown targets keep local snapshots in flight', async () => { diff --git a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache.spec.ts b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache.spec.ts index 0eaf37a80d9..ff5c5518c44 100644 --- a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache.spec.ts +++ b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache.spec.ts @@ -1,16 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, - getSshGitProviderMock, join, listWorktrees, lstat, mkdir, mkdtemp, - registerSshFilesystemProvider, rm, - tmpdir, - unregisterSshFilesystemProvider + tmpdir } from '../orca-runtime-test-mocks.spec' import type { WorkspaceLineage, @@ -63,71 +60,6 @@ describe('OrcaRuntimeService', () => { ).resolves.toEqual({ refs: [], refDetails: [], truncated: true }) }) - it('resolves SSH worktrees when manually updating lineage', async () => { - const remoteRepo = { - id: 'remote-repo', - path: '/home/user/repo', - displayName: 'remote', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'ssh-1' - } - const childId = `${remoteRepo.id}::/home/user/repo-child` - const parentId = `${remoteRepo.id}::/home/user/repo-parent` - const metaById: Record = { - [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }), - [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) - } - const setWorktreeLineage = vi.fn((_worktreeId, lineage) => lineage) - const listSshWorktrees = vi.fn().mockResolvedValue([ - { - path: '/home/user/repo-child', - head: 'abc', - branch: 'feature/child', - isBare: false, - isMainWorktree: false - }, - { - path: '/home/user/repo-parent', - head: 'def', - branch: 'feature/parent', - isBare: false, - isMainWorktree: false - } - ]) - getSshGitProviderMock.mockReturnValue({ listWorktrees: listSshWorktrees }) - const runtimeStore = { - ...store, - getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getRepos: () => [remoteRepo], - getAllWorktreeMeta: () => metaById, - getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { - metaById[worktreeId] = { ...metaById[worktreeId], ...meta } - return metaById[worktreeId] - }, - getWorktreeLineage: () => undefined, - setWorktreeLineage - } - const runtime = new OrcaRuntimeService(runtimeStore as never) - - await runtime.updateManagedWorktreeMeta(`id:${childId}`, { - lineage: { parentWorktree: `id:${parentId}` } - }) - - expect(listSshWorktrees).toHaveBeenCalledWith(remoteRepo.path) - expect(setWorktreeLineage).toHaveBeenCalledWith( - childId, - expect.objectContaining({ - worktreeId: childId, - worktreeInstanceId: 'child-instance', - parentWorktreeId: parentId, - parentWorktreeInstanceId: 'parent-instance', - origin: 'manual' - }) - ) - }) - it('resolves SSH lineage updates from stored metadata when the scan cache misses', async () => { const remoteRepo = { id: 'remote-repo', @@ -144,16 +76,6 @@ describe('OrcaRuntimeService', () => { [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) } const setWorktreeLineage = vi.fn((_worktreeId: string, lineage: WorktreeLineage) => lineage) - const listSshWorktrees = vi.fn().mockResolvedValue([ - { - path: '/home/user/repo', - head: 'abc', - branch: 'main', - isBare: false, - isMainWorktree: true - } - ]) - getSshGitProviderMock.mockReturnValue({ listWorktrees: listSshWorktrees }) const runtimeStore = { ...store, getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), @@ -199,16 +121,6 @@ describe('OrcaRuntimeService', () => { const metaById: Record = { [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }) } - const listSshWorktrees = vi.fn().mockResolvedValue([ - { - path: '/home/user/repo', - head: 'abc', - branch: 'main', - isBare: false, - isMainWorktree: true - } - ]) - getSshGitProviderMock.mockReturnValue({ listWorktrees: listSshWorktrees }) const runtimeStore = { ...store, getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), @@ -231,73 +143,6 @@ describe('OrcaRuntimeService', () => { ).rejects.toThrow('selector_not_found') }) - it('rejects SSH lineage updates when Orca worktree identity is missing', async () => { - const remoteRepo = { - id: 'remote-repo', - path: '/home/user/repo', - displayName: 'remote', - badgeColor: 'blue', - addedAt: 1, - connectionId: 'ssh-1' - } - const childId = `${remoteRepo.id}::/home/user/repo-child` - const parentId = `${remoteRepo.id}::/home/user/repo-parent` - const metaById: Record = {} - const setWorktreeLineage = vi.fn((_worktreeId: string, lineage: WorktreeLineage) => lineage) - const fsProvider = { - readFile: vi.fn(), - createDir: vi.fn().mockResolvedValue(undefined), - writeFile: vi.fn().mockResolvedValue(undefined) - } - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/home/user/repo-child', - head: 'abc', - branch: 'feature/child', - isBare: false, - isMainWorktree: false - }, - { - path: '/home/user/repo-parent', - head: 'def', - branch: 'feature/parent', - isBare: false, - isMainWorktree: false - } - ]) - }) - registerSshFilesystemProvider('ssh-1', fsProvider as never) - const runtimeStore = { - ...store, - getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getRepos: () => [remoteRepo], - getAllWorktreeMeta: () => metaById, - getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], - setWorktreeMeta: (worktreeId: string, meta: Partial) => { - metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } - return metaById[worktreeId] - }, - getWorktreeLineage: () => undefined, - setWorktreeLineage - } - const runtime = new OrcaRuntimeService(runtimeStore as never) - - try { - await expect( - runtime.updateManagedWorktreeMeta(`id:${childId}`, { - lineage: { parentWorktree: `id:${parentId}` } - }) - ).rejects.toThrow('Worktree instance identity was unavailable') - } finally { - unregisterSshFilesystemProvider('ssh-1') - } - - expect(fsProvider.createDir).not.toHaveBeenCalled() - expect(fsProvider.writeFile).not.toHaveBeenCalled() - expect(setWorktreeLineage).not.toHaveBeenCalled() - }) - it('rejects local lineage updates when Orca worktree identity is missing', async () => { const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-lineage-')) const repoPath = join(tempRoot, 'repo') diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts index d28ca1027a8..31492db4702 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-03.spec.ts @@ -3,7 +3,6 @@ import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fi import { OrcaRuntimeService, getDefaultWorkspaceSession, - registerSshGitProvider, setPlatform, win32, worktreePathComparison @@ -17,6 +16,16 @@ import { store } from '../orca-runtime-test-fixtures.spec' +// Why: this process serves no SSH git, so an SSH repo lists its stored rows. +function storedWorktreeMeta( + repoId: string, + worktrees: readonly { path: string }[] +): Record { + return Object.fromEntries( + worktrees.map((worktree) => [`${repoId}::${worktree.path}`, makeWorktreeMeta()]) + ) +} + describe('OrcaRuntimeService', () => { it('keeps an OSC row from an SSH pane after its PTY disconnects', async () => { // Why: OSC snapshots must carry the pane transport; hardcoding local would @@ -180,9 +189,6 @@ describe('OrcaRuntimeService', () => { getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], getWorkspaceSession: () => getDefaultWorkspaceSession() } - registerSshGitProvider('ssh-6072', { - listWorktrees: vi.fn().mockResolvedValue([remoteWorktree]) - } as never) const now = Date.now() const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { getAgentStatusSnapshot: () => [ @@ -253,9 +259,6 @@ describe('OrcaRuntimeService', () => { return metaById[worktreeId] } } - registerSshGitProvider('ssh-1', { - listWorktrees: vi.fn().mockResolvedValue([remoteWorktree]) - } as never) const now = Date.now() const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { @@ -335,20 +338,17 @@ describe('OrcaRuntimeService', () => { path: slashPath, branch: 'refs/heads/slash' } + const storedMeta = storedWorktreeMeta( + remoteRepo.id, + includeSlashWorktree ? [backslashWorktree, slashWorktree] : [backslashWorktree] + ) const runtimeStore = { ...store, getRepos: () => [remoteRepo], getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getAllWorktreeMeta: () => ({}), + getAllWorktreeMeta: () => storedMeta, getWorktreeMeta: () => undefined } - registerSshGitProvider('ssh-relative', { - listWorktrees: vi - .fn() - .mockResolvedValue( - includeSlashWorktree ? [backslashWorktree, slashWorktree] : [backslashWorktree] - ) - } as never) const now = Date.now() const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { @@ -413,16 +413,14 @@ describe('OrcaRuntimeService', () => { isMainWorktree: false } }) + const storedMeta = storedWorktreeMeta(remoteRepo.id, remoteWorktrees) const runtimeStore = { ...store, getRepos: () => [remoteRepo], getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getAllWorktreeMeta: () => ({}), + getAllWorktreeMeta: () => storedMeta, getWorktreeMeta: () => undefined } - registerSshGitProvider('ssh-pair-aware-scale', { - listWorktrees: vi.fn().mockResolvedValue(remoteWorktrees) - } as never) const now = Date.now() const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { getAgentStatusSnapshot: () => @@ -471,16 +469,14 @@ describe('OrcaRuntimeService', () => { } }) const getRepo = vi.fn((id: string) => (id === remoteRepo.id ? remoteRepo : undefined)) + const storedMeta = storedWorktreeMeta(remoteRepo.id, remoteWorktrees) const runtimeStore = { ...store, getRepos: () => [remoteRepo], getRepo, - getAllWorktreeMeta: () => ({}), + getAllWorktreeMeta: () => storedMeta, getWorktreeMeta: () => undefined } - registerSshGitProvider('ssh-malformed-scale', { - listWorktrees: vi.fn().mockResolvedValue(remoteWorktrees) - } as never) const now = Date.now() const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { getAgentStatusSnapshot: () => @@ -522,16 +518,14 @@ describe('OrcaRuntimeService', () => { isBare: false, isMainWorktree: false } + const storedMeta = storedWorktreeMeta(remoteRepo.id, [remoteWorktree]) const runtimeStore = { ...store, getRepos: () => [remoteRepo], getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getAllWorktreeMeta: () => ({}), + getAllWorktreeMeta: () => storedMeta, getWorktreeMeta: () => undefined } - registerSshGitProvider('ssh-repeated-miss', { - listWorktrees: vi.fn().mockResolvedValue([remoteWorktree]) - } as never) const now = Date.now() const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { getAgentStatusSnapshot: () => @@ -586,16 +580,14 @@ describe('OrcaRuntimeService', () => { isMainWorktree: false } ] + const storedMeta = storedWorktreeMeta(remoteRepo.id, remoteWorktrees) const runtimeStore = { ...store, getRepos: () => [remoteRepo], getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined), - getAllWorktreeMeta: () => ({}), + getAllWorktreeMeta: () => storedMeta, getWorktreeMeta: () => undefined } - registerSshGitProvider('ssh-truncated', { - listWorktrees: vi.fn().mockResolvedValue(remoteWorktrees) - } as never) const now = Date.now() const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { getAgentStatusSnapshot: () => [ diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-04.spec.ts index 67c0a5caeda..93c83631b73 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries-part-04.spec.ts @@ -1,7 +1,6 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { OrcaRuntimeService, - registerSshGitProvider, setPlatform, worktreePathComparison } from '../orca-runtime-test-mocks.spec' @@ -27,23 +26,17 @@ describe('OrcaRuntimeService', () => { isMainWorktree: false })) const pinnedPath = 'relative/zzz-pinned' - const pinnedWorktree = { - path: pinnedPath, - head: 'pinned', - branch: 'refs/heads/pinned', - isBare: false, - isMainWorktree: false - } const unreadPath = 'relative/zzz-unread' - const unreadWorktree = { - ...pinnedWorktree, - path: unreadPath, - head: 'unread', - branch: 'refs/heads/unread' - } const pinnedId = `${remoteRepo.id}::${pinnedPath}` const unreadId = `${remoteRepo.id}::${unreadPath}` + // Why: this process serves no SSH git, so the SSH repo lists its stored rows. const metaById: Record = { + ...Object.fromEntries( + activeWorktrees.map((worktree) => [ + `${remoteRepo.id}::${worktree.path}`, + makeWorktreeMeta() + ]) + ), [pinnedId]: makeWorktreeMeta({ isPinned: true }), [unreadId]: makeWorktreeMeta({ isUnread: true }) } @@ -54,9 +47,6 @@ describe('OrcaRuntimeService', () => { getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId] } - registerSshGitProvider('ssh-pinned-limit', { - listWorktrees: vi.fn().mockResolvedValue([...activeWorktrees, pinnedWorktree, unreadWorktree]) - } as never) const now = Date.now() const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { getAgentStatusSnapshot: () => diff --git a/src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts b/src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts index dd80f786653..167db4e6f7a 100644 --- a/src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts +++ b/src/main/runtime/orca-runtime-tests/runtime-availability.spec.ts @@ -2,9 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID, OrcaRuntimeService, - electronMocks, - registerSshGitProvider, - unregisterSshGitProvider + electronMocks } from '../orca-runtime-test-mocks.spec' import { HEADLESS_LEAF_ID, @@ -178,7 +176,6 @@ describe('OrcaRuntimeService', () => { getForegroundProcess: async () => null }) const listWorktrees = vi.fn(() => new Promise(() => {})) - registerSshGitProvider('ssh-target', { listWorktrees } as never) vi.useFakeTimers() try { @@ -207,7 +204,6 @@ describe('OrcaRuntimeService', () => { }) } finally { vi.useRealTimers() - unregisterSshGitProvider('ssh-target') } }) 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 d2b1c957cf9..794aafdd981 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 @@ -10,9 +10,7 @@ import { join, mkdtemp, randomUUID, - registerSshGitProvider, - tmpdir, - unregisterSshGitProvider + tmpdir } from '../orca-runtime-test-mocks.spec' import { TEST_FOLDER_PROJECT_GROUP_ID, @@ -25,6 +23,7 @@ import { expectStablePaneKeyEnv, makeFolderProjectGroup, makeFolderWorkspace, + makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec' @@ -65,6 +64,10 @@ describe('OrcaRuntimeService', () => { ...store, getRepos: () => [remoteRepo], getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), + // Why: this process serves no SSH git, so the SSH repo resolves from its stored rows. + getAllWorktreeMeta: () => ({ + [`${TEST_REPO_ID}::C:/remote/repo`]: makeWorktreeMeta() + }), getSettings: () => ({ ...store.getSettings(), disabledTuiAgents: [], @@ -74,20 +77,7 @@ describe('OrcaRuntimeService', () => { agentDefaultEnv: {} }) } - const provider = { - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), - listWorktrees: vi.fn().mockResolvedValue([ - { - path: 'C:/remote/repo', - head: 'abc', - branch: 'main', - isBare: false, - isMainWorktree: true - } - ]) - } const spawn = vi.fn().mockResolvedValue({ id: 'pty-remote-windows-bare' }) - registerSshGitProvider('ssh-1', provider as never) const runtime = new OrcaRuntimeService(remoteStore as never) runtime.setPtyController({ spawn, @@ -96,21 +86,17 @@ describe('OrcaRuntimeService', () => { getForegroundProcess: async () => null }) - try { - const terminal = await runtime.createTerminal('path:C:/remote/repo', { - command: 'claude', - title: 'worker' - }) + const terminal = await runtime.createTerminal('path:C:/remote/repo', { + command: 'claude', + title: 'worker' + }) - const spawnCall = spawn.mock.calls[0]?.[0] as { command?: string } | undefined - expect(spawnCall?.command).toBe("claude '--dangerously-skip-permissions'") - expect(terminal).toMatchObject({ - executionHostId: 'ssh:ssh-1', - hostPlatform: 'linux' - }) - } finally { - unregisterSshGitProvider('ssh-1') - } + const spawnCall = spawn.mock.calls[0]?.[0] as { command?: string } | undefined + expect(spawnCall?.command).toBe("claude '--dangerously-skip-permissions'") + expect(terminal).toMatchObject({ + executionHostId: 'ssh:ssh-1', + hostPlatform: 'linux' + }) }) it('matches canonical bare agent commands when a command override is configured', async () => { diff --git a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-04.spec.ts index 817b8fd2251..24f39579698 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-handles-and-agent-status-part-04.spec.ts @@ -569,9 +569,12 @@ describe('OrcaRuntimeService', () => { it('releases a stable-pane claim when creation aborts before provider spawn', async () => { const releaseClaim = vi.fn() const spawn = vi.fn() + // C5e relies on this: a disconnected client is refused before the pane is adopted. + const adoptStablePane = vi.fn() const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ claimStablePaneCreate: vi.fn(() => releaseClaim), + adoptStablePane, spawn, write: () => true, kill: () => true, @@ -589,6 +592,65 @@ describe('OrcaRuntimeService', () => { ).rejects.toThrow('client_disconnected') expect(spawn).not.toHaveBeenCalled() + expect(adoptStablePane).not.toHaveBeenCalled() expect(releaseClaim).toHaveBeenCalledOnce() }) + + // C5e relies on this: createTerminal checks the client again after adoption, before it spawns. + it('refuses before spawning when the client disconnects during pane adoption', async () => { + const abort = new AbortController() + const spawn = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + adoptStablePane: vi.fn(async () => { + abort.abort() + return null + }), + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect( + runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'disconnected-during-adopt', + leafId: HEADLESS_LEAF_ID, + signal: abort.signal + }) + ).rejects.toThrow('client_disconnected') + + expect(spawn).not.toHaveBeenCalled() + }) + + // C5e and C7 rely on this: createTerminal hands the client signal and the slept-worktree refusal to the spawn. + it('passes the client signal and the slept-worktree refusal to the spawn', async () => { + const spawn = vi.fn(async (_args: Record) => { + throw new Error('spawn_reached') + }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + adoptStablePane: vi.fn(async () => null), + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const abort = new AbortController() + + await expect( + runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'guards-forwarded', + leafId: HEADLESS_LEAF_ID, + signal: abort.signal, + refuseSleptWorktree: true + }) + ).rejects.toThrow('spawn_reached') + + expect(spawn).toHaveBeenCalledOnce() + expect(spawn.mock.calls[0]?.[0]).toMatchObject({ + signal: abort.signal, + refuseSleptWorktree: true + }) + }) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts index 01e6e8f5826..b13db8acaa3 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts @@ -5,9 +5,7 @@ import { getDefaultWorkspaceSession, join, mkdtemp, - registerSshFilesystemProvider, - tmpdir, - unregisterSshFilesystemProvider + tmpdir } from '../orca-runtime-test-mocks.spec' import type { OrchestrationDb } from '../orchestration/db' import type { WorkspaceSessionState } from '../orca-runtime-test-mocks.spec' @@ -279,7 +277,7 @@ describe('OrcaRuntimeService', () => { }) }) - it('adopts an SSH folder legacy worker through its SSH workspace-session partition', async () => { + it('defers an SSH folder legacy worker while its folder path is unavailable', async () => { const connectionId = 'ssh-folder' const ptyId = `ssh:${connectionId}@@pty-folder-legacy` const workerPaneKey = `legacy-ssh-folder-worker:${HEADLESS_LEAF_ID}` @@ -374,52 +372,21 @@ describe('OrcaRuntimeService', () => { }) ) runtime.setNotifier({ revealTerminalSession } as never) - registerSshFilesystemProvider(connectionId, { - stat: vi.fn(async () => ({ size: 0, type: 'directory', mtime: 1 })) - } as never) - try { - expect(sshSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() - expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() - await expect( - runtime.reconcileLegacyWorkerTerminals({ - connectionId, - materializeRenderer: true - }) - ).resolves.toMatchObject({ - adoptedDispatchIds: ['dispatch-ssh-folder'], - exitedDispatchIds: [], - deferredDispatchIds: [] + expect(sshSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() + // Why: this process serves no SSH filesystem, so the folder path stays unavailable. + await expect( + runtime.reconcileLegacyWorkerTerminals({ + connectionId, + materializeRenderer: true }) - } finally { - unregisterSshFilesystemProvider(connectionId) - } - - expect(sshSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() - expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() - expect(getWorkspaceSession).toHaveBeenCalledWith(`ssh:${connectionId}`) - expect(setWorkspaceSession).toHaveBeenCalledWith(expect.any(Object), `ssh:${connectionId}`) - expect(listProcesses).toHaveBeenCalledTimes(3) - expect(listProcesses).toHaveBeenCalledWith(`ssh:${connectionId}`, LIST_PROVIDER_DEADLINE) - expect(sshSession.tabsByWorktree[TEST_FOLDER_WORKSPACE_KEY]).toContainEqual( - expect.objectContaining({ - id: 'legacy-ssh-folder-worker', - ptyId, - worktreeId: TEST_FOLDER_WORKSPACE_KEY - }) - ) - expect(revealTerminalSession).toHaveBeenCalledWith(TEST_FOLDER_WORKSPACE_KEY, { - ptyId, - title: 'SSH folder worker', - activate: false, - presentation: 'background', - tabId: 'legacy-ssh-folder-worker', - leafId: HEADLESS_LEAF_ID, - focus: false, - expectedProcessIdentity: { - terminalHandle: 'term_ssh_folder', - incarnationId - } + ).resolves.toMatchObject({ + adoptedDispatchIds: [], + exitedDispatchIds: [], + deferredDispatchIds: ['dispatch-ssh-folder'] }) + expect(sshSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() + expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() + expect(revealTerminalSession).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts index 8559bbe0d74..804c3fbc75b 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts @@ -3,9 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, getDefaultWorkspaceSession, - registerSshGitProvider, - setPlatform, - unregisterSshGitProvider + setPlatform } from '../orca-runtime-test-mocks.spec' import type { OrchestrationDb } from '../orchestration/db' import type { WorkspaceSessionState } from '../orca-runtime-test-mocks.spec' @@ -130,47 +128,32 @@ describe('OrcaRuntimeService', () => { }) ) runtime.setNotifier({ revealTerminalSession } as never) - registerSshGitProvider(connectionId, { - listWorktrees: vi.fn(async () => [ - { - path: TEST_WORKTREE_PATH, - head: 'abc', - branch: 'main', - isBare: false, - isMainWorktree: false - } - ]) - } as never) - try { - await expect( - runtime.reconcileLegacyWorkerTerminals({ - connectionId: 'ssh-wrong-host', - materializeRenderer: true - }) - ).resolves.toMatchObject({ - adoptedDispatchIds: [], - exitedDispatchIds: [], - deferredDispatchIds: ['dispatch-ssh'] + await expect( + runtime.reconcileLegacyWorkerTerminals({ + connectionId: 'ssh-wrong-host', + materializeRenderer: true }) - expect(listProcesses).not.toHaveBeenCalled() - expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() - expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() + ).resolves.toMatchObject({ + adoptedDispatchIds: [], + exitedDispatchIds: [], + deferredDispatchIds: ['dispatch-ssh'] + }) + expect(listProcesses).not.toHaveBeenCalled() + expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() + expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() - await expect( - runtime.reconcileLegacyWorkerTerminals({ - connectionId, - materializeRenderer: true - }) - ).resolves.toMatchObject({ - adoptedDispatchIds: ['dispatch-ssh'], - exitedDispatchIds: [], - deferredDispatchIds: [] + await expect( + runtime.reconcileLegacyWorkerTerminals({ + connectionId, + materializeRenderer: true }) - expect(listProcesses).toHaveBeenLastCalledWith(`ssh:${connectionId}`, LIST_PROVIDER_DEADLINE) - } finally { - unregisterSshGitProvider(connectionId) - } + ).resolves.toMatchObject({ + adoptedDispatchIds: ['dispatch-ssh'], + exitedDispatchIds: [], + deferredDispatchIds: [] + }) + expect(listProcesses).toHaveBeenLastCalledWith(`ssh:${connectionId}`, LIST_PROVIDER_DEADLINE) expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() expect(getWorkspaceSession).toHaveBeenCalledWith(`ssh:${connectionId}`) diff --git a/src/main/runtime/orca-runtime-tests/worktree-create-execution-host.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-create-execution-host.spec.ts index 981e9860463..05906574448 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-create-execution-host.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-create-execution-host.spec.ts @@ -1,10 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - OrcaRuntimeService, - addWorktree, - registerSshGitProvider, - unregisterSshGitProvider -} from '../orca-runtime-test-mocks.spec' +import { OrcaRuntimeService, addWorktree } from '../orca-runtime-test-mocks.spec' import { store } from '../orca-runtime-test-fixtures.spec' const RUNTIME_REPO_PATH = '/remote/repo' @@ -38,24 +33,17 @@ describe('OrcaRuntimeService worktree create execution host', () => { expect(addWorktree).not.toHaveBeenCalled() }) - it('refuses a runtime-hosted repo whose nested SSH target is dialable in this namespace', async () => { - // `target-a` names a target inside env-1. The same-named one registered here is another - // machine, so creating through it would put the checkout on the wrong host. - const provider = { exec: vi.fn(), addWorktree: vi.fn(), listWorktrees: vi.fn() } - registerSshGitProvider('target-a', provider as never) + it('refuses a runtime-hosted repo that names a nested SSH target', async () => { + // `target-a` names a target inside env-1, so creating through it here would put the checkout + // on the wrong host. const runtime = new OrcaRuntimeService( makeRuntimeHostedStore({ connectionId: 'target-a' }) as never ) - try { - await expect( - runtime.createManagedWorktree({ repoSelector: 'id:repo-1', name: 'wt' }) - ).rejects.toThrow('not dispatched by this process') + await expect( + runtime.createManagedWorktree({ repoSelector: 'id:repo-1', name: 'wt' }) + ).rejects.toThrow('not dispatched by this process') - expect(provider.addWorktree).not.toHaveBeenCalled() - expect(addWorktree).not.toHaveBeenCalled() - } finally { - unregisterSshGitProvider('target-a') - } + expect(addWorktree).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts index edef0e391c8..2b396c8afa7 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-selector-resolution.spec.ts @@ -10,16 +10,11 @@ import { addWorktree, computeWorktreePathMock, ensurePathWithinWorkspaceMock, - getSshGitProviderMock, join, listWorktrees, mkdir, mkdtemp, - registerSshFilesystemProvider, - registerSshGitProvider, tmpdir, - unregisterSshFilesystemProvider, - unregisterSshGitProvider, writeFile } from '../orca-runtime-test-mocks.spec' import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec' @@ -109,50 +104,26 @@ describe('OrcaRuntimeService', () => { badgeColor: 'blue', addedAt: 1, connectionId: 'ssh-1' - }) + }), + // Why: this process serves no SSH git, so the SSH repo resolves from its stored rows. + getAllWorktreeMeta: () => ({ + [`${TEST_REPO_ID}:://Server/Share/Repo`]: makeWorktreeMeta() + }), + getWorktreeMeta: () => makeWorktreeMeta() } - const fsProvider = { readDir: vi.fn().mockResolvedValue([]) } - const gitProvider = { - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '//Server/Share/Repo', - head: 'abc', - branch: 'feature/foo', - isBare: false, - isMainWorktree: false - } - ]), - getStatus: vi.fn().mockResolvedValue({ - branch: 'feature/foo', - files: [], - ahead: 0, - behind: 0, - hasConflicts: false - }) - } - registerSshFilesystemProvider('ssh-1', fsProvider as never) - registerSshGitProvider('ssh-1', gitProvider as never) const runtime = new OrcaRuntimeService(remoteStore as never) - try { - await expect(runtime.readFileExplorerDir('path://server/share/repo', 'src')).rejects.toThrow( - SshHostNotServedError - ) - await expect(runtime.getRuntimeGitStatus('path://server/share/repo')).rejects.toThrow( - SshHostNotServedError - ) - await expect(runtime.showRepo('path://server/share/repo')).resolves.toMatchObject({ - path: '//Server/Share/Repo' - }) - } finally { - unregisterSshFilesystemProvider('ssh-1') - unregisterSshGitProvider('ssh-1') - } + await expect(runtime.readFileExplorerDir('path://server/share/repo', 'src')).rejects.toThrow( + SshHostNotServedError + ) + await expect(runtime.getRuntimeGitStatus('path://server/share/repo')).rejects.toThrow( + SshHostNotServedError + ) + await expect(runtime.showRepo('path://server/share/repo')).resolves.toMatchObject({ + path: '//Server/Share/Repo' + }) expect(listWorktrees).not.toHaveBeenCalled() - expect(gitProvider.listWorktrees).toHaveBeenCalledWith('//Server/Share/Repo') - expect(fsProvider.readDir).not.toHaveBeenCalled() - expect(gitProvider.getStatus).not.toHaveBeenCalled() }) it.each([ @@ -181,44 +152,25 @@ describe('OrcaRuntimeService', () => { it('refuses SSH folder workspace file explorer reads instead of reading this machine', async () => { const folderPath = '/srv/platform' - const fsProvider = { - stat: vi.fn(async (pathValue: string) => ({ - size: pathValue.endsWith('/app.ts') ? 8 : 0, - type: pathValue.endsWith('/app.ts') ? 'file' : 'directory', - mtime: 1 - })), - readDir: vi.fn().mockResolvedValue([ - { - name: 'app.ts', - isDirectory: false, - isSymlink: false - } - ]), - readFile: vi.fn().mockResolvedValue({ content: 'remote\n', isBinary: false }) - } - const folderWorkspace = makeFolderWorkspace({ folderPath, connectionId: 'ssh-folder' }) + const folderWorkspace = makeFolderWorkspace({ + folderPath, + connectionId: 'ssh-folder' + }) const projectGroup = makeFolderProjectGroup({ parentPath: folderPath }) const runtime = new OrcaRuntimeService( createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never ) - registerSshFilesystemProvider('ssh-folder', fsProvider as never) - try { - await expect( - runtime.readFileExplorerDir(`id:${TEST_FOLDER_WORKSPACE_KEY}`, 'src') - ).rejects.toThrow(SshHostNotServedError) - await expect( - runtime.readFileExplorerPreview(`id:${TEST_FOLDER_WORKSPACE_KEY}`, 'src/app.ts') - ).rejects.toThrow(SshHostNotServedError) - } finally { - unregisterSshFilesystemProvider('ssh-folder') - } - - expect(fsProvider.readDir).not.toHaveBeenCalled() - expect(fsProvider.readFile).not.toHaveBeenCalled() + // Why: this process serves no SSH filesystem, so the folder path itself is unavailable. + await expect( + runtime.readFileExplorerDir(`id:${TEST_FOLDER_WORKSPACE_KEY}`, 'src') + ).rejects.toThrow('folder_workspace_path_unavailable:/srv/platform') + await expect( + runtime.readFileExplorerPreview(`id:${TEST_FOLDER_WORKSPACE_KEY}`, 'src/app.ts') + ).rejects.toThrow('folder_workspace_path_unavailable:/srv/platform') }) - it('lists persisted SSH worktrees while the git provider is unavailable', async () => { + it('lists persisted SSH worktrees from stored rows', async () => { vi.mocked(listWorktrees).mockClear() const remoteRepo = { id: 'remote-repo', @@ -250,7 +202,6 @@ describe('OrcaRuntimeService', () => { const listed = await runtime.listManagedWorktrees('id:remote-repo') expect(listWorktrees).not.toHaveBeenCalled() - expect(getSshGitProviderMock).toHaveBeenCalledWith('ssh-missing') expect(listed).toMatchObject({ totalCount: 2, truncated: false, @@ -304,7 +255,7 @@ describe('OrcaRuntimeService', () => { expect(listWorktrees).not.toHaveBeenCalled() }) - it('guides registered SSH repo ids without probing the provider', async () => { + it('guides registered SSH repo ids to the full id shape', async () => { const remoteRepo = { ...store.getRepos()[0], connectionId: 'ssh-1' } const remoteStore = { ...store, @@ -312,14 +263,12 @@ describe('OrcaRuntimeService', () => { getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined) } vi.mocked(listWorktrees).mockClear() - getSshGitProviderMock.mockClear() const runtime = new OrcaRuntimeService(remoteStore) await expect(runtime.showManagedWorktree(`id:${TEST_REPO_ID}`)).rejects.toMatchObject({ code: 'worktree_id_requires_full_path' }) expect(listWorktrees).not.toHaveBeenCalled() - expect(getSshGitProviderMock).not.toHaveBeenCalled() }) it('rejects bare repo ids through terminal list RPC with the structured code', async () => { diff --git a/src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts b/src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts index a126dbeb112..05701907f64 100644 --- a/src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts +++ b/src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts @@ -8,7 +8,6 @@ import { inferFolderWorkspacePathConnection } from '../project-groups/folder-workspace-path-status' import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-scope' import type { ResolvedWorktree } from './runtime-worktree-path-identity' import type { BrowserExecutionHostKeyResolution } from './runtime-browser-client-page-adoption' @@ -102,11 +101,10 @@ export class OrcaRuntimeWithTransitionGraphReloadToTerminalState extends OrcaRun if (!this.store) { throw new Error('runtime_unavailable') } - const status = await getFolderWorkspacePathStatus( - this.store, - { scope: 'folder-workspace', folderWorkspaceId: workspace.id }, - { getSshFilesystemProvider } - ) + const status = await getFolderWorkspacePathStatus(this.store, { + scope: 'folder-workspace', + folderWorkspaceId: workspace.id + }) assertFolderWorkspacePathUsable(status) return { id: folderWorkspaceKey(workspace.id), diff --git a/src/main/runtime/orca-runtime-worktree-change-invalidation.test.ts b/src/main/runtime/orca-runtime-worktree-change-invalidation.test.ts index 85fb35f7bd0..50115ecb99d 100644 --- a/src/main/runtime/orca-runtime-worktree-change-invalidation.test.ts +++ b/src/main/runtime/orca-runtime-worktree-change-invalidation.test.ts @@ -20,13 +20,6 @@ const electronMocks = vi.hoisted(() => { }) vi.mock('electron', () => electronMocks) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: vi.fn(), - getSshGitProviderGeneration: vi.fn(() => 0), - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', - requireSshGitProvider: vi.fn() -})) - const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) vi.mock('../git/worktree', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c73c810051c..eb96b9169bb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -15,10 +15,6 @@ import { peekOpenedAgentSessionRecordStore } from './agent-session-record-store- import { createAgentLaunchRecordWarmupGate } from './agent-launch-record-warmup-gate' import { WorkspaceLayoutStream } from './workspace-layout-stream' import type { RemoteLayoutSnapshot } from '../../shared/remote-layout-snapshot' -import { - shouldWatchLayoutRoundTrip, - watchLayoutRoundTrip -} from './workspace-layout-round-trip-watch' import { registerDetectedWorktreeScanInvalidation } from '../ipc/worktrees/listing/register-detected-worktree-scan-invalidation' import { listReferenceWorkspaces, @@ -91,9 +87,6 @@ class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog { // module registers the generation bump at load; a headless host never loads it. registerDetectedWorktreeScanInvalidation() registerWorktreeChangeInvalidator((repoId) => this.invalidateWorktreeCatalog(repoId)) - if (this.store && shouldWatchLayoutRoundTrip()) { - watchLayoutRoundTrip(this.store) - } } /** Whether a window owns the layout and can show a launch's tab ahead of its process. */ diff --git a/src/main/runtime/orchestration-worker-workspace-resolution.test.ts b/src/main/runtime/orchestration-worker-workspace-resolution.test.ts index d95139045a7..85313d07b00 100644 --- a/src/main/runtime/orchestration-worker-workspace-resolution.test.ts +++ b/src/main/runtime/orchestration-worker-workspace-resolution.test.ts @@ -21,22 +21,11 @@ vi.mock('electron', () => electronMocks) const scanLocalRepoWorktreesForResolution = vi.hoisted(() => vi.fn()) vi.mock('./repo-worktree-resolution-scan', () => ({ scanLocalRepoWorktreesForResolution })) -const getSshGitProvider = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider, - getSshGitProviderGeneration: vi.fn(() => 0), - requireSshGitProvider: (connectionId: string) => getSshGitProvider(connectionId) -})) - import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' 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 { WorktreeMeta } from '../../shared/worktree/meta-types' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' import { OrcaRuntimeService } from './orca-runtime' const REPO_ID = 'repo-1' @@ -124,7 +113,6 @@ describe('orchestration worker workspace resolution', () => { } ] }) - getSshGitProvider.mockReset() }) afterEach(async () => { @@ -208,17 +196,6 @@ describe('orchestration worker workspace resolution', () => { addedAt: 1, connectionId: 'ssh-1' } satisfies Repo - getSshGitProvider.mockReturnValue({ - listWorktrees: vi.fn().mockResolvedValue([ - { - path: WORKTREE_PATH, - head: 'remote', - branch: 'remote-feature', - isBare: false, - isMainWorktree: false - } - ]) - }) const runtime = new OrcaRuntimeService( makeStore({ repos: [makeStore().getRepos()[0], remoteRepo], @@ -234,7 +211,7 @@ describe('orchestration worker workspace resolution', () => { ) }) - it('resolves local and SSH folder workspaces without a Git catalog scan', async () => { + it('resolves a local folder workspace and refuses an SSH one without a Git catalog scan', async () => { const localPath = await mkdtemp(join(tmpdir(), 'orca-worker-local-folder-')) tempPaths.push(localPath) const group = { id: 'group-1', name: 'Group', parentPath: localPath } as ProjectGroup @@ -251,30 +228,24 @@ describe('orchestration worker workspace resolution', () => { folderPath: '/srv/app', connectionId: 'ssh-folder' } - registerSshFilesystemProvider('ssh-folder', { - stat: vi.fn().mockResolvedValue({ type: 'directory', size: 0, mtime: 1 }) - } as never) - try { - const runtime = new OrcaRuntimeService( - makeStore({ - folderWorkspaces: [localFolder, remoteFolder], - projectGroups: [group] - }) as never - ) + const runtime = new OrcaRuntimeService( + makeStore({ + folderWorkspaces: [localFolder, remoteFolder], + projectGroups: [group] + }) as never + ) - await expect( - runtime.showManagedTerminalWorkspace('folder:local-folder') - ).resolves.toMatchObject({ - id: 'folder:local-folder', - path: localPath, - hostId: 'local' - }) - await expect( - runtime.showManagedTerminalWorkspace('id:folder:remote-folder') - ).resolves.toMatchObject({ id: 'folder:remote-folder', hostId: 'ssh:ssh-folder' }) - } finally { - unregisterSshFilesystemProvider('ssh-folder') - } + await expect( + runtime.showManagedTerminalWorkspace('folder:local-folder') + ).resolves.toMatchObject({ + id: 'folder:local-folder', + path: localPath, + hostId: 'local' + }) + // Why: this process serves no SSH filesystem, so the SSH folder path is unavailable. + await expect(runtime.showManagedTerminalWorkspace('id:folder:remote-folder')).rejects.toThrow( + 'folder_workspace_path_unavailable:/srv/app' + ) expect(scanLocalRepoWorktreesForResolution).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/orchestration/worker-output-archive.test.ts b/src/main/runtime/orchestration/worker-output-archive.test.ts index d5a9d5da73a..37919576ef7 100644 --- a/src/main/runtime/orchestration/worker-output-archive.test.ts +++ b/src/main/runtime/orchestration/worker-output-archive.test.ts @@ -3,7 +3,6 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../orca-runtime' -import * as sshFilesystemDispatch from '../../providers/ssh-filesystem-dispatch' import * as workerTranscriptRead from './worker-transcript-read' import { captureWorkerOutputArchive, summarizeWorkerOutputArchive } from './worker-output-archive' @@ -34,18 +33,15 @@ function codexMessage(id: string, text: string): string { describe('worker output archive WSL routing', () => { let directory: string let transcriptPath: string - let sshProviderLookup: { mockRestore: () => void } let transcriptReadSpy: { mockRestore: () => void } | undefined beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-worker-archive-')) transcriptPath = join(directory, 'session.jsonl') await writeFile(transcriptPath, `${codexMessage('wsl', 'WSL archive output')}\n`) - sshProviderLookup = vi.spyOn(sshFilesystemDispatch, 'getSshFilesystemProvider') }) afterEach(async () => { - sshProviderLookup.mockRestore() transcriptReadSpy?.mockRestore() await rm(directory, { recursive: true, force: true }) }) @@ -101,8 +97,7 @@ describe('worker output archive WSL routing', () => { sessionId: 'wsl-session', transcriptPath: guestTranscriptPath, wslDistro: 'Ubuntu', - limit: expect.any(Number), - filesystemProvider: undefined + limit: expect.any(Number) }) expect(result).toMatchObject({ kind: 'transcript_pin', @@ -111,11 +106,9 @@ describe('worker output archive WSL routing', () => { messages: [{ id: 'wsl', blocks: [{ type: 'text', text: 'WSL archive output' }] }] } }) - expect(sshProviderLookup).not.toHaveBeenCalled() }) - it('does not resolve an SSH transcript locally when its provider is unavailable', async () => { - vi.mocked(sshFilesystemDispatch.getSshFilesystemProvider).mockReturnValue(undefined) + it('does not resolve an SSH transcript locally', async () => { transcriptReadSpy = vi.spyOn(workerTranscriptRead, 'readWorkerTranscript') const runtime = { getExactWorkerProviderSession: vi.fn(() => ({ @@ -144,7 +137,6 @@ describe('worker output archive WSL routing', () => { attachedAtMs: Date.now() - 1 }) - expect(sshFilesystemDispatch.getSshFilesystemProvider).toHaveBeenCalledWith('ssh:remote-host') expect(workerTranscriptRead.readWorkerTranscript).not.toHaveBeenCalled() expect(result).toMatchObject({ kind: 'terminal_tail', diff --git a/src/main/runtime/orchestration/worker-output-archive.ts b/src/main/runtime/orchestration/worker-output-archive.ts index ddae515a7f2..5aec3ab0887 100644 --- a/src/main/runtime/orchestration/worker-output-archive.ts +++ b/src/main/runtime/orchestration/worker-output-archive.ts @@ -12,7 +12,6 @@ import { redactWorkerTerminalLines } from './worker-transcript-payload' import { readWorkerTranscript } from './worker-transcript-read' -import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { isWslHookRelayConnectionId } from '../../../shared/wsl-hook-relay-contract' import { captureStructuredWorkerArchive } from '../rpc/methods/orchestration-structured-worker-lifecycle' import type { WorkerStructuredJournalArchive } from './structured-worker-journal-archive' @@ -106,11 +105,9 @@ export async function captureWorkerOutputArchive(args: { if (session) { transcriptFallbackReason = 'transcript_unreadable' const isWslSession = isWslHookRelayConnectionId(session.connectionId) - const remoteConnectionId = session.connectionId && !isWslSession ? session.connectionId : null - const remoteFilesystemProvider = remoteConnectionId - ? getSshFilesystemProvider(remoteConnectionId) - : undefined - if ((isWslSession && !session.wslDistro) || (remoteConnectionId && !remoteFilesystemProvider)) { + // Why: this process serves no SSH filesystem, so an SSH worker's transcript is unreachable. + const isSshSession = Boolean(session.connectionId) && !isWslSession + if ((isWslSession && !session.wslDistro) || isSshSession) { transcriptFallbackReason = 'remote_capability_unavailable' } else { const snapshot = await readWorkerTranscript({ @@ -118,8 +115,7 @@ export async function captureWorkerOutputArchive(args: { sessionId: session.providerSession.id, transcriptPath: session.providerSession.transcriptPath, wslDistro: session.wslDistro, - limit: MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT, - filesystemProvider: remoteFilesystemProvider + limit: MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT }).catch(() => null) if (snapshot?.ok && snapshot.messages.length > 0) { return { diff --git a/src/main/runtime/orchestration/worker-transcript-local-read.ts b/src/main/runtime/orchestration/worker-transcript-local-read.ts index 50bdc1a924f..3ce5385e367 100644 --- a/src/main/runtime/orchestration/worker-transcript-local-read.ts +++ b/src/main/runtime/orchestration/worker-transcript-local-read.ts @@ -6,7 +6,6 @@ import { type NativeChatLineDecoder } from '../../native-chat/transcript-tail-reader' import { transcriptFallbackId } from '../../native-chat/transcript-fallback-id' -import { MAX_REMOTE_TRANSCRIPT_SCAN_BYTES } from './worker-transcript-remote-read' import { localTranscriptOffsetStartsInsideRecord, readLocalTranscriptHandleBoundaryCheckpoint, @@ -19,6 +18,8 @@ import { type WorkerTranscriptSourceIdentity } from './worker-transcript-source-identity' +const MAX_TRANSCRIPT_SCAN_BYTES = 8 * 1024 * 1024 + type LocalTranscriptReadSuccess = { ok: true filePath: string @@ -87,7 +88,7 @@ export async function readForwardLocalWorkerTranscriptPage( if (startOffset > fileSize) { return sourceChanged() } - const scanEnd = Math.min(fileSize, startOffset + MAX_REMOTE_TRANSCRIPT_SCAN_BYTES) + const scanEnd = Math.min(fileSize, startOffset + MAX_TRANSCRIPT_SCAN_BYTES) const handle = await open(filePath, 'r') const opened = localWorkerTranscriptSourceIdentity(await handle.stat({ bigint: true })) if (!opened || opened.fingerprint !== sourceIdentity.fingerprint || opened.size < scanEnd) { diff --git a/src/main/runtime/orchestration/worker-transcript-read.ts b/src/main/runtime/orchestration/worker-transcript-read.ts index cb6f0467c4a..3272281bad8 100644 --- a/src/main/runtime/orchestration/worker-transcript-read.ts +++ b/src/main/runtime/orchestration/worker-transcript-read.ts @@ -3,7 +3,6 @@ import { resolveNativeChatTranscriptAgent } from '../../../shared/native-chat-ag import type { OrchestrationWorkerReadFallbackReason } from '../../../shared/orchestration-worker-output' import { resolveSessionFilePath } from '../../native-chat/session-file-resolver' import { nativeChatLineDecoderForAgent } from '../../native-chat/transcript-tail-reader' -import type { IFilesystemProvider } from '../../providers/types' import { boundWorkerTranscriptMessages, clampWorkerTranscriptLimit @@ -12,7 +11,6 @@ import { readForwardLocalWorkerTranscriptPage, readInitialLocalWorkerTranscriptPage } from './worker-transcript-local-read' -import { readRemoteWorkerTranscript } from './worker-transcript-remote-read' type WorkerTranscriptReadFailure = { ok: false @@ -46,8 +44,6 @@ export async function readWorkerTranscript(args: { expectedSourceFingerprint?: string /** Hash of the bounded content immediately before a cursor offset. */ expectedBoundaryCheckpoint?: string - /** Remote execution-host provider. When present no local filesystem lookup occurs. */ - filesystemProvider?: IFilesystemProvider }): Promise { const transcriptAgent = resolveNativeChatTranscriptAgent(args.agent) if (!transcriptAgent) { @@ -58,24 +54,6 @@ export async function readWorkerTranscript(args: { return { ok: false, reason: 'provider_unsupported', warnings: [] } } let filePath: string | null - if (args.filesystemProvider) { - // A remote provider can only read the hook-attested path. Never search the - // desktop's provider roots for a remote session (same-path sentinels are a - // real authority boundary, not merely a portability concern). - filePath = args.transcriptPath?.trim() || null - if (!filePath) { - return { ok: false, reason: 'transcript_missing', warnings: [] } - } - const page = await readRemoteWorkerTranscript(args, filePath, decode) - if ( - page.ok && - args.expectedSourceFingerprint && - page.sourceFingerprint !== args.expectedSourceFingerprint - ) { - return { ok: false, reason: 'source_changed', warnings: [] } - } - return page - } try { filePath = await resolveSessionFilePath(args.agent, args.sessionId, { transcriptPath: args.transcriptPath, diff --git a/src/main/runtime/orchestration/worker-transcript-remote-range-read.ts b/src/main/runtime/orchestration/worker-transcript-remote-range-read.ts deleted file mode 100644 index 1403cb2c010..00000000000 --- a/src/main/runtime/orchestration/worker-transcript-remote-range-read.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { MAX_FILE_RANGE_READ_BYTES } from '../../../shared/file-range-read' -import type { IFilesystemProvider } from '../../providers/types' -import { - createWorkerTranscriptBoundaryCheckpoint, - remoteWorkerTranscriptSourceIdentity, - workerTranscriptBoundaryCheckpointStart, - workerTranscriptSourceChanged, - type WorkerTranscriptSourceIdentity -} from './worker-transcript-source-identity' - -export type RemoteTranscriptWindow = { - bytes: Buffer - fileSize: number - startOffset: number - scanEnd: number - startsInsideRecord: boolean - boundaryPrefix: Buffer - sourceIdentity: WorkerTranscriptSourceIdentity -} - -export async function supportsRemoteTranscriptRangeRead( - provider: IFilesystemProvider -): Promise { - if (!provider.readFileRange) { - return false - } - return provider.supportsFileRangeRead ? provider.supportsFileRangeRead() : true -} - -export async function readRemoteTranscriptBoundaryBytes( - provider: IFilesystemProvider, - filePath: string, - offset: number -): Promise { - const start = workerTranscriptBoundaryCheckpointStart(offset) - const expectedBytes = offset - start - const bytes = await readRemoteTranscriptRange(provider, filePath, start, expectedBytes) - return bytes.length === expectedBytes ? bytes : null -} - -export async function readRemoteTranscriptRangedWindow(args: { - provider: IFilesystemProvider - filePath: string - requestedOffset?: number - expectedBoundaryCheckpoint?: string - maxScanBytes: number -}): Promise { - const remoteStat = await args.provider.stat(args.filePath) - const sourceIdentity = remoteWorkerTranscriptSourceIdentity(remoteStat) - if (!sourceIdentity) { - throw new Error('Remote transcript host did not provide stable file identity') - } - const fileSize = remoteStat.size - const startOffset = args.requestedOffset ?? Math.max(0, fileSize - args.maxScanBytes) - if (startOffset > fileSize) { - return null - } - const scanEnd = - args.requestedOffset === undefined - ? fileSize - : Math.min(fileSize, startOffset + args.maxScanBytes) - const boundaryPrefix = await readRemoteTranscriptBoundaryBytes( - args.provider, - args.filePath, - startOffset - ) - if (!boundaryPrefix) { - return null - } - const boundaryCheckpoint = createWorkerTranscriptBoundaryCheckpoint(boundaryPrefix) - if ( - args.expectedBoundaryCheckpoint !== undefined && - boundaryCheckpoint !== args.expectedBoundaryCheckpoint - ) { - return null - } - const startsInsideRecord = boundaryPrefix.length > 0 && boundaryPrefix.at(-1) !== 0x0a - const bytes = await readRemoteTranscriptRange( - args.provider, - args.filePath, - startOffset, - scanEnd - startOffset - ) - if (bytes.length !== scanEnd - startOffset) { - return null - } - const boundaryAfter = await readRemoteTranscriptBoundaryBytes( - args.provider, - args.filePath, - startOffset - ) - const after = remoteWorkerTranscriptSourceIdentity(await args.provider.stat(args.filePath)) - if ( - !boundaryAfter || - createWorkerTranscriptBoundaryCheckpoint(boundaryAfter) !== boundaryCheckpoint || - workerTranscriptSourceChanged(sourceIdentity, after, scanEnd) - ) { - return null - } - return { - bytes, - fileSize, - startOffset, - scanEnd, - startsInsideRecord, - boundaryPrefix, - sourceIdentity - } -} - -export async function readRemoteTranscriptRange( - provider: IFilesystemProvider, - filePath: string, - position: number, - length: number -): Promise { - const windows: Buffer[] = [] - let bytesRead = 0 - while (bytesRead < length) { - const windowLength = Math.min(MAX_FILE_RANGE_READ_BYTES, length - bytesRead) - const window = await provider.readFileRange!(filePath, position + bytesRead, windowLength) - windows.push(window.bytes) - bytesRead += window.bytesRead - if (window.bytesRead < windowLength) { - break - } - } - return Buffer.concat(windows, bytesRead) -} diff --git a/src/main/runtime/orchestration/worker-transcript-remote-read.test.ts b/src/main/runtime/orchestration/worker-transcript-remote-read.test.ts deleted file mode 100644 index 595ec07b6cc..00000000000 --- a/src/main/runtime/orchestration/worker-transcript-remote-read.test.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { MAX_FILE_RANGE_READ_BYTES } from '../../../shared/file-range-read' -import type { IFilesystemProvider } from '../../providers/types' -import { sshFileStreamReadCap } from '../../ssh/ssh-file-stream-read-cap' -import { readWorkerTranscript } from './worker-transcript-read' -import { MAX_REMOTE_TRANSCRIPT_SCAN_BYTES } from './worker-transcript-remote-read' - -function codexMessage(id: string, text: string): Buffer { - return Buffer.from( - `${JSON.stringify({ - type: 'event_msg', - payload: { id, type: 'agent_message', message: text } - })}\n` - ) -} - -function fileStat(readContents: () => Buffer, readIdentity: () => number = () => 1) { - return { - size: readContents().length, - type: 'file' as const, - mtime: 0, - mtimeMs: 0, - dev: 7, - ino: readIdentity() - } -} - -function rangedProvider( - readContents: () => Buffer, - readIdentity?: () => number -): { - provider: IFilesystemProvider - readFile: ReturnType - readFileRange: ReturnType -} { - const readFile = vi.fn(async () => { - throw new Error('Whole-file reads must not serve a ranged transcript') - }) - const readFileRange = vi.fn(async (_path: string, position: number, length: number) => { - const bytes = readContents().subarray(position, position + length) - return { bytes, bytesRead: bytes.length } - }) - return { - provider: { - readFile, - readFileRange, - supportsFileRangeRead: vi.fn(async () => true), - stat: vi.fn(async () => fileStat(readContents, readIdentity)) - } as unknown as IFilesystemProvider, - readFile, - readFileRange - } -} - -function preRangeProvider( - readContents: () => Buffer, - readIdentity?: () => number -): IFilesystemProvider { - return { - readFile: vi.fn(async () => ({ content: readContents().toString('utf8'), isBinary: false })), - readFileRange: vi.fn(), - supportsFileRangeRead: vi.fn(async () => false), - stat: vi.fn(async () => fileStat(readContents, readIdentity)) - } as unknown as IFilesystemProvider -} - -describe('remote worker transcript reads', () => { - it('reports a missing attested path separately from remote capability loss', async () => { - const result = await readWorkerTranscript({ - agent: 'codex', - sessionId: 'missing-path-session', - filesystemProvider: preRangeProvider(() => Buffer.from('')) - }) - - expect(result).toEqual({ ok: false, reason: 'transcript_missing', warnings: [] }) - }) - - it.each([ - ['ranged', (readContents: () => Buffer) => rangedProvider(readContents).provider], - ['pre-range', preRangeProvider] - ])( - 'holds a split EOF record at its start and emits it once after append on a %s host', - async (_providerKind, createProvider) => { - const first = codexMessage('first', 'complete before split') - const splitRecord = codexMessage('split', 'completed by second append') - const splitAt = Math.floor(splitRecord.length / 2) - let contents = Buffer.concat([first, splitRecord.subarray(0, splitAt)]) - const provider = createProvider(() => contents) - const transcriptPath = '/remote/split-append.jsonl' - - const initial = await readWorkerTranscript({ - agent: 'codex', - sessionId: 'split-session', - transcriptPath, - filesystemProvider: provider, - limit: 10 - }) - - expect(initial).toMatchObject({ - ok: true, - messages: [{ id: 'first', blocks: [{ type: 'text', text: 'complete before split' }] }], - nextOffset: first.length, - limited: false, - warnings: [] - }) - if (!initial.ok) { - throw new Error('Expected an initial split transcript page') - } - - contents = Buffer.concat([contents, splitRecord.subarray(splitAt)]) - const completed = await readWorkerTranscript({ - agent: 'codex', - sessionId: 'split-session', - transcriptPath, - filesystemProvider: provider, - offset: initial.nextOffset, - expectedSourceFingerprint: initial.sourceFingerprint, - expectedBoundaryCheckpoint: initial.boundaryCheckpoint, - limit: 10 - }) - expect(completed).toMatchObject({ - ok: true, - messages: [{ id: 'split', blocks: [{ type: 'text', text: 'completed by second append' }] }], - nextOffset: contents.length, - limited: false - }) - if (!completed.ok) { - throw new Error('Expected the completed split transcript page') - } - - await expect( - readWorkerTranscript({ - agent: 'codex', - sessionId: 'split-session', - transcriptPath, - filesystemProvider: provider, - offset: completed.nextOffset, - expectedSourceFingerprint: completed.sourceFingerprint, - expectedBoundaryCheckpoint: completed.boundaryCheckpoint, - limit: 10 - }) - ).resolves.toMatchObject({ ok: true, messages: [], nextOffset: contents.length }) - } - ) - - it('returns and redacts the newest bounded page from an append-only transcript over 8 MiB', async () => { - const capability = `dcap_${'A'.repeat(43)}` - let contents = Buffer.concat([ - Buffer.alloc(MAX_REMOTE_TRANSCRIPT_SCAN_BYTES + 128, 0x78), - Buffer.from('\n'), - codexMessage('latest', `newest output ${capability}`) - ]) - const { provider, readFile, readFileRange } = rangedProvider(() => contents) - const transcriptPath = '/remote/home/ada/.codex/sessions/rollout.jsonl' - - const initial = await readWorkerTranscript({ - agent: 'codex', - sessionId: 'remote-session', - transcriptPath, - filesystemProvider: provider, - limit: 2 - }) - - expect(initial).toMatchObject({ - ok: true, - messages: [ - { - id: 'latest', - blocks: [{ type: 'text', text: 'newest output [dispatch capability redacted]' }] - } - ], - nextOffset: contents.length, - limited: true, - warnings: expect.arrayContaining([ - 'Dispatch capability tokens were redacted from transcript output.', - 'Older transcript records were clipped by the remote scan limit and are not pageable through this EOF cursor; the cursor only follows records appended after this read.' - ]) - }) - expect(readFile).not.toHaveBeenCalled() - expect(readFileRange.mock.calls.every((call) => call[2] <= MAX_FILE_RANGE_READ_BYTES)).toBe( - true - ) - expect(readFileRange.mock.calls.reduce((sum, call) => sum + call[2], 0)).toBeLessThanOrEqual( - MAX_REMOTE_TRANSCRIPT_SCAN_BYTES + 128 - ) - expect(JSON.stringify(initial)).not.toContain(capability) - if (!initial.ok) { - throw new Error('Expected an initial transcript page') - } - expect(initial.warnings.join(' ')).not.toContain('continue with the cursor') - - contents = Buffer.concat([contents, codexMessage('appended', 'arrived after the first read')]) - await expect( - readWorkerTranscript({ - agent: 'codex', - sessionId: 'remote-session', - transcriptPath, - filesystemProvider: provider, - offset: initial.nextOffset, - expectedSourceFingerprint: initial.sourceFingerprint, - expectedBoundaryCheckpoint: initial.boundaryCheckpoint, - limit: 2 - }) - ).resolves.toMatchObject({ - ok: true, - messages: [ - { id: 'appended', blocks: [{ type: 'text', text: 'arrived after the first read' }] } - ], - nextOffset: contents.length, - limited: false - }) - }) - - it('keeps the bounded whole-file fallback for an older SSH host', async () => { - const contents = codexMessage('legacy', 'small legacy transcript') - const readFile = vi.fn(async () => ({ content: contents.toString('utf8'), isBinary: false })) - const readFileRange = vi.fn() - const provider = { - readFile, - readFileRange, - supportsFileRangeRead: vi.fn(async () => false), - stat: vi.fn(async () => fileStat(() => contents)) - } as unknown as IFilesystemProvider - - await expect( - readWorkerTranscript({ - agent: 'codex', - sessionId: 'legacy-session', - transcriptPath: '/remote/legacy.jsonl', - filesystemProvider: provider - }) - ).resolves.toMatchObject({ - ok: true, - messages: [{ id: 'legacy', blocks: [{ type: 'text', text: 'small legacy transcript' }] }] - }) - expect(readFile).toHaveBeenCalledWith('/remote/legacy.jsonl', { - maxTextBytes: sshFileStreamReadCap(false) - }) - expect(readFileRange).not.toHaveBeenCalled() - }) - - it('tails above the scan cap on a pre-range host and follows its EOF cursor', async () => { - let contents = Buffer.concat([ - Buffer.alloc(MAX_REMOTE_TRANSCRIPT_SCAN_BYTES + 128, 0x78), - Buffer.from('\n'), - codexMessage('legacy-tail', 'newest legacy output') - ]) - const readFile = vi.fn(async (_path: string, limits?: { maxTextBytes?: number }) => { - if (contents.length > (limits?.maxTextBytes ?? 0)) { - throw new Error('Reported totalSize exceeds client cap') - } - return { content: contents.toString('utf8'), isBinary: false } - }) - const provider = { - readFile, - readFileRange: vi.fn(), - supportsFileRangeRead: vi.fn(async () => false), - stat: vi.fn(async () => fileStat(() => contents)) - } as unknown as IFilesystemProvider - const transcriptPath = '/remote/legacy-large.jsonl' - - const initial = await readWorkerTranscript({ - agent: 'codex', - sessionId: 'legacy-large-session', - transcriptPath, - filesystemProvider: provider, - limit: 2 - }) - - expect(initial).toMatchObject({ - ok: true, - messages: [{ id: 'legacy-tail', blocks: [{ type: 'text', text: 'newest legacy output' }] }], - nextOffset: contents.length, - limited: true, - warnings: expect.arrayContaining([ - 'Older transcript records were clipped by the remote scan limit and are not pageable through this EOF cursor; the cursor only follows records appended after this read.' - ]) - }) - if (!initial.ok) { - throw new Error('Expected an initial legacy transcript page') - } - - contents = Buffer.concat([contents, codexMessage('legacy-appended', 'followed from cursor')]) - await expect( - readWorkerTranscript({ - agent: 'codex', - sessionId: 'legacy-large-session', - transcriptPath, - filesystemProvider: provider, - offset: initial.nextOffset, - expectedSourceFingerprint: initial.sourceFingerprint, - expectedBoundaryCheckpoint: initial.boundaryCheckpoint, - limit: 2 - }) - ).resolves.toMatchObject({ - ok: true, - messages: [ - { id: 'legacy-appended', blocks: [{ type: 'text', text: 'followed from cursor' }] } - ], - nextOffset: contents.length, - limited: false - }) - expect(readFile).toHaveBeenLastCalledWith(transcriptPath, { - maxTextBytes: sshFileStreamReadCap(false) - }) - }) - - it.each([ - ['equal-size', 0], - ['larger', 64] - ])('rejects a same-identity ranged truncate/regrow at %s', async (_label, extraBytes) => { - let contents = codexMessage( - 'first', - 'original transcript with enough padding for equal-size rewrite' - ) - const { provider } = rangedProvider(() => contents) - const transcriptPath = '/remote/replaced.jsonl' - const initial = await readWorkerTranscript({ - agent: 'codex', - sessionId: 'replacement-session', - transcriptPath, - filesystemProvider: provider, - limit: 10 - }) - if (!initial.ok) { - throw new Error('Expected the original remote transcript') - } - - const replacement = codexMessage('unrelated', 'replacement content') - contents = Buffer.concat([ - replacement, - Buffer.alloc(Math.max(0, initial.nextOffset + extraBytes - replacement.length), 0x20) - ]) - const replaced = await readWorkerTranscript({ - agent: 'codex', - sessionId: 'replacement-session', - transcriptPath, - filesystemProvider: provider, - offset: initial.nextOffset, - expectedSourceFingerprint: initial.sourceFingerprint, - expectedBoundaryCheckpoint: initial.boundaryCheckpoint, - limit: 10 - }) - - expect(replaced).toEqual({ ok: false, reason: 'source_changed', warnings: [] }) - }) - - it('degrades when a remote host cannot prove stable file identity', async () => { - const contents = codexMessage('legacy', 'identity unavailable') - const provider = { - readFile: vi.fn(async () => ({ content: contents.toString('utf8'), isBinary: false })), - readFileRange: vi.fn(), - supportsFileRangeRead: vi.fn(async () => false), - stat: vi.fn(async () => ({ size: contents.length, type: 'file' as const, mtime: 0 })) - } as unknown as IFilesystemProvider - - await expect( - readWorkerTranscript({ - agent: 'codex', - sessionId: 'legacy-no-identity', - transcriptPath: '/remote/legacy-no-identity.jsonl', - filesystemProvider: provider - }) - ).resolves.toEqual({ - ok: false, - reason: 'remote_capability_unavailable', - warnings: [] - }) - }) -}) diff --git a/src/main/runtime/orchestration/worker-transcript-remote-read.ts b/src/main/runtime/orchestration/worker-transcript-remote-read.ts deleted file mode 100644 index 1c2c68b98e0..00000000000 --- a/src/main/runtime/orchestration/worker-transcript-remote-read.ts +++ /dev/null @@ -1,269 +0,0 @@ -import type { NativeChatMessage } from '../../../shared/native-chat-types' -import type { OrchestrationWorkerReadFallbackReason } from '../../../shared/orchestration-worker-output' -import { FileRangeReadUnsupportedError, type IFilesystemProvider } from '../../providers/types' -import { - MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES, - type NativeChatLineDecoder -} from '../../native-chat/transcript-tail-reader' -import { transcriptFallbackId } from '../../native-chat/transcript-fallback-id' -import { sshFileStreamReadCap } from '../../ssh/ssh-file-stream-read-cap' -import { - boundWorkerTranscriptMessages, - clampWorkerTranscriptLimit -} from './worker-transcript-payload' -import { - createWorkerTranscriptBoundaryCheckpoint, - remoteWorkerTranscriptSourceIdentity, - WORKER_TRANSCRIPT_BOUNDARY_CHECKPOINT_BYTES, - workerTranscriptSourceChanged -} from './worker-transcript-source-identity' -import { - readRemoteTranscriptRangedWindow, - supportsRemoteTranscriptRangeRead, - type RemoteTranscriptWindow -} from './worker-transcript-remote-range-read' - -export const MAX_REMOTE_TRANSCRIPT_SCAN_BYTES = 8 * 1024 * 1024 -// The legacy snapshot stays at SSH's established ceiling while parsing only the scan window. -const MAX_LEGACY_REMOTE_TRANSCRIPT_READ_BYTES = sshFileStreamReadCap(false) - -type RemoteReadArgs = { - agent: string - sessionId: string - transcriptPath?: string - offset?: number - limit?: number - expectedBoundaryCheckpoint?: string - filesystemProvider?: IFilesystemProvider -} - -type RemoteReadResult = - | { - ok: true - filePath: string - sourceFingerprint: string - boundaryCheckpoint: string - messages: NativeChatMessage[] - nextOffset: number - limited: boolean - clipping: string[] - warnings: string[] - } - | { - ok: false - reason: OrchestrationWorkerReadFallbackReason | 'source_changed' - warnings: string[] - } - -class RemoteTranscriptIdentityUnavailableError extends Error {} - -export async function readRemoteWorkerTranscript( - args: RemoteReadArgs, - filePath: string, - decode: NativeChatLineDecoder -): Promise { - try { - const window = await readTranscriptWindow(args, filePath) - if (!window) { - return { ok: false, reason: 'source_changed', warnings: [] } - } - return parseTranscriptWindow(args, filePath, decode, window) - } catch (error) { - const code = (error as NodeJS.ErrnoException | null)?.code - return { - ok: false, - reason: code === 'ENOENT' ? 'transcript_missing' : 'remote_capability_unavailable', - warnings: [] - } - } -} - -async function readTranscriptWindow( - args: RemoteReadArgs, - filePath: string -): Promise { - const provider = args.filesystemProvider! - if (await supportsRemoteTranscriptRangeRead(provider)) { - try { - return await readRemoteTranscriptRangedWindow({ - provider, - filePath, - requestedOffset: args.offset, - expectedBoundaryCheckpoint: args.expectedBoundaryCheckpoint, - maxScanBytes: MAX_REMOTE_TRANSCRIPT_SCAN_BYTES - }) - } catch (error) { - // A stale capability answer can race an older relay; degrade once through its bounded snapshot. - if (!(error instanceof FileRangeReadUnsupportedError)) { - throw error - } - } - } - return readLegacyWindow(provider, filePath, args.offset, args.expectedBoundaryCheckpoint) -} - -async function readLegacyWindow( - provider: IFilesystemProvider, - filePath: string, - requestedOffset: number | undefined, - expectedBoundaryCheckpoint: string | undefined -): Promise { - const sourceIdentity = remoteWorkerTranscriptSourceIdentity(await provider.stat(filePath)) - if (!sourceIdentity) { - throw new RemoteTranscriptIdentityUnavailableError( - 'Remote transcript host did not provide stable file identity' - ) - } - const result = await provider.readFile(filePath, { - maxTextBytes: MAX_LEGACY_REMOTE_TRANSCRIPT_READ_BYTES - }) - if (typeof result.content !== 'string') { - throw new Error('Remote transcript read returned invalid content') - } - const allBytes = Buffer.from(result.content, 'utf8') - const fileSize = allBytes.length - const startOffset = requestedOffset ?? Math.max(0, fileSize - MAX_REMOTE_TRANSCRIPT_SCAN_BYTES) - if (startOffset > fileSize) { - return null - } - const scanEnd = Math.min(fileSize, startOffset + MAX_REMOTE_TRANSCRIPT_SCAN_BYTES) - const boundaryStart = Math.max(0, startOffset - WORKER_TRANSCRIPT_BOUNDARY_CHECKPOINT_BYTES) - const boundaryPrefix = allBytes.subarray(boundaryStart, startOffset) - if ( - expectedBoundaryCheckpoint !== undefined && - createWorkerTranscriptBoundaryCheckpoint(boundaryPrefix) !== expectedBoundaryCheckpoint - ) { - return null - } - const after = remoteWorkerTranscriptSourceIdentity(await provider.stat(filePath)) - if (workerTranscriptSourceChanged(sourceIdentity, after, scanEnd)) { - return null - } - return { - bytes: allBytes.subarray(startOffset, scanEnd), - fileSize, - startOffset, - scanEnd, - startsInsideRecord: startOffset > 0 && allBytes[startOffset - 1] !== 0x0a, - boundaryPrefix, - sourceIdentity - } -} - -function parseTranscriptWindow( - args: RemoteReadArgs, - filePath: string, - decode: NativeChatLineDecoder, - window: RemoteTranscriptWindow -): RemoteReadResult { - const limit = clampWorkerTranscriptLimit(args.limit) - const initialRead = args.offset === undefined - const messages: NativeChatMessage[] = [] - const decodedMessages: NativeChatMessage[] = [] - let malformed = 0 - let oversized = 0 - let relativeCursor = 0 - let nextOffset = window.startOffset - if (window.startsInsideRecord) { - const newline = window.bytes.indexOf(0x0a) - if (newline === -1) { - return finish(window.scanEnd < window.fileSize ? window.scanEnd : window.startOffset) - } - relativeCursor = newline + 1 - nextOffset = window.startOffset + relativeCursor - } - while (relativeCursor < window.bytes.length && (initialRead || messages.length < limit)) { - const newline = window.bytes.indexOf(0x0a, relativeCursor) - if (newline === -1) { - if (window.scanEnd < window.fileSize) { - if (window.bytes.length - relativeCursor > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) { - oversized++ - nextOffset = window.scanEnd - } - } - break - } - const lineEnd = newline + 1 - const line = window.bytes - .subarray(relativeCursor, lineEnd) - .toString('utf8') - .replace(/\r?\n$/, '') - if (Buffer.byteLength(line, 'utf8') > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) { - oversized++ - } else if (line) { - try { - JSON.parse(line) - const absoluteLineStart = window.startOffset + relativeCursor - const message = decode(line, transcriptFallbackId(filePath, absoluteLineStart)) - if (message) { - const destination = initialRead ? decodedMessages : messages - destination.push(message) - } - } catch { - malformed++ - } - } - relativeCursor = lineEnd - nextOffset = window.startOffset + relativeCursor - } - if (initialRead) { - messages.push(...decodedMessages.slice(-limit)) - } - return finish(nextOffset) - - function finish(cursor: number): RemoteReadResult { - const bounded = boundWorkerTranscriptMessages(messages, filePath) - const scanLimited = window.startOffset > 0 || window.scanEnd < window.fileSize - const initialTailClipped = initialRead && window.startOffset > 0 - const pageLimited = initialRead - ? scanLimited || decodedMessages.length > limit - : cursor < window.fileSize - return { - ok: true, - filePath, - sourceFingerprint: window.sourceIdentity.fingerprint, - boundaryCheckpoint: boundaryCheckpointAt(window, cursor), - messages: bounded.messages, - nextOffset: cursor, - limited: bounded.limited || pageLimited, - clipping: [ - ...(pageLimited ? ['message_limit_or_scan_window'] : []), - ...(bounded.limited ? ['transcript_payload'] : []) - ], - warnings: [ - ...(malformed > 0 ? [`${malformed} malformed transcript record(s) were skipped.`] : []), - ...(oversized > 0 ? [`${oversized} oversized transcript record(s) were skipped.`] : []), - ...bounded.warnings, - ...(initialTailClipped - ? [ - 'Older transcript records were clipped by the remote scan limit and are not pageable through this EOF cursor; the cursor only follows records appended after this read.' - ] - : scanLimited - ? ['Transcript scanning stopped at the bounded byte limit; continue with the cursor.'] - : []) - ] - } - } -} - -function boundaryCheckpointAt(window: RemoteTranscriptWindow, offset: number): string { - const relativeOffset = offset - window.startOffset - if (relativeOffset >= WORKER_TRANSCRIPT_BOUNDARY_CHECKPOINT_BYTES) { - return createWorkerTranscriptBoundaryCheckpoint( - window.bytes.subarray( - relativeOffset - WORKER_TRANSCRIPT_BOUNDARY_CHECKPOINT_BYTES, - relativeOffset - ) - ) - } - const prefixBytes = Math.min( - window.boundaryPrefix.length, - WORKER_TRANSCRIPT_BOUNDARY_CHECKPOINT_BYTES - relativeOffset - ) - return createWorkerTranscriptBoundaryCheckpoint( - Buffer.concat([ - window.boundaryPrefix.subarray(window.boundaryPrefix.length - prefixBytes), - window.bytes.subarray(0, relativeOffset) - ]) - ) -} diff --git a/src/main/runtime/orchestration/worker-transcript-source-identity.ts b/src/main/runtime/orchestration/worker-transcript-source-identity.ts index 5693c9bcdac..3733891037f 100644 --- a/src/main/runtime/orchestration/worker-transcript-source-identity.ts +++ b/src/main/runtime/orchestration/worker-transcript-source-identity.ts @@ -1,6 +1,5 @@ import { createHash } from 'node:crypto' import type { BigIntStats } from 'node:fs' -import type { FileStat } from '../../providers/types' export type WorkerTranscriptSourceIdentity = { fingerprint: string @@ -8,7 +7,7 @@ export type WorkerTranscriptSourceIdentity = { mtimeMs: number } -export const WORKER_TRANSCRIPT_BOUNDARY_CHECKPOINT_BYTES = 64 +const WORKER_TRANSCRIPT_BOUNDARY_CHECKPOINT_BYTES = 64 export function createWorkerTranscriptBoundaryCheckpoint(bytes: Uint8Array): string { return createHash('sha256') @@ -40,24 +39,6 @@ export function localWorkerTranscriptSourceIdentity( ) } -export function remoteWorkerTranscriptSourceIdentity( - stats: FileStat -): WorkerTranscriptSourceIdentity | null { - const mtimeMs = stats.mtimeMs ?? stats.mtime - if ( - stats.type !== 'file' || - !Number.isSafeInteger(stats.size) || - stats.size < 0 || - !Number.isSafeInteger(stats.dev) || - !Number.isSafeInteger(stats.ino) || - ((stats.dev ?? 0) === 0 && (stats.ino ?? 0) === 0) || - !Number.isFinite(mtimeMs) - ) { - return null - } - return createIdentity(String(stats.dev), String(stats.ino), stats.size, mtimeMs) -} - export function workerTranscriptSourceChanged( before: WorkerTranscriptSourceIdentity, after: WorkerTranscriptSourceIdentity | null, diff --git a/src/main/runtime/project-host-setup-delete-guard.test.ts b/src/main/runtime/project-host-setup-delete-guard.test.ts index 37c2e5d7368..fcb6e42eebe 100644 --- a/src/main/runtime/project-host-setup-delete-guard.test.ts +++ b/src/main/runtime/project-host-setup-delete-guard.test.ts @@ -46,7 +46,6 @@ function makeController(options: { getStore: () => store as never, listRepos: () => [targetRepo], addRepo: vi.fn(), - addRemoteRepo: vi.fn(), cloneRepo: vi.fn(), invalidateResolvedWorktrees: vi.fn(), invalidateWorktreeScan: vi.fn(), diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index b7c3506d1f9..4bf7c1d0e7d 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -11,7 +11,8 @@ import { authenticateMobileE2EE, decodeMobileE2EEPublicKey, describeMobileE2EEAuthFailure, - type DeviceKeyResolution + type DeviceKeyResolution, + type DeviceTokenRefusal } from './mobile-e2ee-auth-validation' import { isMobileE2EEOutboundItemWithinLimit } from './mobile-e2ee-outbound-admission' import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-request-frames' @@ -36,7 +37,7 @@ const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5 export type E2EEChannelOptions = { serverSecretKey: Uint8Array - resolveAuthenticatedDevice: (token: string) => E2EEAuthenticatedDevice | null + resolveAuthenticatedDevice: (token: string) => E2EEAuthenticatedDevice | DeviceTokenRefusal | null // Absent: key auth is refused as bad_auth, the same as before device keys existed. resolveAuthenticatedDeviceKey?: (deviceId: string) => DeviceKeyResolution onReady: (channel: E2EEChannel, device: E2EEAuthenticatedDevice) => void diff --git a/src/main/runtime/rpc/methods/native-chat-ssh-transcript.test.ts b/src/main/runtime/rpc/methods/native-chat-ssh-transcript.test.ts index 996a905e04b..e4ae0dba48a 100644 --- a/src/main/runtime/rpc/methods/native-chat-ssh-transcript.test.ts +++ b/src/main/runtime/rpc/methods/native-chat-ssh-transcript.test.ts @@ -1,16 +1,9 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' import type { NativeChatMessage } from '../../../../shared/native-chat-types' -import { mergeNativeChatMessages } from '../../../../shared/native-chat-merge' -import { foldToolMessages, pairToolBlocks } from '../../../../shared/native-chat-tool-fold' -import type { IFilesystemProvider } from '../../../providers/types' -import { - registerSshFilesystemProvider, - unregisterSshFilesystemProvider -} from '../../../providers/ssh-filesystem-dispatch' import { eraseRpcMethods, type RpcContext } from '../core' import { NATIVE_CHAT_METHODS } from './native-chat' @@ -34,36 +27,6 @@ function claudeLines(...texts: string[]): string { .join('') } -/** An SSH host's filesystem as the relay serves it: stat plus positional reads. */ -function sshHost(files: Map): IFilesystemProvider { - const bytes = (path: string): Buffer => { - const body = files.get(path) - if (body === undefined) { - // As the relay delivers it: the multiplexer swaps Node's 'ENOENT' code for its transport code. - const error = new Error(`ENOENT: no such file or directory, lstat '${path}'`) - Object.defineProperty(error, 'code', { value: -32000 }) - throw error - } - return Buffer.from(body) - } - const provider: Pick = { - stat: async (path) => ({ - size: bytes(path).length, - type: 'file', - mtime: 0, - mtimeMs: bytes(path).length, - dev: 1, - ino: 1 - }), - readFileRange: async (path, position, length) => { - const slice = bytes(path).subarray(position, position + length) - return { bytes: slice, bytesRead: slice.length } - } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: transcript reads use only stat and readFileRange. - return provider as IFilesystemProvider -} - function sshHookRow(transcriptPath: string): Partial { return { paneKey: 'tab-1:leaf-1', @@ -138,240 +101,10 @@ describe('native chat for an agent whose transcript lives on an SSH host (#26057 }) afterEach(() => { - unregisterSshFilesystemProvider(CONNECTION_ID) rmSync(localDir, { recursive: true, force: true }) }) - it('reads the history from the SSH host the hook store attests the session to', async () => { - registerSshFilesystemProvider( - CONNECTION_ID, - sshHost(new Map([[transcriptPath, claudeLines('first remote turn', 'second remote turn')]])) - ) - - const page = await readSession(transcriptPath) - - expect(page.error).toBeUndefined() - expect(page.texts).toEqual(['first remote turn', 'second remote turn']) - }) - - it('publishes raw provider order to old peers without private folded-result markers', async () => { - const records = [ - { - type: 'assistant', - uuid: 'a', - message: { - role: 'assistant', - content: [{ type: 'tool_use', id: 'a', name: 'Bash', input: { command: 'a' } }] - } - }, - { - type: 'user', - uuid: 'old-x', - message: { - role: 'user', - content: [{ type: 'tool_result', tool_use_id: 'x', content: 'OLD X' }] - } - }, - { - type: 'assistant', - uuid: 'x', - message: { - role: 'assistant', - content: [{ type: 'tool_use', id: 'x', name: 'Bash', input: { command: 'x' } }] - } - } - ] - registerSshFilesystemProvider( - CONNECTION_ID, - sshHost( - new Map([ - [transcriptPath, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`] - ]) - ) - ) - const read = method('nativeChat.readSession') - if ('stream' in read) { - throw new Error('nativeChat.readSession is a stream') - } - const page = await read.handler( - { agent: 'claude', sessionId: SESSION_ID, transcriptPath }, - phoneContext({}) - ) - if ( - !page || - typeof page !== 'object' || - !('messages' in page) || - !Array.isArray(page.messages) - ) { - throw new Error('nativeChat.readSession did not publish messages') - } - const published: NativeChatMessage[] = page.messages - expect(published.map((message) => message.id)).toEqual(['a', 'old-x', 'x']) - expect(published.every((message) => !Object.hasOwn(message, 'unpairedToolResults'))).toBe(true) - const oldPeerRoundTrip: NativeChatMessage[] = JSON.parse(JSON.stringify(published)) - const projected = foldToolMessages(oldPeerRoundTrip) - expect(pairToolBlocks(projected[0]!.blocks).every((pair) => pair.result === undefined)).toBe( - true - ) - expect(projected[1]).toMatchObject({ id: 'old-x', role: 'tool', blocks: [{ output: 'OLD X' }] }) - expect(foldToolMessages(projected)).toEqual(projected) - }) - - it('keeps remote page cursors and reattaches several outputs to their earlier named calls', async () => { - const records = [ - { - type: 'user', - uuid: 'prompt', - message: { role: 'user', content: [{ type: 'text', text: 'remote question' }] } - }, - ...['a', 'b'].map((id) => ({ - type: 'assistant', - uuid: `call-${id}`, - message: { - role: 'assistant', - content: [{ type: 'tool_use', id, name: 'Bash', input: { command: id } }] - } - })), - { - type: 'assistant', - uuid: 'backlog', - message: { - role: 'assistant', - content: Array.from({ length: 64 }, (_, index) => ({ - type: 'tool_use', - id: `silent-${index}`, - name: 'Bash', - input: { command: `silent ${index}` } - })) - } - }, - { - type: 'user', - uuid: 'outputs', - message: { - role: 'user', - content: [ - { type: 'tool_result', tool_use_id: 'a', content: 'output A' }, - { type: 'tool_result', tool_use_id: 'b', content: 'output B', is_error: true } - ] - } - } - ] - const lines = records.map((record) => `${JSON.stringify(record)}\n`) - const cursor = Buffer.byteLength(lines.slice(0, 3).join('')) - registerSshFilesystemProvider( - CONNECTION_ID, - sshHost(new Map([[transcriptPath, lines.join('')]])) - ) - const read = method('nativeChat.readSession') - if ('stream' in read) { - throw new Error('nativeChat.readSession is a stream') - } - const readPage = read.handler - async function page(limit: number, beforeOffset?: number) { - const result = await readPage( - { agent: 'claude', sessionId: SESSION_ID, transcriptPath, limit, beforeOffset }, - phoneContext({}) - ) - if ( - !result || - typeof result !== 'object' || - !('messages' in result) || - !Array.isArray(result.messages) || - !('beforeOffset' in result) || - !('hasMore' in result) - ) { - throw new Error('nativeChat.readSession did not publish a page') - } - const messages: NativeChatMessage[] = result.messages - return { messages, beforeOffset: result.beforeOffset, hasMore: result.hasMore } - } - const tail = await page(2) - expect(tail.beforeOffset).toBe(cursor) - expect(tail.hasMore).toBe(true) - expect(tail.messages.map((row) => row.id)).toEqual(['backlog', 'outputs']) - expect(tail.messages.every((row) => !Object.hasOwn(row, 'unpairedToolResults'))).toBe(true) - const partial = foldToolMessages(tail.messages) - expect(partial[1]).toMatchObject({ - id: 'outputs', - role: 'tool', - blocks: [{ output: 'output A' }, { output: 'output B', isError: true }] - }) - expect(pairToolBlocks(partial[0]!.blocks).every((pair) => pair.result === undefined)).toBe(true) - expect(foldToolMessages(partial)).toEqual(partial) - - const older = await page(100, cursor) - expect(older.beforeOffset).toBe(0) - expect(older.hasMore).toBe(false) - expect(older.messages.map((row) => row.id)).toEqual(['prompt', 'call-a', 'call-b']) - const raw = mergeNativeChatMessages(older.messages, tail.messages) - expect(raw.every((row) => !Object.hasOwn(row, 'unpairedToolResults'))).toBe(true) - const whole = foldToolMessages(raw) - expect(whole.some((row) => row.role === 'tool')).toBe(false) - expect( - whole.flatMap((row) => - pairToolBlocks(row.blocks).flatMap((pair) => - pair.result ? [[pair.call?.callId, pair.result.output]] : [] - ) - ) - ).toEqual([ - ['a', 'output A'], - ['b', 'output B'] - ]) - expect(foldToolMessages(whole)).toEqual(whole) - expect(tail.beforeOffset).toBe(cursor) - }) - - it('streams the SSH host history and its later turns to a subscribed phone', async () => { - const files = new Map([[transcriptPath, claudeLines('remote history')]]) - registerSshFilesystemProvider(CONNECTION_ID, sshHost(files)) - const frames: unknown[] = [] - let cleanup = (): void => {} - const subscribe = method('nativeChat.subscribe') - if (!('stream' in subscribe)) { - throw new Error('nativeChat.subscribe is not a stream') - } - - await subscribe.handler( - { agent: 'claude', sessionId: SESSION_ID, transcriptPath, subscriptionId: 'sub-1' }, - phoneContext({ - registerSubscriptionCleanup: (_id, fn) => { - cleanup = fn - }, - cleanupSubscription: () => cleanup() - }), - (frame) => frames.push(frame) - ) - try { - await vi.waitFor(() => expect(texts(frames[0])).toEqual(['remote history'])) - files.set(transcriptPath, claudeLines('remote history', 'remote follow-up')) - await vi.waitFor( - () => - expect( - frames - .filter( - (frame) => - frame && typeof frame === 'object' && 'type' in frame && frame.type === 'appended' - ) - .flatMap((frame) => texts(frame)) - ).toEqual(['remote follow-up']), - { timeout: 5_000 } - ) - } finally { - cleanup() - } - }) - - it('reads a transcript the SSH host has not written yet as not found, so the chat keeps waiting', async () => { - registerSshFilesystemProvider(CONNECTION_ID, sshHost(new Map())) - - const page = await readSession(transcriptPath) - - expect(page.texts).toEqual([]) - expect(page.notFound).toBe(true) - }) - - it('never answers from this machine while the SSH host is disconnected', async () => { + it('never answers from this machine for an SSH-hosted transcript', async () => { const page = await readSession(transcriptPath) expect(page.texts).toEqual([]) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-output.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-output.test.ts index bad08eba258..c3f0c1e9266 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-output.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-output.test.ts @@ -3,7 +3,6 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../../../orca-runtime' -import * as sshFilesystemDispatch from '../../../../../providers/ssh-filesystem-dispatch' import { readExactWorkerOutput } from './worker-output' function codexMessage(id: string, text: string): string { @@ -20,7 +19,6 @@ describe('exact orchestration worker output', () => { let providerSession: ReturnType let runtime: OrcaRuntimeService const readTerminal = vi.fn() - let sshProviderLookup: { mockRestore: () => void } beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-worker-output-')) @@ -47,7 +45,6 @@ describe('exact orchestration worker output', () => { truncated: false, nextCursor: '9' }) - sshProviderLookup = vi.spyOn(sshFilesystemDispatch, 'getSshFilesystemProvider') runtime = { getExactWorkerProviderSession: vi.fn(() => providerSession), getTerminalProcessIncarnation: vi.fn(() => 'pty:incarnation-1'), @@ -57,7 +54,6 @@ describe('exact orchestration worker output', () => { }) afterEach(async () => { - sshProviderLookup.mockRestore() await rm(directory, { recursive: true, force: true }) }) @@ -134,7 +130,6 @@ describe('exact orchestration worker output', () => { messages: [{ id: 'a', blocks: [{ type: 'text', text: 'worker A only' }] }] } }) - expect(sshProviderLookup).not.toHaveBeenCalled() }) it('falls back safely when a WSL session lacks an attested distro', async () => { @@ -167,7 +162,7 @@ describe('exact orchestration worker output', () => { expect(result.cursor).toMatch(/^owr1_/) }) - it('keeps SSH transcript reads behind the remote filesystem capability', async () => { + it('falls back to the terminal for an SSH transcript', async () => { providerSession = { ...providerSession!, connectionId: 'ssh-target' @@ -179,7 +174,6 @@ describe('exact orchestration worker output', () => { source: 'terminal', fallbackReason: 'remote_capability_unavailable' }) - expect(sshProviderLookup).toHaveBeenCalledWith('ssh-target') }) it('reads Grok through the shared Native Chat transcript decoder', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-output.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-output.ts index 99555843791..516b4e9608f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-output.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-output.ts @@ -13,7 +13,6 @@ import { } from '../../../../orchestration/worker-output-cursor' import { redactWorkerTerminalLines } from '../../../../orchestration/worker-transcript-payload' import { readWorkerTranscript } from '../../../../orchestration/worker-transcript-read' -import { getSshFilesystemProvider } from '../../../../../providers/ssh-filesystem-dispatch' import { isWslHookRelayConnectionId } from '../../../../../../shared/wsl-hook-relay-contract' export async function readExactWorkerOutput(args: { @@ -48,11 +47,8 @@ export async function readExactWorkerOutput(args: { if (isWslSession && !session.wslDistro) { return fallbackOrThrow(args, 'remote_capability_unavailable') } - const remoteFilesystemProvider = - session.connectionId && !isWslSession - ? getSshFilesystemProvider(session.connectionId) - : undefined - if (session.connectionId && !isWslSession && !remoteFilesystemProvider) { + // Why: this process serves no SSH filesystem, so an SSH worker's transcript is unreachable. + if (session.connectionId && !isWslSession) { return fallbackOrThrow(args, 'remote_capability_unavailable') } if (cursor?.source === 'transcript' && !cursor.boundaryCheckpoint) { @@ -66,8 +62,7 @@ export async function readExactWorkerOutput(args: { offset: cursor?.source === 'transcript' ? cursor.position : undefined, expectedBoundaryCheckpoint: cursor?.source === 'transcript' ? (cursor.boundaryCheckpoint ?? undefined) : undefined, - limit: args.limit, - filesystemProvider: remoteFilesystemProvider + limit: args.limit }) if (!transcript.ok) { if (transcript.reason === 'source_changed') { diff --git a/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts b/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts index 18e15fa6281..cb2c4b2bb28 100644 --- a/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts +++ b/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts @@ -2,6 +2,7 @@ import type { DesktopMobileE2EEV2Session } from './mobile-e2ee-v2-desktop-sessio import { publicKeyFromBase64 } from './e2ee-crypto' import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-request-frames' import { isDeviceProofB64, verifyDeviceProof } from '../../../shared/device-key-proof' +import { INSECURE_WEB_ORIGIN_AUTH_CODE } from '../../../shared/web-origin-security' export type ClassifiedE2EEAuth = | { kind: 'token'; deviceToken: string; clientCapabilities?: unknown } @@ -80,6 +81,10 @@ export type MobileE2EEAuthFailureCode = | 'device_revoked' | 'registry_unavailable' | 'bad_proof' + | typeof INSECURE_WEB_ORIGIN_AUTH_CODE + +// The token matched, but this transport may not claim it (e.g. a first pairing from an insecure page). +export type DeviceTokenRefusal = { refused: typeof INSECURE_WEB_ORIGIN_AUTH_CODE } export type DeviceKeyResolution = | { kind: 'found'; device: TDevice; devicePublicKeyB64: string } @@ -92,7 +97,7 @@ export type DeviceKeyResolution = export function authenticateMobileE2EE(args: { plaintext: string v2Session: DesktopMobileE2EEV2Session | null - resolveDevice: (token: string) => TDevice | null + resolveDevice: (token: string) => TDevice | DeviceTokenRefusal | null // Absent on a host that does not accept device keys: key auth is then bad_auth, as before. resolveDeviceKey?: (deviceId: string) => DeviceKeyResolution }): @@ -101,6 +106,9 @@ export function authenticateMobileE2EE const auth = classifyE2EEAuth(args.plaintext, args.v2Session) if (auth.kind === 'token') { const device = args.resolveDevice(auth.deviceToken) + if (device && 'refused' in device) { + return { ok: false, code: device.refused } + } // Why: a keyed row has no token, so presenting one can never match it. return device?.deviceToken === auth.deviceToken ? { ok: true, device, clientCapabilities: auth.clientCapabilities } @@ -144,7 +152,9 @@ export function describeMobileE2EEAuthFailure(failure: { ? 'Unauthorized' : failure.code === 'registry_unavailable' ? 'Device registry unavailable' - : 'Invalid e2ee_auth' + : failure.code === INSECURE_WEB_ORIGIN_AUTH_CODE + ? 'Insecure web origin' + : 'Invalid e2ee_auth' return { sealedError: { type: 'e2ee_error', diff --git a/src/main/runtime/rpc/mobile-socket-wiring.ts b/src/main/runtime/rpc/mobile-socket-wiring.ts index 009a35c9363..4305a73c89b 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.ts @@ -1,6 +1,12 @@ import { randomBytes } from 'node:crypto' import type { WebSocket } from 'ws' import type { DeviceEntry, DeviceRegistry } from '../device-registry' +import { isPendingTokenRow } from '../device-entry' +import { EPHEMERAL_DEVICE_SWEEP_INTERVAL_MS } from '../ephemeral-device-rows' +import { + INSECURE_WEB_ORIGIN_AUTH_CODE, + isInsecureRemoteWebOrigin +} from '../../../shared/web-origin-security' import { devicePrincipalOf } from '../device-principal' import type { E2EEKeypair } from '../e2ee-keypair' import { E2EEChannel, type E2EEAuthenticatedDevice } from './e2ee-channel' @@ -11,7 +17,8 @@ import type { RpcBinarySender } from './rpc-binary-sender' type MobileSocketPayload = string | Uint8Array export type MobileSocketTransportMetadata = - | { transport: 'direct' } + // webClient: the Orca web bundle marked its upgrade; webOrigin: that page's origin. + | { transport: 'direct'; webOrigin?: string; webClient?: true } | { transport: 'relay' relayHostId: string @@ -59,6 +66,8 @@ type MobileSocketWiringOptions = { onReady?: (socket: AuthenticatedMobileSocket) => void // Why: stale keys and missing registry entries both fail before RPC can explain the re-pair action. onUnpairedDeviceAuthFailure?: (metadata: MobileSocketTransportMetadata) => void + // Operator opt-in for first pairings from a plain-http page on another machine. + allowInsecureWebPairing?: () => boolean } function toAuthenticatedDevice(device: DeviceEntry): E2EEAuthenticatedDevice { @@ -76,6 +85,8 @@ export class MobileSocketWiring { private readonly onClose: MobileSocketWiringOptions['onClose'] private readonly onReady: MobileSocketWiringOptions['onReady'] private readonly onUnpairedDeviceAuthFailure: MobileSocketWiringOptions['onUnpairedDeviceAuthFailure'] + private readonly allowInsecureWebPairing: () => boolean + private readonly ephemeralSweep: ReturnType private readonly channels = new Map() private readonly connectionIds = new Map() private readonly authenticatedSockets = new Map() @@ -90,6 +101,39 @@ export class MobileSocketWiring { this.onClose = options.onClose this.onReady = options.onReady this.onUnpairedDeviceAuthFailure = options.onUnpairedDeviceAuthFailure + this.allowInsecureWebPairing = options.allowInsecureWebPairing ?? (() => false) + this.ephemeralSweep = setInterval( + () => this.sweepIdleEphemeralDevices(Date.now()), + EPHEMERAL_DEVICE_SWEEP_INTERVAL_MS + ) + // Why: bookkeeping must never hold the process open. + this.ephemeralSweep.unref?.() + } + + dispose(): void { + clearInterval(this.ephemeralSweep) + } + + /** Removes idle session-only browser rows and cuts any socket still bound to them. */ + sweepIdleEphemeralDevices(now: number): string[] { + let removed: string[] = [] + try { + removed = this.deviceRegistry.pruneIdleEphemeralDevices(now, (deviceId) => + this.hasAuthenticatedSocket(deviceId) + ) + } catch (error) { + console.error('[mobile] Failed to prune idle browser-session devices:', error) + } + for (const deviceId of removed) { + this.terminateDeviceConnections(deviceId) + } + return removed + } + + private hasAuthenticatedSocket(deviceId: string): boolean { + return Array.from(this.authenticatedSockets.values()).some( + (socket) => socket.device.deviceId === deviceId + ) } attachTransport( @@ -156,6 +200,16 @@ export class MobileSocketWiring { if (!device) { return null } + if ( + isPendingTokenRow(device) && + metadata.transport === 'direct' && + // Why the marker too: phones send an http Origin as well, and must never be gated. + metadata.webClient === true && + isInsecureRemoteWebOrigin(metadata.webOrigin) && + !this.allowInsecureWebPairing() + ) { + return { refused: INSECURE_WEB_ORIGIN_AUTH_CODE } + } // Why: outer relay authorization cannot choose the local Orca // identity; E2EE must resolve the same device before readiness. if (metadata.transport === 'relay' && metadata.relayDeviceId !== device.deviceId) { @@ -251,6 +305,10 @@ export class MobileSocketWiring { Array.from(this.authenticatedSockets.values()).some( (candidate) => candidate.device.deviceId === socket.device.deviceId ) + // Why: a session-only row idles from its last disconnect, not from when it connected. + if (socket && !hasOtherConnections && socket.device.deviceToken === undefined) { + this.deviceRegistry.updateLastSeenDeferred(socket.device.deviceId) + } this.onClose(socket, hasOtherConnections) } } diff --git a/src/main/runtime/rpc/web-session-pairing.test.ts b/src/main/runtime/rpc/web-session-pairing.test.ts new file mode 100644 index 00000000000..0965e4971f3 --- /dev/null +++ b/src/main/runtime/rpc/web-session-pairing.test.ts @@ -0,0 +1,239 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import nacl from 'tweetnacl' +import type { WebSocket } from 'ws' +import { describe, expect, it, vi } from 'vitest' +import { encodeDeviceProofMessage } from '../../../shared/device-key-proof' +import { DeviceRegistry } from '../device-registry' +import { EPHEMERAL_DEVICE_IDLE_MS } from '../ephemeral-device-rows' +import { SimulatedMobileE2EEV2Peer } from '../relay/simulated-mobile-e2ee-v2-peer' +import { registerSocketDeviceKey } from '../runtime-rpc/runtime-rpc-device-key-registration' +import { generateKeyPair } from './e2ee-crypto' +import { + MobileSocketWiring, + type AuthenticatedMobileSocket, + type MobileSocketTransport, + type MobileSocketTransportMetadata +} from './mobile-socket-wiring' + +class FakeSocket { + readonly OPEN = 1 + readyState = this.OPEN + bufferedAmount = 0 + readonly sent: (string | Buffer)[] = [] + readonly send = vi.fn((data: string | Buffer) => this.sent.push(data)) + readonly close = vi.fn() +} + +class FakeTransport implements MobileSocketTransport { + private messageHandler: Parameters[0] | null = null + readonly setClientId = vi.fn() + readonly terminateClientConnections = vi.fn(() => 0) + onMessage(handler: Parameters[0]): void { + this.messageHandler = handler + } + onConnectionClose(): void {} + receive(ws: FakeSocket, message: string): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wiring only sends, closes and reads OPEN/readyState/bufferedAmount. + this.messageHandler?.(message, vi.fn(), ws as unknown as WebSocket) + } +} + +const desktop = generateKeyPair() +const LAN_PAGE = 'http://192.168.1.20:6768' +const RELAY_HOST_ID = 'AbCdEf0123_-xyZ9' + +// The new web bundle marks its upgrade; phones (React Native) send an http Origin but no marker. +const WEB_CLIENT = { webOrigin: LAN_PAGE, webClient: true as const } + +function harness( + page: { webOrigin?: string; webClient?: true } | undefined, + allowInsecureWebPairing = false, + relay?: (deviceId: string) => MobileSocketTransportMetadata +) { + const registry = new DeviceRegistry(mkdtempSync(join(tmpdir(), 'orca-web-session-'))) + const transport = new FakeTransport() + const ready: AuthenticatedMobileSocket[] = [] + const wiring = new MobileSocketWiring({ + deviceRegistry: registry, + e2eeKeypair: { + publicKey: desktop.publicKey, + secretKey: desktop.secretKey, + publicKeyB64: Buffer.from(desktop.publicKey).toString('base64') + }, + onText: vi.fn(), + onBinary: vi.fn(), + onClose: vi.fn(), + onReady: (socket) => ready.push(socket), + allowInsecureWebPairing: () => allowInsecureWebPairing + }) + let relayDeviceId = '' + wiring.attachTransport(transport, () => + relay ? relay(relayDeviceId) : { transport: 'direct', ...page } + ) + + function connect(auth: (transcriptHashB64: string) => Record) { + const ws = new FakeSocket() + const peer = new SimulatedMobileE2EEV2Peer( + nacl.box.keyPair(), + desktop.publicKey, + relay ? RELAY_HOST_ID : null + ) + transport.receive(ws, JSON.stringify(peer.hello)) + expect(peer.acceptReady(JSON.parse(String(ws.sent[0])))).toBe(true) + const transcriptHashB64 = peer.transcriptHashB64 + const before = ready.length + transport.receive( + ws, + peer.sealText( + JSON.stringify({ type: 'e2ee_auth', v: 2, transcriptHashB64, ...auth(transcriptHashB64) }) + ) + ) + const reply = ws.sent[1] ? JSON.parse(peer.openText(String(ws.sent[1]))!) : null + return { ws, transcriptHashB64, reply, socket: ready.length > before ? ready.at(-1) : null } + } + return { + registry, + wiring, + transport, + connect, + bindRelay: (deviceId: string) => (relayDeviceId = deviceId) + } +} + +function sign(key: nacl.SignKeyPair, purpose: 'auth' | 'register', transcriptHashB64: string) { + const message = encodeDeviceProofMessage(purpose, Buffer.from(transcriptHashB64, 'base64')) + return Buffer.from(nacl.sign.detached(message, key.secretKey)).toString('base64') +} + +describe('pairing from a plain-http page on another machine', () => { + it('refuses a first pairing without operator opt-in, with a sealed reason', () => { + const { registry, connect } = harness(WEB_CLIENT) + const pending = registry.getOrCreatePendingDevice('Browser', 'runtime') + const attempt = connect(() => ({ deviceToken: pending.token })) + expect(attempt.reply).toEqual({ type: 'e2ee_error', error: { code: 'insecure_origin' } }) + expect(attempt.socket).toBeNull() + expect(attempt.ws.close).toHaveBeenCalledWith(4001, 'Insecure web origin') + expect(registry.getDevice(pending.deviceId)?.lastSeenAt).toBe(0) + }) + + it('pairs when the operator opted in', () => { + const { registry, connect } = harness(WEB_CLIENT, true) + const pending = registry.getOrCreatePendingDevice('Browser', 'runtime') + expect(connect(() => ({ deviceToken: pending.token })).reply).toMatchObject({ + type: 'e2ee_authenticated' + }) + }) + + it('never cuts a browser that was already paired', () => { + const { registry, connect } = harness(WEB_CLIENT) + const paired = registry.addDevice('Browser', 'runtime') + registry.updateLastSeen(paired.deviceId) + expect(connect(() => ({ deviceToken: paired.token })).reply).toMatchObject({ + type: 'e2ee_authenticated' + }) + }) + + it('lets https, loopback and native clients pair as before', () => { + for (const page of [ + { webOrigin: 'https://desk.example.com', webClient: true as const }, + { webOrigin: 'http://localhost:6768', webClient: true as const }, + undefined + ]) { + const { registry, connect } = harness(page) + const pending = registry.getOrCreatePendingDevice('Client', 'runtime') + expect(connect(() => ({ deviceToken: pending.token })).reply).toMatchObject({ + type: 'e2ee_authenticated' + }) + } + }) +}) + +describe('phones over direct LAN are never gated by their Origin', () => { + it('pairs a released phone whose socket sends an http LAN Origin but no web marker', () => { + for (const scope of ['mobile', 'runtime'] as const) { + const { registry, connect } = harness({ webOrigin: LAN_PAGE }) + const pending = registry.getOrCreatePendingDevice('Phone', scope) + expect(connect(() => ({ deviceToken: pending.token })).reply).toMatchObject({ + type: 'e2ee_authenticated' + }) + } + }) +}) + +describe('relayed phones are never gated', () => { + it('pairs over Relay whatever the page marker says', () => { + const { registry, connect, bindRelay } = harness(WEB_CLIENT, false, (relayDeviceId) => ({ + transport: 'relay', + relayHostId: RELAY_HOST_ID, + relayDeviceId, + basisConnId: 'conn', + credentialKind: 'invite' + })) + const pending = registry.getOrCreatePendingDevice('Phone', 'mobile') + bindRelay(pending.deviceId) + expect(connect(() => ({ deviceToken: pending.token })).reply).toMatchObject({ + type: 'e2ee_authenticated' + }) + }) +}) + +describe('session-only browser keys', () => { + it('registers an ephemeral row, sweeps it once idle and disconnected, and then revokes it', () => { + const { registry, wiring, transport, connect } = harness({ + webOrigin: 'https://desk.example.com', + webClient: true as const + }) + const paired = registry.addDevice('Browser', 'runtime') + registry.updateLastSeen(paired.deviceId) + const migrating = connect(() => ({ deviceToken: paired.token })) + const key = nacl.sign.keyPair() + const fork = registerSocketDeviceKey(registry, migrating.socket!, { + devicePublicKeyB64: Buffer.from(key.publicKey).toString('base64'), + deviceProofB64: sign(key, 'register', migrating.transcriptHashB64), + ephemeral: true + }) + expect(registry.getDevice(fork.deviceId)?.ephemeral).toBe(true) + + const keyed = connect((hash) => ({ + deviceId: fork.deviceId, + deviceProofB64: sign(key, 'auth', hash) + })) + expect(keyed.reply).toMatchObject({ type: 'e2ee_authenticated' }) + const idle = registry.getDevice(fork.deviceId)!.lastSeenAt + EPHEMERAL_DEVICE_IDLE_MS + 1 + // A live socket keeps the row however long the tab stays open. + expect(wiring.sweepIdleEphemeralDevices(idle)).toEqual([]) + + transport.receive(keyed.ws, 'ignored') + wiring.dispose() + expect(registry.pruneIdleEphemeralDevices(idle, () => false)).toEqual([fork.deviceId]) + const after = connect((hash) => ({ + deviceId: fork.deviceId, + deviceProofB64: sign(key, 'auth', hash) + })) + expect(after.reply).toEqual({ + type: 'e2ee_error', + error: { code: 'device_revoked', deviceId: fork.deviceId } + }) + }) + + it('cuts sockets bound to a swept row', () => { + const { registry, wiring, transport } = harness(undefined) + const paired = registry.addDevice('Browser', 'runtime') + registry.updateLastSeen(paired.deviceId) + const key = nacl.sign.keyPair() + const fork = registry.forkDeviceKey( + paired.deviceId, + Buffer.from(key.publicKey).toString('base64'), + 'Tab', + { ephemeral: true } + ) + expect( + wiring.sweepIdleEphemeralDevices(fork.lastSeenAt + EPHEMERAL_DEVICE_IDLE_MS + 1) + ).toEqual([fork.deviceId]) + expect(transport.terminateClientConnections).toHaveBeenCalledWith(fork.deviceId) + expect(registry.getDevice(paired.deviceId)).not.toBeNull() + wiring.dispose() + }) +}) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 29a6aa4b854..06d943faddf 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -4,6 +4,7 @@ import { createServer as createHttpServer, type Server as HttpServer } from 'nod import { WebSocketServer, type WebSocket } from 'ws' import type { RpcTransport } from './transport' import { createStaticWebClientHandler } from './static-web-client-handler' +import { hasWebClientUpgradeMarker } from '../../../shared/web-origin-security' import { attachNodeWebSocketLifecycle, clearNodeWebSocketPreAuthTimer, @@ -59,6 +60,7 @@ export class WebSocketTransport implements RpcTransport { private readonly fallbackLadder: readonly number[] private persistedFallbackBindFailed = false private httpServer: HttpsServer | HttpServer | null = null + private readonly webPages = new WeakMap() private wss: WebSocketServer | null = null private messageHandler: WebSocketMessageHandler | null = null private connectionCloseHandler: WebSocketConnectionCloseHandler | null = null @@ -110,6 +112,11 @@ export class WebSocketTransport implements RpcTransport { this.connectionCloseHandler = handler } + /** The Orca web bundle that opened `ws` (it marks its upgrade), with its page origin. */ + webPageOf(ws: WebSocket): { webOrigin?: string; webClient: true } | undefined { + return this.webPages.get(ws) + } + setClientId(ws: WebSocket, clientId: string): void { this.wsClientIds.set(ws, clientId) clearNodeWebSocketPreAuthTimer(ws, this.preAuthTimers) @@ -213,11 +220,18 @@ export class WebSocketTransport implements RpcTransport { maxPayload: WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES }) - wss.on('connection', (ws) => { + wss.on('connection', (ws, request) => { if (wss.clients.size > WEBSOCKET_TRANSPORT_MAX_CONNECTIONS) { rejectNodeWebSocketOverCapacity(ws) return } + if (hasWebClientUpgradeMarker(request.url)) { + const origin = request.headers.origin + this.webPages.set(ws, { + ...(typeof origin === 'string' ? { webOrigin: origin } : {}), + webClient: true + }) + } this.handleConnection(ws) }) diff --git a/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts b/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts index e6168093595..04d540b5db9 100644 --- a/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts +++ b/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts @@ -1,7 +1,5 @@ // @ts-nocheck -- mechanically split declarations. -import type { FileReadLimits, IFilesystemProvider } from '../providers/types' import type { RuntimeFilePreviewResult } from '../../shared/runtime-types' -import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' import { REMOTE_RPC_MAX_CONTENT_BYTES, remoteRpcResultExceedsContentBudget @@ -43,21 +41,6 @@ export function previewableBinaryByteLimit(maxContentBytes: number): number { return Math.floor(base64Bytes / 4) * 3 } -export async function readPreviewFileWithinCap( - provider: IFilesystemProvider, - filePath: string, - limits: FileReadLimits -): Promise { - try { - return await provider.readFile(filePath, limits) - } catch (error) { - if (error instanceof FileReadCapExceededError) { - throw new Error('file_too_large') - } - throw error - } -} - export function assertPreviewWithinTransportBudget( result: RuntimeFilePreviewResult, maxContentBytes: number | undefined diff --git a/src/main/runtime/runtime-file-search.test.ts b/src/main/runtime/runtime-file-search.test.ts index ef3b8e522ae..203da0ba189 100644 --- a/src/main/runtime/runtime-file-search.test.ts +++ b/src/main/runtime/runtime-file-search.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { ExecutionHostNotDispatchableError } from '../providers/execution-host-provider-dispatch' -import { SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch' +import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error' import { RuntimeFileSearch, type RuntimeFileSearchHost } from './runtime-file-search' function createSearch(executionHostId: 'local' | 'ssh:unavailable' | 'runtime:other' = 'local') { @@ -62,7 +62,7 @@ describe('RuntimeFileSearch', () => { it('preserves distinct search and listing results for unavailable SSH hosts', async () => { const { search, requireStore } = createSearch('ssh:unavailable') await expect(search.searchRuntimeFiles('folder-1', { query: 'needle' })).rejects.toThrow( - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE + SSH_HOST_NOT_SERVED_MESSAGE ) await expect(search.listRuntimeFiles('folder-1')).resolves.toEqual([]) await expect(search.searchQuickOpenFilePaths('folder-1', 'needle', 10)).resolves.toEqual({ diff --git a/src/main/runtime/runtime-git-execution-host-ownership.test.ts b/src/main/runtime/runtime-git-execution-host-ownership.test.ts index 9d42d003eb6..b9818313bc6 100644 --- a/src/main/runtime/runtime-git-execution-host-ownership.test.ts +++ b/src/main/runtime/runtime-git-execution-host-ownership.test.ts @@ -2,23 +2,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { GlobalSettings } from '../../shared/global-settings-types' import type * as GitRemoteModule from '../git/remote' import type * as GitStatusModule from '../git/status' -import type * as SshGitDispatchModule from '../providers/ssh-git-dispatch' import type { ResolvedRuntimeGitWorktree } from './orca-runtime-git' import { RuntimeGitCommands } from './orca-runtime-git' -import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-git-dispatch' +import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error' const mocks = vi.hoisted(() => ({ - getSshGitProvider: vi.fn(), getStatus: vi.fn(), gitFetch: vi.fn(), stageFile: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', async () => ({ - ...(await vi.importActual('../providers/ssh-git-dispatch')), - getSshGitProvider: mocks.getSshGitProvider -})) - vi.mock('../git/status', async () => ({ ...(await vi.importActual('../git/status')), getStatus: mocks.getStatus, @@ -45,23 +38,20 @@ function remoteCommands(): RuntimeGitCommands { describe('runtime Git execution-host ownership', () => { beforeEach(() => { - mocks.getSshGitProvider.mockReset().mockReturnValue(undefined) mocks.getStatus.mockReset() mocks.gitFetch.mockReset() mocks.stageFile.mockReset() }) - it('never substitutes local reads, sync, or mutations when the SSH provider is absent', async () => { + it('never substitutes local reads, sync, or mutations for an SSH host', async () => { const commands = remoteCommands() await expect(commands.getRuntimeGitStatus('id:wt-1')).rejects.toThrow( - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE - ) - await expect(commands.fetchRuntimeGit('id:wt-1')).rejects.toThrow( - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE + SSH_HOST_NOT_SERVED_MESSAGE ) + await expect(commands.fetchRuntimeGit('id:wt-1')).rejects.toThrow(SSH_HOST_NOT_SERVED_MESSAGE) await expect(commands.stageRuntimeGitPath('id:wt-1', 'src/a.ts')).rejects.toThrow( - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE + SSH_HOST_NOT_SERVED_MESSAGE ) expect(mocks.getStatus).not.toHaveBeenCalled() expect(mocks.gitFetch).not.toHaveBeenCalled() diff --git a/src/main/runtime/runtime-github-worktree-base.ts b/src/main/runtime/runtime-github-worktree-base.ts index f2bc88d0912..d4c6baa3675 100644 --- a/src/main/runtime/runtime-github-worktree-base.ts +++ b/src/main/runtime/runtime-github-worktree-base.ts @@ -54,9 +54,11 @@ export async function resolveRuntimeGitHubWorktreeBase( gitExec }) const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => - fetchPrHeadTrackingRef(repo, null, remote, branch, { localGitExecOptions }) + fetchPrHeadTrackingRef(repo, remote, branch, { localGitExecOptions }) const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise => - fetchGitHubPullRequestHeadRef(repo, null, remote, prNumber, { localGitExecOptions }) + fetchGitHubPullRequestHeadRef(repo, remote, prNumber, { + localGitExecOptions + }) return resolveGitHubPrStartPoint({ repoPath: repo.path, prNumber: args.prNumber, diff --git a/src/main/runtime/runtime-gitlab-fork-mr-base.ts b/src/main/runtime/runtime-gitlab-fork-mr-base.ts index 71270798b3f..58ed9142929 100644 --- a/src/main/runtime/runtime-gitlab-fork-mr-base.ts +++ b/src/main/runtime/runtime-gitlab-fork-mr-base.ts @@ -49,7 +49,6 @@ export async function resolveRuntimeGitLabForkMrBase(args: { try { const localRef = await fetchGitLabMergeRequestHeadRef( args.repo, - null, args.remote, args.mrIid, args.localGitExecOptions ? { localGitExecOptions: args.localGitExecOptions } : {} diff --git a/src/main/runtime/runtime-project-group-controller.ts b/src/main/runtime/runtime-project-group-controller.ts index 0e5ea78bb72..65da6fd0103 100644 --- a/src/main/runtime/runtime-project-group-controller.ts +++ b/src/main/runtime/runtime-project-group-controller.ts @@ -13,7 +13,6 @@ import { getFolderWorkspacePathStatusForPath } from '../project-groups/folder-workspace-path-status' import { FolderWorkspaceCreateRefusedError } from '../project-groups/folder-workspace-create-refusal' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { RuntimeStore } from './runtime-store-contract' import { folderWorkspaceKey } from '../../shared/workspace-scope' @@ -150,16 +149,13 @@ export class RuntimeProjectGroupController { if (!group || !folderPath) { throw new FolderWorkspaceCreateRefusedError('folder_workspace_project_group_not_found') } - const status = await getFolderWorkspacePathStatusForPath( - { - folderPath, - projectGroupId: group.id, - connectionId: input.connectionId ?? group.connectionId ?? null, - projectGroups, - repos: store.getRepos() - }, - { getSshFilesystemProvider } - ) + const status = await getFolderWorkspacePathStatusForPath({ + folderPath, + projectGroupId: group.id, + connectionId: input.connectionId ?? group.connectionId ?? null, + projectGroups, + repos: store.getRepos() + }) const pathRefusal = folderWorkspacePathRefusal(status) if (pathRefusal) { throw new FolderWorkspaceCreateRefusedError(pathRefusal) @@ -179,7 +175,7 @@ export class RuntimeProjectGroupController { if (!store) { throw new Error('runtime_unavailable') } - return getFolderWorkspacePathStatus(store, request, { getSshFilesystemProvider }) + return getFolderWorkspacePathStatus(store, request) } async updateFolderWorkspace( @@ -198,19 +194,16 @@ export class RuntimeProjectGroupController { return null } const projectGroups = store.getProjectGroups?.() ?? [] - const status = await getFolderWorkspacePathStatusForPath( - { - folderPath: updates.folderPath, - projectGroupId: workspace.projectGroupId, - connectionId: - workspace.connectionId ?? - projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ?? - null, - projectGroups, - repos: store.getRepos() - }, - { getSshFilesystemProvider } - ) + const status = await getFolderWorkspacePathStatusForPath({ + folderPath: updates.folderPath, + projectGroupId: workspace.projectGroupId, + connectionId: + workspace.connectionId ?? + projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ?? + null, + projectGroups, + repos: store.getRepos() + }) assertFolderWorkspacePathUsable(status) } const updated = store.updateFolderWorkspace(folderWorkspaceId, updates) diff --git a/src/main/runtime/runtime-project-host-setup-controller.test.ts b/src/main/runtime/runtime-project-host-setup-controller.test.ts index 114f0069904..3349ff52097 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.test.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.test.ts @@ -1,45 +1,39 @@ -// The CLI/runtime RPC used to refuse `--host ssh:*` with "set the project up from the Orca desktop -// app" — while the desktop IPC handler in the *same process* routed it correctly through -// addRemoteRepoFromPath. Safe but wrong: the process refusing is the one that owns the connection. import { describe, expect, it, vi } from 'vitest' import { RuntimeProjectHostSetupController } from './runtime-project-host-setup-controller' import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-lookup' import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' import type { Repo } from '../../shared/repo-types' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' const TARGET_ID = 'target-1' -const REMOTE_PATH = '/srv/app' +const REPO_PATH = '/srv/app' -const remoteRepo = { - id: 'repo-remote', - path: REMOTE_PATH, +const repo: Repo = { + id: 'repo-1', + path: REPO_PATH, displayName: 'app', badgeColor: 'blue', addedAt: 1, - kind: 'git', - connectionId: TARGET_ID -} as unknown as Repo + kind: 'git' +} function makeController(): { controller: RuntimeProjectHostSetupController addRepo: ReturnType - addRemoteRepo: ReturnType cloneRepo: ReturnType projectId: string } { const store = { - getProjects: () => projectHostSetupProjectionFromRepos([remoteRepo]).projects, + getProjects: () => projectHostSetupProjectionFromRepos([repo]).projects, getProjectHostSetups: () => [], - updateRepo: (_id: string, updates: Record) => ({ ...remoteRepo, ...updates }) + updateRepo: (_id: string, updates: Record) => ({ ...repo, ...updates }) } - const addRepo = vi.fn().mockResolvedValue(remoteRepo) - const addRemoteRepo = vi.fn().mockResolvedValue(remoteRepo) - const cloneRepo = vi.fn().mockResolvedValue(remoteRepo) + const addRepo = vi.fn().mockResolvedValue(repo) + const cloneRepo = vi.fn().mockResolvedValue(repo) const controller = new RuntimeProjectHostSetupController({ getStore: () => store as never, - listRepos: () => [remoteRepo], + listRepos: () => [repo], addRepo, - addRemoteRepo, cloneRepo, invalidateResolvedWorktrees: vi.fn(), invalidateWorktreeScan: vi.fn(), @@ -49,74 +43,61 @@ function makeController(): { return { controller, addRepo, - addRemoteRepo, cloneRepo, - projectId: getProjectHostSetupForRepo([], remoteRepo).projectId + projectId: getProjectHostSetupForRepo([], repo).projectId } } describe('RuntimeProjectHostSetupController host routing', () => { - it('registers an existing folder on an SSH host instead of refusing it (#11163)', async () => { - const { controller, addRepo, addRemoteRepo, projectId } = makeController() + it('refuses an existing folder on an SSH target with the not-connected text, never locally', async () => { + const { controller, addRepo, projectId } = makeController() - const result = await controller.setupExistingFolder({ + const setup = controller.setupExistingFolder({ projectId, hostId: `ssh:${TARGET_ID}`, - path: REMOTE_PATH, + path: REPO_PATH, kind: 'git' }) - expect(addRemoteRepo).toHaveBeenCalledWith({ - connectionId: TARGET_ID, - remotePath: REMOTE_PATH, - kind: 'git' - }) - // The local registration path validates the path against the client filesystem. + await expect(setup).rejects.toThrow(SshHostNotServedError) + await expect(setup).rejects.toThrow(`SSH connection "${TARGET_ID}" not found or not connected`) expect(addRepo).not.toHaveBeenCalled() - expect(result.repo.id).toBe(remoteRepo.id) }) - it('adds a new project from a folder on an SSH target, never as a local path', async () => { - const { controller, addRepo, addRemoteRepo } = makeController() + it('names the decoded connection id of a percent-encoded SSH target', async () => { + const { controller, addRepo, projectId } = makeController() - const repo = await controller.addSshRepo({ connectionId: TARGET_ID, remotePath: REMOTE_PATH }) - - expect(addRemoteRepo).toHaveBeenCalledWith({ - connectionId: TARGET_ID, - remotePath: REMOTE_PATH, - kind: 'git' - }) + await expect( + controller.setupExistingFolder({ + projectId, + hostId: 'ssh:my%20host', + path: REPO_PATH, + kind: 'folder' + }) + ).rejects.toThrow('SSH connection "my host" not found or not connected') expect(addRepo).not.toHaveBeenCalled() - expect(repo.connectionId).toBe(TARGET_ID) }) - it('decodes a percent-encoded SSH target back to its connection id', async () => { - const { controller, addRemoteRepo, projectId } = makeController() + it('keeps repo.addRemote failing with the not-connected text', () => { + const { controller, addRepo } = makeController() - await controller.setupExistingFolder({ - projectId, - hostId: 'ssh:my%20host', - path: REMOTE_PATH, - kind: 'folder' - }) - - expect(addRemoteRepo).toHaveBeenCalledWith( - expect.objectContaining({ connectionId: 'my host', kind: 'folder' }) + expect(() => controller.addSshRepo({ connectionId: TARGET_ID })).toThrow( + `SSH connection "${TARGET_ID}" not found or not connected` ) + expect(addRepo).not.toHaveBeenCalled() }) it('still uses the local registration for local and runtime hosts', async () => { - const { controller, addRepo, addRemoteRepo, projectId } = makeController() + const { controller, addRepo, projectId } = makeController() await controller.setupExistingFolder({ projectId, hostId: 'local', - path: REMOTE_PATH, + path: REPO_PATH, kind: 'git' }) - expect(addRepo).toHaveBeenCalledWith(REMOTE_PATH, 'git', 'local') - expect(addRemoteRepo).not.toHaveBeenCalled() + expect(addRepo).toHaveBeenCalledWith(REPO_PATH, 'git', 'local') }) it('refuses to clone onto an SSH host, because nothing here clones remotely', async () => { @@ -127,7 +108,7 @@ describe('RuntimeProjectHostSetupController host routing', () => { projectId, hostId: `ssh:${TARGET_ID}`, url: 'https://example.com/app.git', - destination: REMOTE_PATH + destination: REPO_PATH }) ).rejects.toThrow(/Cloning onto an SSH host is not supported/) expect(cloneRepo).not.toHaveBeenCalled() diff --git a/src/main/runtime/runtime-project-host-setup-controller.ts b/src/main/runtime/runtime-project-host-setup-controller.ts index fcd7ba286fa..a29e999d1a2 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.ts @@ -22,6 +22,10 @@ import { import { getProjectIdForProviderIdentity } from '../../shared/project-host-setup-projection' import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-lookup' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' +import { + SshHostNotServedError, + sshTargetNotConnectedMessage +} from '../providers/ssh-host-not-served-error' import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import type { RuntimeStore } from './runtime-store-contract' @@ -35,13 +39,6 @@ type RuntimeProjectHostSetupDependencies = { getStore: () => RuntimeStore | null listRepos: () => Repo[] addRepo: (path: string, kind: 'folder' | 'git', hostId: ExecutionHostId) => Promise - /** Register an existing path that lives on an SSH host; `addRepo` only reaches local/runtime hosts. */ - addRemoteRepo: (args: { - connectionId: string - remotePath: string - displayName?: string - kind: 'folder' | 'git' - }) => Promise cloneRepo: (url: string, destination: string, hostId: ExecutionHostId) => Promise invalidateResolvedWorktrees: () => void invalidateWorktreeScan: (repoId: string) => void @@ -49,10 +46,8 @@ type RuntimeProjectHostSetupDependencies = { countLiveTerminalsForRepo: (repo: Repo) => number } -// Why clone alone still refuses: nothing in this process clones onto an SSH host. `cloneRepo` runs -// `git clone` on the client, so accepting `ssh:*` here would register the client's copy as the -// host's repo — a local answer to a remote question. Registering an existing remote path, by -// contrast, has a correct implementation this process already uses over IPC. +// Why clone refuses up front: `cloneRepo` runs `git clone` on the client, so accepting `ssh:*` +// here would register the client's copy as the host's repo — a local answer to a remote question. function assertCloneHostIsSupported(hostId: ExecutionHostId | null | undefined): void { if (parseExecutionHostId(hostId)?.kind !== 'ssh') { return @@ -62,6 +57,10 @@ function assertCloneHostIsSupported(hostId: ExecutionHostId | null | undefined): ) } +function sshTargetNotServedError(connectionId: string): SshHostNotServedError { + return new SshHostNotServedError(connectionId, sshTargetNotConnectedMessage(connectionId)) +} + export class RuntimeProjectHostSetupController { constructor(private readonly deps: RuntimeProjectHostSetupDependencies) {} @@ -108,32 +107,20 @@ export class RuntimeProjectHostSetupController { } const kind = args.kind === 'folder' ? 'folder' : 'git' const knownRepoIds = new Set(this.deps.listRepos().map((repo) => repo.id)) - // Why route rather than refuse: this process owns the SSH connection, and its own IPC handler - // already registers `ssh:*` hosts correctly. Refusing here only made the CLI and runtime RPC - // disagree with the desktop app about what the same process can do. const sshTargetId = getSshTargetIdForExecutionHost(args.hostId) - const repo = sshTargetId - ? await this.deps.addRemoteRepo({ - connectionId: sshTargetId, - remotePath: args.path, - ...(args.displayName ? { displayName: args.displayName } : {}), - kind - }) - : await this.deps.addRepo(args.path, kind, args.hostId) + if (sshTargetId) { + throw sshTargetNotServedError(sshTargetId) + } + const repo = await this.deps.addRepo(args.path, kind, args.hostId) return this.completeSetup(args, repo, !knownRepoIds.has(repo.id)) } - /** Registers an existing folder on one of this host's SSH targets as a new project. */ - addSshRepo(args: { - connectionId: string - remotePath: string - displayName?: string - kind?: 'folder' | 'git' - }): Promise { + /** `repo.addRemote` stays registered for older clients; this process serves no SSH target. */ + addSshRepo(args: { connectionId: string }): Promise { if (!this.deps.getStore()) { throw new Error('runtime_unavailable') } - return this.deps.addRemoteRepo({ ...args, kind: args.kind === 'folder' ? 'folder' : 'git' }) + throw sshTargetNotServedError(args.connectionId) } async setupClone(args: ProjectHostSetupCloneArgs): Promise { diff --git a/src/main/runtime/runtime-project-host-setup-publication.test.ts b/src/main/runtime/runtime-project-host-setup-publication.test.ts index ad39e2a51d0..3592f17d5b4 100644 --- a/src/main/runtime/runtime-project-host-setup-publication.test.ts +++ b/src/main/runtime/runtime-project-host-setup-publication.test.ts @@ -43,7 +43,6 @@ function makeController(): { getStore: () => store as unknown as RuntimeStore, listRepos: () => [repo], addRepo: vi.fn(), - addRemoteRepo: vi.fn(), cloneRepo: vi.fn(), invalidateResolvedWorktrees, invalidateWorktreeScan: vi.fn(), diff --git a/src/main/runtime/runtime-repository-ref-queries.test.ts b/src/main/runtime/runtime-repository-ref-queries.test.ts index accf18dec15..ee0027b781d 100644 --- a/src/main/runtime/runtime-repository-ref-queries.test.ts +++ b/src/main/runtime/runtime-repository-ref-queries.test.ts @@ -1,11 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import type { Repo } from '../../shared/repo-types' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { RuntimeRepositoryRefQueries } from './runtime-repository-ref-queries' -const { getProvider } = vi.hoisted(() => ({ getProvider: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getProvider })) - const repo: Repo = { id: 'remote-repo', path: '/repo', @@ -17,7 +13,6 @@ const repo: Repo = { describe('qualified refs in repository searches', () => { it('keeps folder workspaces outside Git searches', async () => { - vi.mocked(getSshGitProvider).mockClear() const queries = new RuntimeRepositoryRefQueries({ resolveRepo: async () => ({ ...repo, kind: 'folder' }) }) @@ -25,6 +20,5 @@ describe('qualified refs in repository searches', () => { refs: [], truncated: false }) - expect(getSshGitProvider).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/runtime-rpc-browser-host-admission.test.ts b/src/main/runtime/runtime-rpc-browser-host-admission.test.ts index b9187315478..24b94ee4fe4 100644 --- a/src/main/runtime/runtime-rpc-browser-host-admission.test.ts +++ b/src/main/runtime/runtime-rpc-browser-host-admission.test.ts @@ -55,7 +55,9 @@ describe('runtime RPC browser-host admission', () => { }) server['deviceRegistry'] = new DeviceRegistry(userDataPath) const device = server['deviceRegistry'].addDevice('runtime-test', 'runtime') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server reads only these wiring members in this test. server['mobileSocketWiring'] = { + dispose: () => {}, getConnectionId: () => 'connection-a' } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> const socket = new FakeWebSocket() @@ -125,7 +127,9 @@ describe('runtime RPC browser-host admission', () => { [socketB, 'connection-b'], [socketAReplacement, 'connection-a-replacement'] ]) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server reads only these wiring members in this test. server['mobileSocketWiring'] = { + dispose: () => {}, getConnectionId: (socket) => connectionIds.get(socket as unknown as FakeWebSocket) } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> const repliesA: Record[] = [] diff --git a/src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts b/src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts index ea8837c01c9..2054fa23de8 100644 --- a/src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts +++ b/src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts @@ -54,7 +54,9 @@ describe('OrcaRuntimeRpcServer', () => { server['deviceRegistry'] = new DeviceRegistry(userDataPath) const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime') const ws = new FakeWebSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server reads only these wiring members in this test. server['mobileSocketWiring'] = { + dispose: () => {}, getConnectionId: () => 'conn-test' } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> const replies: Record[] = [] @@ -132,7 +134,9 @@ describe('OrcaRuntimeRpcServer', () => { // Why: 'runtime' scope, not 'mobile' — orchestration.ask is absent from the mobile allowlist. const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime') const ws = new FakeWebSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server reads only these wiring members in this test. server['mobileSocketWiring'] = { + dispose: () => {}, getConnectionId: () => 'conn-test' } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> const replies: Record[] = [] @@ -214,7 +218,9 @@ describe('OrcaRuntimeRpcServer', () => { server['deviceRegistry'] = new DeviceRegistry(userDataPath) const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime') const ws = new FakeWebSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server reads only these wiring members in this test. server['mobileSocketWiring'] = { + dispose: () => {}, getConnectionId: () => 'conn-test' } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> let activeDispatches = 0 diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-device-key-registration.ts b/src/main/runtime/runtime-rpc/runtime-rpc-device-key-registration.ts index e63b2d7f38c..7c566e7c407 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-device-key-registration.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-device-key-registration.ts @@ -35,7 +35,8 @@ export function registerSocketDeviceKey( const entry = registry.forkDeviceKey( legacy.deviceId, params.devicePublicKeyB64, - sanitizeDeviceLabel(params.deviceLabel, legacy.name) + sanitizeDeviceLabel(params.deviceLabel, legacy.name), + { ephemeral: params.ephemeral === true } ) return { deviceId: entry.deviceId, credentialRevision: entry.credentialRevision ?? 0 } } diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index 3159f9f6286..188ce1318e4 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -1,3 +1,4 @@ +import { ALLOW_INSECURE_WEB_PAIRING_ENV } from '../../../shared/web-origin-security' import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../shared/agent-launch-runtime-capability' import type { RuntimeTransportMetadata } from '../../../shared/runtime-bootstrap' import { watchRuntimeMetadataOwnership } from '../runtime-metadata-ownership-watch' @@ -112,6 +113,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { } catch (error) { // Why: WebSocket transport is supplementary; on failure (e.g. port in use) continue with Unix socket only. console.error('[runtime] Failed to start WebSocket transport:', error) + this.mobileSocketWiring?.dispose() this.mobileSocketWiring = null } } @@ -191,7 +193,10 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { fallbackLadder: wsFallbackPortLadder(this.wsPort) }) const mobileSocketWiring = this.ensureMobileSocketWiring(deviceRegistry, e2eeKeypair) - this.detachWebSocketWiring = mobileSocketWiring.attachTransport(wsTransport) + this.detachWebSocketWiring = mobileSocketWiring.attachTransport(wsTransport, (ws) => ({ + transport: 'direct', + ...wsTransport.webPageOf(ws) + })) try { await wsTransport.start() @@ -270,6 +275,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { this.runtime.onClientDisconnected(socket.device.clientId) } }, + allowInsecureWebPairing: () => process.env[ALLOW_INSECURE_WEB_PAIRING_ENV] === '1', // Why: relay attempts are authorized upstream; only direct failures should prompt local re-pairing. onUnpairedDeviceAuthFailure: (metadata) => { if (metadata.transport === 'direct') { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts index 2efd9bc7678..d5e5b8610dd 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts @@ -41,6 +41,7 @@ export class RuntimeRpcShutdown extends RuntimeRpcMobilePairing { if (this.hostDescriptor) { publishHostDescriptor(this.runtime.getRuntimeId(), null) } + this.mobileSocketWiring?.dispose() this.mobileSocketWiring = null this.detachWebSocketWiring = null const stopResults = await Promise.allSettled( diff --git a/src/main/runtime/runtime-search-line-fragments.test.ts b/src/main/runtime/runtime-search-line-fragments.test.ts index 67308d8667f..4451a1e9960 100644 --- a/src/main/runtime/runtime-search-line-fragments.test.ts +++ b/src/main/runtime/runtime-search-line-fragments.test.ts @@ -24,10 +24,6 @@ vi.mock( '../ipc/local-worktree-runtime-options', async () => (await import('./orca-runtime-files-mock-registry')).localWorktreeRuntimeOptionsMock ) -vi.mock( - '../providers/ssh-filesystem-dispatch', - async () => (await import('./orca-runtime-files-mock-registry')).sshFilesystemDispatchMock -) type MockRuntimeSearchChild = EventEmitter & { stdout: EventEmitter & { setEncoding: ReturnType } diff --git a/src/main/runtime/ssh-file-explorer-chunk-read.ts b/src/main/runtime/ssh-file-explorer-chunk-read.ts deleted file mode 100644 index fdf3c9c5981..00000000000 --- a/src/main/runtime/ssh-file-explorer-chunk-read.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { MAX_FILE_RANGE_READ_BYTES } from '../../shared/file-range-read' -import type { RuntimeFileReadChunkResult } from '../../shared/runtime-types' -import { FileRangeReadUnsupportedError, type IFilesystemProvider } from '../providers/types' - -export const SSH_RANGED_READ_UNAVAILABLE_MESSAGE = - 'This SSH host cannot serve ranged file reads; reconnect the SSH target to update its Orca helper and retry' - -/** - * Serves one `readFileExplorerChunk` window from an SSH host, mirroring the local positional read. - * The host refuses a window wider than its range cap rather than clamping it, so a caller-sized - * window is paged into bounded reads here instead of failing the whole chunk. - */ -export async function readSshFileExplorerChunk( - provider: IFilesystemProvider, - filePath: string, - fileSize: number, - offset: number, - length: number -): Promise { - const readFileRange = provider.readFileRange?.bind(provider) - if (!readFileRange) { - throw new Error(SSH_RANGED_READ_UNAVAILABLE_MESSAGE) - } - const want = Math.min(length, Math.max(0, fileSize - offset)) - const windows: Buffer[] = [] - let bytesRead = 0 - while (bytesRead < want) { - const windowLength = Math.min(MAX_FILE_RANGE_READ_BYTES, want - bytesRead) - let window - try { - window = await readFileRange(filePath, offset + bytesRead, windowLength) - } catch (error) { - if (error instanceof FileRangeReadUnsupportedError) { - throw new Error(SSH_RANGED_READ_UNAVAILABLE_MESSAGE) - } - throw error - } - windows.push(window.bytes) - bytesRead += window.bytesRead - // A short window means EOF, so the file shrank under the stat: stop instead of spinning. This - // chunk reports `eof` false, but the caller's next chunk re-stats the smaller file and ends the - // transfer short — the same silent outcome the local branch produces, fixable only in both. - if (window.bytesRead < windowLength) { - break - } - } - return { - contentBase64: Buffer.concat(windows).toString('base64'), - bytesRead, - eof: offset + bytesRead >= fileSize - } -} diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 132154d98c7..49453beb171 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -206,6 +206,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = }) it('leaves a pointer the person stopped while its agent was starting stopped', async () => { + observationClock.start() await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() await host.close(COORDINATOR, 'evict') @@ -214,7 +215,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = await finishWorker(taskId) await vi.waitFor(() => expect(providerFaults.starts).toBe(before + 1), WAIT) // The person presses Stop while the agent is still starting. - const cancelled = await host.cancel( + const cancelling = host.cancel( { callerKey: 'test-surface' }, { envelope: { @@ -230,6 +231,8 @@ describe('a worker result reaches the structured chat that coordinates it', () = turnId: 'turn-x' } ) + await observationClock.observe(400) + const cancelled = await cancelling expect(cancelled).toMatchObject({ ok: true }) providerFaults.startDelayMs = 0 // A fixed window, not a poll: a re-point would start the agent again in it. diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index 294e2fd227a..d826b2f1b2f 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -34,6 +34,7 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) @@ -370,3 +371,113 @@ function parentSubject(summaries: AgentSessionStatusSummary[]) { summary.sessionId ) } + +describe('Claude background-only resume through hook shutdown', () => { + let claude = createScriptedClaudeRuntime([SESSION]) + let server = new AgentHookServer() + + beforeEach(() => { + claude = createScriptedClaudeRuntime([SESSION]) + server = new AgentHookServer() + }) + + afterEach(async () => { + await claude.dispose() + server.stop() + }) + + it.each([ + { trigger: 'quit', kind: 'agent', settled: false }, + { trigger: 'update', kind: 'agent', settled: false }, + { trigger: 'quit', kind: 'command', settled: false }, + { trigger: 'update', kind: 'command', settled: false }, + { trigger: 'quit', kind: 'command', settled: true } + ] as const)( + '$trigger with a background $kind (settled: $settled)', + async ({ trigger, kind, settled }) => { + const host = await claude.install() + host.deps.statusSink = { + publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), + forget: (subject) => server.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + server.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => server.getStructuredChildWorkViews(subject) + } + const params = claude.attachParams(SESSION, null) + expect(await host.attach({ callerKey: 'restart-test' }, params)).toMatchObject({ ok: true }) + if (params.providerHandle?.kind !== 'claude') { + throw new Error('missing Claude provider handle') + } + const providerSession = params.providerHandle.sessionId + const message = claude.child(SESSION).handlers.onMessage + message?.({ + type: 'assistant', + uuid: 'lead-assistant', + session_id: providerSession, + parent_tool_use_id: null, + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Finished the foreground reply' }] + } + }) + message?.({ + type: 'system', + subtype: 'task_started', + uuid: 'background-start', + session_id: providerSession, + task_id: 'background-1', + tool_use_id: 'spawn-1', + task_type: kind === 'agent' ? 'local_agent' : 'local_bash', + description: 'Audit the build', + is_backgrounded: true + }) + if (settled) { + message?.({ + type: 'system', + subtype: 'task_notification', + uuid: 'background-end', + session_id: providerSession, + task_id: 'background-1', + tool_use_id: 'spawn-1', + status: 'completed' + }) + } + message?.({ + type: 'result', + subtype: 'success', + uuid: 'lead-result', + session_id: providerSession, + result: 'Finished the foreground reply', + is_error: false + }) + await host.flushStreamedEvents(SESSION) + await vi.waitFor(() => + expect(server.getStatusSnapshot().find((entry) => entry.structuredHost)).toMatchObject({ + state: settled ? 'done' : 'working', + mainAgent: { state: 'done' } + }) + ) + const stopping = stopStructuredAgentSessionRuntime({ trigger }) + server.stop({ keepStatus: true }) + await stopping + expect( + server + .getStructuredChildWorkViews(makeStructuredAgentStatusSubject(params.location, SESSION)) + .filter((child) => child.state !== 'done') + ).toEqual([]) + const activity = { state: 'done', prompts: [], tasks: [{ kind, label: 'Audit the build' }] } + const markers = await host.deps.recoveryCapsule?.list(Date.now()) + const restarted = await claude.install() + const candidates = await restarted.restartResume.list() + if (settled) { + expect(markers).toEqual([]) + expect(candidates).toEqual([]) + } else { + expect(markers).toMatchObject([{ sessionId: SESSION, trigger, activity }]) + expect(candidates).toMatchObject([ + { sessionId: SESSION, agent: 'claude', trigger, activity } + ]) + } + } + ) +}) diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index 96614dc817d..b4fd229e3ac 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -230,7 +230,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { children: (sessionId: string): ScriptedClaudeChild[] => children.filter((entry) => entry.sessionId === sessionId), install: async (): Promise => { - root = await mkdtemp(join(tmpdir(), 'orca-scripted-claude-runtime-')) + root ??= await mkdtemp(join(tmpdir(), 'orca-scripted-claude-runtime-')) await mkdir(join(root, 'claude-home'), { recursive: true }) const directory = root host = await ensureStructuredAgentSessionHost({ diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index d32fc1f74d1..3fc2599c3eb 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { CodexAppServerConnection, CodexAppServerConnectionHandlers, @@ -20,6 +20,14 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { AgentHookServer } from '../agent-hooks/server' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule' +import { + openStructuredCodexRpcHarness, + THREAD as RESTART_THREAD, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' const THREAD = 'thread-runtime-child-work' const CHILD = 'thread-runtime-reviewer' @@ -134,3 +142,126 @@ describe('structured Codex child work through the production runtime', () => { ]) }) }) + +describe('Codex background-only resume through hook shutdown', () => { + let harness: StructuredCodexRpcHarness | null = null + let server: AgentHookServer | null = null + + afterEach(async () => { + await harness?.dispose() + harness = null + server?.stop() + server = null + }) + + it.each([ + { trigger: 'quit', kind: 'agent', settled: false }, + { trigger: 'update', kind: 'agent', settled: false }, + { trigger: 'quit', kind: 'command', settled: false }, + { trigger: 'update', kind: 'command', settled: false }, + { trigger: 'update', kind: 'command', settled: true } + ] as const)( + '$trigger with a background $kind (settled: $settled)', + async ({ trigger, kind, settled }) => { + const runtime = await openStructuredCodexRpcHarness() + harness = runtime + const store = new AgentHookServer() + server = store + const config = { + ...runtime.hostConfig(), + resolveWorkspacePath: async () => runtime.root, + statusSink: { + publish: (summary, subject) => store.ingestStructuredStatus(summary, subject), + forget: (subject) => store.dropStructuredStatus(subject), + publishChildWork: (subject, evidence, provider) => + store.ingestStructuredChildWork(subject, evidence, provider), + readChildWork: (subject) => store.getStructuredChildWorkViews(subject) + } satisfies StructuredAgentSessionStatusSink + } + const host = await ensureStructuredAgentSessionHost(config) + const params = hostTestAttachParams(null, { + providerHandle: undefined, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + } + }) + params.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` + expect(await host.attach({ callerKey: 'restart-test' }, params)).toMatchObject({ ok: true }) + const notify = runtime.codex.notify + notify('turn/started', { threadId: RESTART_THREAD, turn: { id: 'lead-turn' } }) + if (kind === 'agent') { + notify('turn/started', { threadId: CHILD, turn: { id: 'child-turn' } }) + notify('item/started', { + threadId: RESTART_THREAD, + turnId: 'lead-turn', + item: { + type: 'subAgentActivity', + id: 'spawn-1', + kind: 'started', + agentThreadId: CHILD, + agentPath: '/root/review' + } + }) + } else { + const command = { + type: 'commandExecution', + id: 'command-1', + command: 'npm run build', + source: 'unifiedExecStartup' + } + notify('item/started', { + threadId: RESTART_THREAD, + turnId: 'lead-turn', + item: { ...command, status: 'inProgress' } + }) + if (settled) { + notify('item/completed', { + threadId: RESTART_THREAD, + turnId: 'lead-turn', + item: { ...command, status: 'completed', exitCode: 0 } + }) + } + } + notify('turn/completed', { + threadId: RESTART_THREAD, + turn: { id: 'lead-turn', status: 'completed' } + }) + await host.flushStreamedEvents(SESSION) + await vi.waitFor(() => + expect(store.getStatusSnapshot().find((entry) => entry.structuredHost)).toMatchObject({ + state: settled ? 'done' : 'working', + mainAgent: { state: 'done' } + }) + ) + // The real quit starts async chat teardown, then synchronously stops hook ingress. + const stopping = stopStructuredAgentSessionRuntime({ trigger }) + store.stop({ keepStatus: true }) + await stopping + expect( + store + .getStructuredChildWorkViews(makeStructuredAgentStatusSubject(params.location, SESSION)) + .filter((child) => child.state !== 'done') + ).toEqual([]) + const activity = { + state: 'done', + prompts: [], + tasks: [{ kind, label: kind === 'agent' ? 'review' : 'npm run build' }] + } + const markers = await new AgentSessionRecoveryCapsule(runtime.root).list(Date.now()) + const restarted = await ensureStructuredAgentSessionHost(config) + const candidates = await restarted.restartResume.list() + if (settled) { + expect(markers).toEqual([]) + expect(candidates).toEqual([]) + } else { + expect(markers).toMatchObject([{ sessionId: SESSION, trigger, activity }]) + expect(candidates).toMatchObject([ + { sessionId: SESSION, agent: 'codex', workspaceKind: 'folder', trigger, activity } + ]) + } + } + ) +}) diff --git a/src/main/runtime/workspace-layout-round-trip-watch.test.ts b/src/main/runtime/workspace-layout-round-trip-watch.test.ts deleted file mode 100644 index 6e9e34cb6da..00000000000 --- a/src/main/runtime/workspace-layout-round-trip-watch.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { localDesktopSession } from '../../shared/workspace-layout/workspace-layout-profile.test-fixture' -import { GIT_KEY } from '../../shared/workspace-layout/workspace-layout-session.test-fixture' -import { - shouldWatchLayoutRoundTrip, - watchLayoutRoundTrip -} from './workspace-layout-round-trip-watch' - -afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() -}) - -function watched(initial: WorkspaceSessionState) { - let session = initial - const listeners = new Set<() => void>() - const stop = watchLayoutRoundTrip({ - getWorkspaceSession: () => session, - getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], - onWorkspaceSessionWritten: (listener) => { - listeners.add(listener) - return () => listeners.delete(listener) - } - }) - return { - stop, - write(next: WorkspaceSessionState) { - session = next - listeners.forEach((listener) => listener()) - } - } -} - -describe('the round-trip watch on Store writes', () => { - it('runs only in dev, e2e or when asked', () => { - expect(shouldWatchLayoutRoundTrip({ NODE_ENV: 'production' })).toBe(false) - expect(shouldWatchLayoutRoundTrip({ NODE_ENV: 'development' })).toBe(true) - expect(shouldWatchLayoutRoundTrip({ ORCA_E2E_USER_DATA_DIR: '/e2e' })).toBe(true) - expect(shouldWatchLayoutRoundTrip({ ORCA_LAYOUT_ROUND_TRIP_CHECK: '1' })).toBe(true) - }) - - it('checks a settled burst once, logs findings by kind, and skips unchanged partitions', () => { - vi.useFakeTimers() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { write, stop } = watched(localDesktopSession()) - const corrupt = localDesktopSession() - corrupt.tabsByWorktree[GIT_KEY] = JSON.parse('[null]') - write(corrupt) - write(corrupt) - vi.runAllTimers() - expect(warn).toHaveBeenCalledTimes(1) - expect(warn.mock.calls[0]).toEqual([ - '[workspace-layout] round-trip check:', - 'local', - [{ kind: 'threw', message: expect.any(String) }] - ]) - write(corrupt) - vi.runAllTimers() - write(localDesktopSession()) - vi.runAllTimers() - expect(warn).toHaveBeenCalledTimes(1) - stop() - }) -}) diff --git a/src/main/runtime/workspace-layout-round-trip-watch.ts b/src/main/runtime/workspace-layout-round-trip-watch.ts deleted file mode 100644 index 3aeaedad52b..00000000000 --- a/src/main/runtime/workspace-layout-round-trip-watch.ts +++ /dev/null @@ -1,61 +0,0 @@ -// The shadow self-check on Store writes, in dev and e2e runs only: each written partition must load -// and save to a fixed point (idempotence). Findings are logged by kind, and so is each kind of -// first-load change the first time a host shows it; nothing is thrown and nothing is written. - -import type { ExecutionHostId } from '../../shared/execution-host' -import { stableJson } from '../../shared/workspace-layout/workspace-layout-load-report' -import { checkLayoutRoundTrip } from '../../shared/workspace-layout/workspace-layout-round-trip-check' -import type { RuntimeStore } from './runtime-store-contract' - -const SETTLE_MS = 1000 - -type WatchedStore = Pick< - RuntimeStore, - 'getWorkspaceSession' | 'getWorkspaceSessionHostIds' | 'onWorkspaceSessionWritten' -> - -export function shouldWatchLayoutRoundTrip(env: NodeJS.ProcessEnv = process.env): boolean { - return ( - env.NODE_ENV === 'development' || - Boolean(env.ORCA_E2E_USER_DATA_DIR) || - env.ORCA_LAYOUT_ROUND_TRIP_CHECK === '1' - ) -} - -/** Checks each partition a burst of writes changed, once the burst settles. */ -export function watchLayoutRoundTrip(store: WatchedStore): () => void { - const checked = new Map() - const loggedKinds = new Map>() - let timer: ReturnType | null = null - const check = (): void => { - timer = null - for (const hostId of store.getWorkspaceSessionHostIds?.() ?? []) { - const session = store.getWorkspaceSession?.(hostId) - const json = session ? stableJson(session) : '' - if (!session || checked.get(hostId) === json) { - continue - } - checked.set(hostId, json) - const { findings, firstLoadChanges } = checkLayoutRoundTrip(hostId, session) - const hostKind = hostId.split(':')[0] - if (findings.length > 0) { - console.warn('[workspace-layout] round-trip check:', hostKind, findings) - } - const logged = loggedKinds.get(hostId) ?? new Set() - loggedKinds.set(hostId, logged) - if (Object.keys(firstLoadChanges).some((kind) => !logged.has(kind))) { - Object.keys(firstLoadChanges).forEach((kind) => logged.add(kind)) - console.info('[workspace-layout] first-load changes:', hostKind, firstLoadChanges) - } - } - } - const stop = store.onWorkspaceSessionWritten?.(() => { - timer ??= setTimeout(check, SETTLE_MS) - }) - return () => { - stop?.() - if (timer) { - clearTimeout(timer) - } - } -} diff --git a/src/main/runtime/worktree-path-selector-wsl-posix.test.ts b/src/main/runtime/worktree-path-selector-wsl-posix.test.ts index 2346afa500b..b06ee2bbcdf 100644 --- a/src/main/runtime/worktree-path-selector-wsl-posix.test.ts +++ b/src/main/runtime/worktree-path-selector-wsl-posix.test.ts @@ -20,14 +20,6 @@ const electronMocks = vi.hoisted(() => { }) vi.mock('electron', () => electronMocks) -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: vi.fn(() => 0), - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', - requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) -})) - const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) vi.mock('../git/worktree', async (importOriginal) => ({ ...(await importOriginal>()), @@ -138,7 +130,6 @@ async function selectorTheCliWouldSend( } beforeEach(() => { - getSshGitProviderMock.mockReset() listWorktreesStrictMock.mockReset() }) diff --git a/src/main/runtime/worktree-ps-degraded-repo-scan.test.ts b/src/main/runtime/worktree-ps-degraded-repo-scan.test.ts index 8b01d20e7a3..5f930d65462 100644 --- a/src/main/runtime/worktree-ps-degraded-repo-scan.test.ts +++ b/src/main/runtime/worktree-ps-degraded-repo-scan.test.ts @@ -15,14 +15,6 @@ const electronMocks = vi.hoisted(() => { }) vi.mock('electron', () => electronMocks) -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: vi.fn(() => 0), - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', - requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) -})) - const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) vi.mock('../git/worktree', async (importOriginal) => ({ ...(await importOriginal>()), @@ -137,16 +129,6 @@ function neverSettles() { return new Promise(() => {}) } -/** A healthy remote scan, so the only difference between the co-hosted cases is how `metaById` is stamped. */ -function makeHealthySshScan() { - return { - listWorktrees: vi.fn(async () => [ - { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, - { path: WORKTREE_PATH, head: 'def', branch: 'feature', isBare: false, isMainWorktree: false } - ]) - } -} - async function advancePastRepoScanBudget(pending: Promise): Promise { await vi.advanceTimersByTimeAsync(6_000) return pending @@ -154,7 +136,6 @@ async function advancePastRepoScanBudget(pending: Promise): Promise { describe('worktree.ps on a degraded repo scan', () => { beforeEach(() => { - getSshGitProviderMock.mockReset() listWorktreesStrictMock.mockReset() listWorktreesStrictMock.mockResolvedValue([]) }) @@ -162,7 +143,6 @@ describe('worktree.ps on a degraded repo scan', () => { it('keeps persisted worktrees when a remote scan stalls past the per-repo budget', async () => { vi.useFakeTimers() try { - getSshGitProviderMock.mockReturnValue({ listWorktrees: vi.fn(neverSettles) }) const runtime = new OrcaRuntimeService(makeStore({ connectionId: 'ssh-remote-1' }) as never) const result = await advancePastRepoScanBudget(runtime.getWorktreePs(10_000)) @@ -174,7 +154,6 @@ describe('worktree.ps on a degraded repo scan', () => { }) it('keeps persisted worktrees when a remote repo is unreachable', async () => { - getSshGitProviderMock.mockReturnValue(undefined) const runtime = new OrcaRuntimeService(makeStore({ connectionId: 'ssh-remote-1' }) as never) const result = await runtime.getWorktreePs(10_000) @@ -249,33 +228,10 @@ describe('worktree.ps on a degraded repo scan', () => { } }) - // Why: remote disconnects should recover on the next poll; only local failures need backoff against spawn storms. - it('rescans a degraded remote repo on the next poll instead of caching the failure', async () => { - vi.useFakeTimers() - try { - const listWorktrees = vi.fn(async () => { - throw new Error('provider offline') - }) - getSshGitProviderMock.mockReturnValue({ listWorktrees }) - const runtime = new OrcaRuntimeService( - makeStore({ connectionId: SSH_CONNECTION_ID }) as never - ) - - await runtime.getWorktreePs(10_000) - await vi.advanceTimersByTimeAsync(2_000) - await runtime.getWorktreePs(10_000) - - expect(listWorktrees).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - }) - // Why: one repo id can be registered on several execution hosts, so a stalled host must not republish another host's rows. it('does not overwrite another execution host’s healthy rows when this host’s scan stalls', async () => { vi.useFakeTimers() try { - getSshGitProviderMock.mockReturnValue(makeHealthySshScan()) listWorktreesStrictMock.mockImplementation(neverSettles) const runtime = new OrcaRuntimeService( makeStore({ @@ -295,39 +251,12 @@ describe('worktree.ps on a degraded repo scan', () => { const result = await advancePastRepoScanBudget(runtime.getWorktreePs(10_000)) const remote = result.worktrees.find((worktree) => worktree.worktreeId === WORKTREE_ID) - expect(remote?.branch).toBe('feature') expect(remote?.hostId).toBe(SSH_HOST_ID) } finally { vi.useRealTimers() } }) - it('does not claim an unstamped persisted row when the repo id is registered on two hosts', async () => { - vi.useFakeTimers() - try { - getSshGitProviderMock.mockReturnValue(makeHealthySshScan()) - listWorktreesStrictMock.mockImplementation(neverSettles) - const runtime = new OrcaRuntimeService( - makeStore({ - connectionId: SSH_CONNECTION_ID, - coHostedRepos: [{ id: REPO_ID, path: LOCAL_REPO_PATH }], - metaById: { - [WORKTREE_ID]: makeMeta(), - [MAIN_WORKTREE_ID]: makeMeta({ displayName: 'main', instanceId: 'parent-instance' }) - } - }) as never - ) - - const result = await advancePastRepoScanBudget(runtime.getWorktreePs(10_000)) - - const remote = result.worktrees.find((worktree) => worktree.worktreeId === WORKTREE_ID) - expect(remote?.hostId).toBe(SSH_HOST_ID) - expect(remote?.branch).toBe('feature') - } finally { - vi.useRealTimers() - } - }) - // Why: the ownership gate must not cost the single-host case the fix exists for — a stamped row on its own host still comes back. it('restores persisted rows stamped for this host when its only owner stalls', async () => { vi.useFakeTimers() diff --git a/src/main/runtime/worktree-ps-host-scope.test.ts b/src/main/runtime/worktree-ps-host-scope.test.ts index cf718cd267f..22174a50367 100644 --- a/src/main/runtime/worktree-ps-host-scope.test.ts +++ b/src/main/runtime/worktree-ps-host-scope.test.ts @@ -15,14 +15,6 @@ const electronMocks = vi.hoisted(() => { }) vi.mock('electron', () => electronMocks) -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: vi.fn(() => 0), - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', - requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) -})) - const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) vi.mock('../git/worktree', async (importOriginal) => ({ ...(await importOriginal>()), @@ -43,7 +35,15 @@ function gitWorktree(path: string, isMain = false) { /** Local rows sort ahead of the remote ones, mirroring the fleet order that starves the cap. */ function makeStore() { - const metaById: Record = {} + // Why: this process serves no SSH git, so the SSH repo lists its stored rows. + const metaById: Record = { + [`${SSH_REPO_ID}::${SSH_REPO_PATH}`]: { + hostId: `ssh:${SSH_CONNECTION_ID}` + }, + [`${SSH_REPO_ID}::${SSH_REPO_PATH}-a`]: { + hostId: `ssh:${SSH_CONNECTION_ID}` + } + } return { getRepo: (id: string) => makeStore() @@ -91,7 +91,6 @@ function makeStore() { describe('worktree.ps host coverage', () => { beforeEach(() => { - getSshGitProviderMock.mockReset() listWorktreesStrictMock.mockReset() listWorktreesStrictMock.mockResolvedValue([ gitWorktree(LOCAL_REPO_PATH, true), @@ -99,12 +98,6 @@ describe('worktree.ps host coverage', () => { gitWorktree(`${LOCAL_REPO_PATH}-b`), gitWorktree(`${LOCAL_REPO_PATH}-c`) ]) - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn(async () => [ - gitWorktree(SSH_REPO_PATH, true), - gitWorktree(`${SSH_REPO_PATH}-a`) - ]) - }) }) it('names every host the page covers', async () => { diff --git a/src/main/runtime/worktree-ps-unverifiable-terminals.test.ts b/src/main/runtime/worktree-ps-unverifiable-terminals.test.ts index 575dc71150a..2499c879653 100644 --- a/src/main/runtime/worktree-ps-unverifiable-terminals.test.ts +++ b/src/main/runtime/worktree-ps-unverifiable-terminals.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' vi.mock('electron', () => ({ @@ -8,14 +8,6 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } })) -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: vi.fn(() => 0), - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', - requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) -})) - import { OrcaRuntimeService } from './orca-runtime' // BUG-9: a network drop to a relay host made `worktree ps` print `live:0 pty:no` for a terminal @@ -42,7 +34,8 @@ function makeStore() { setWorkspaceSession: vi.fn(), getRepos: vi.fn(() => [REPO]), getRepo: vi.fn((id: string) => (id === REPO.id ? REPO : undefined)), - getAllWorktreeMeta: vi.fn(() => ({})), + // Why: this process serves no SSH git, so the SSH repo lists its stored rows. + getAllWorktreeMeta: vi.fn(() => ({ [WORKTREE_ID]: {} })), getWorktreeMeta: vi.fn(() => undefined), setWorktreeMeta: vi.fn(), removeWorktreeMeta: vi.fn(), @@ -81,15 +74,6 @@ async function psRow(runtime: OrcaRuntimeService) { } describe('worktree.ps terminal verdicts for an SSH host', () => { - beforeEach(() => { - getSshGitProviderMock.mockReset() - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn(async () => [ - { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true } - ]) - }) - }) - it('counts a terminal the reachable host lists as live', async () => { const runtime = makeRuntime({ hasPty: () => true, diff --git a/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts b/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts index f4e61a76381..751a5b4535b 100644 --- a/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts +++ b/src/main/runtime/worktree-rm-id-selector-path-spelling.test.ts @@ -20,14 +20,6 @@ const electronMocks = vi.hoisted(() => { }) vi.mock('electron', () => electronMocks) -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: vi.fn(() => 0), - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', - requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) -})) - const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) vi.mock('../git/worktree', async (importOriginal) => ({ ...(await importOriginal>()), @@ -106,7 +98,6 @@ type RemovalInternals = { } beforeEach(() => { - getSshGitProviderMock.mockReset() listWorktreesStrictMock.mockReset() }) diff --git a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts index 05412275168..b41d476b8a3 100644 --- a/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts +++ b/src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts @@ -18,14 +18,6 @@ const electronMocks = vi.hoisted(() => { }) vi.mock('electron', () => electronMocks) -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: vi.fn(() => 0), - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', - requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) -})) - const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) vi.mock('../git/worktree', async (importOriginal) => ({ ...(await importOriginal>()), @@ -181,7 +173,6 @@ function stallProbeOnce(): (fingerprint: string | null) => void { describe('worktree scan admin-fingerprint gate', () => { beforeEach(() => { - getSshGitProviderMock.mockReset() listWorktreesStrictMock.mockReset() listWorktreesStrictMock.mockResolvedValue([ { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, @@ -326,17 +317,13 @@ describe('worktree scan admin-fingerprint gate', () => { it('never consults the probe for SSH repos', async () => { vi.useFakeTimers() try { - const listWorktrees = vi.fn(async () => [ - { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true } - ]) - getSshGitProviderMock.mockReturnValue({ listWorktrees }) const { list } = makeRuntime({ connectionId: 'ssh-remote-1' }) await list() vi.advanceTimersByTime(SCAN_TTL_MS + 1_000) await list() - expect(listWorktrees).toHaveBeenCalledTimes(2) + expect(listWorktreesStrictMock).not.toHaveBeenCalled() expect(readRepoWorktreeAdminFingerprintMock).not.toHaveBeenCalled() } finally { vi.useRealTimers() @@ -609,7 +596,6 @@ describe('worktree scan admin-fingerprint gate', () => { describe('scoped explicit worktree-id resolution', () => { beforeEach(() => { - getSshGitProviderMock.mockReset() listWorktreesStrictMock.mockReset() listWorktreesStrictMock.mockImplementation(async (repoPath: string) => [ { path: repoPath, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, diff --git a/src/main/runtime/worktree-scan-execution-host-routing.test.ts b/src/main/runtime/worktree-scan-execution-host-routing.test.ts index 9e40789da82..237a6b80e02 100644 --- a/src/main/runtime/worktree-scan-execution-host-routing.test.ts +++ b/src/main/runtime/worktree-scan-execution-host-routing.test.ts @@ -18,14 +18,6 @@ const electronMocks = vi.hoisted(() => { }) vi.mock('electron', () => electronMocks) -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: vi.fn(() => 0), - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable', - requireSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) -})) - const listWorktreesStrictMock = vi.hoisted(() => vi.fn()) vi.mock('../git/worktree', async (importOriginal) => ({ ...(await importOriginal>()), @@ -130,23 +122,15 @@ function makeRuntime(repoOverrides: Record): { describe('worktree scan execution-host routing', () => { beforeEach(() => { - getSshGitProviderMock.mockReset() listWorktreesStrictMock.mockReset() listWorktreesStrictMock.mockResolvedValue([]) }) - it('scans an executionHostId-only SSH repo over its SSH provider, not on the client', async () => { - const listWorktrees = vi.fn(async () => [ - { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, - { path: WORKTREE_PATH, head: 'def', branch: 'feature', isBare: false, isMainWorktree: false } - ]) - getSshGitProviderMock.mockReturnValue({ listWorktrees }) + it('lists an executionHostId-only SSH repo from its stored rows, not on the client', async () => { const { list } = makeRuntime({ executionHostId: `ssh:${TARGET_ID}` }) const worktrees = await list() - expect(getSshGitProviderMock).toHaveBeenCalledWith(TARGET_ID) - expect(listWorktrees).toHaveBeenCalledWith(REPO_PATH) // A client-side `git worktree list` against a remote path is the silent-substitution failure. expect(listWorktreesStrictMock).not.toHaveBeenCalled() expect(worktrees.map((worktree) => worktree.path).sort()).toEqual([REPO_PATH, WORKTREE_PATH]) @@ -154,18 +138,6 @@ describe('worktree scan execution-host routing', () => { }) it('routes the PTY of an executionHostId-only SSH worktree to its host', async () => { - getSshGitProviderMock.mockReturnValue({ - listWorktrees: async () => [ - { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, - { - path: WORKTREE_PATH, - head: 'def', - branch: 'feature', - isBare: false, - isMainWorktree: false - } - ] - }) const { runtime } = makeRuntime({ executionHostId: `ssh:${TARGET_ID}` }) const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' }) runtime.setPtyController({ @@ -183,18 +155,6 @@ describe('worktree scan execution-host routing', () => { }) it('still routes a legacy connectionId-only SSH repo the same way', async () => { - getSshGitProviderMock.mockReturnValue({ - listWorktrees: async () => [ - { path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, - { - path: WORKTREE_PATH, - head: 'def', - branch: 'feature', - isBare: false, - isMainWorktree: false - } - ] - }) const { runtime } = makeRuntime({ connectionId: TARGET_ID }) const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' }) runtime.setPtyController({ @@ -206,7 +166,6 @@ describe('worktree scan execution-host routing', () => { await runtime.createTerminal(`id:${WORKTREE_ID}`) - expect(getSshGitProviderMock).toHaveBeenCalledWith(TARGET_ID) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ connectionId: TARGET_ID, cwd: WORKTREE_PATH }) ) diff --git a/src/main/runtime/worktree-scan-repeated-path.test.ts b/src/main/runtime/worktree-scan-repeated-path.test.ts index a559e66a70c..8934f52fa41 100644 --- a/src/main/runtime/worktree-scan-repeated-path.test.ts +++ b/src/main/runtime/worktree-scan-repeated-path.test.ts @@ -25,12 +25,6 @@ vi.mock('./repo-worktree-admin-fingerprint', () => ({ readRepoWorktreeAdminFingerprint: vi.fn(async () => null) })) -const getSshGitProviderMock = vi.hoisted(() => vi.fn()) -vi.mock('../providers/ssh-git-dispatch', async (importOriginal) => ({ - ...(await importOriginal>()), - getSshGitProvider: getSshGitProviderMock -})) - import { OrcaRuntimeService } from './orca-runtime' import type { GitWorktreeInfo } from '../../shared/worktree/types' @@ -101,7 +95,6 @@ async function listedRows(runtime: OrcaRuntimeService): Promise { describe('worktree scan with a repeated path', () => { beforeEach(() => { localScanMock.mockReset() - getSshGitProviderMock.mockReset() }) describe('rows that agree, as reported', () => { @@ -149,15 +142,4 @@ describe('worktree scan with a repeated path', () => { await expect(listedRows(runtime)).resolves.toEqual([`${MAIN_WORKTREE_ID} refs/heads/dev_ops`]) await expect(runtime.showManagedWorktree('branch:stale')).rejects.toThrow('selector_not_found') }) - - it('lists an SSH repo the remote host repeats once', async () => { - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn(async () => [MAIN_ROW, { ...MAIN_ROW, isMainWorktree: false }]) - }) - - await expect(listedRows(makeRuntime({ connectionId: 'builder' }))).resolves.toEqual([ - `${MAIN_WORKTREE_ID} refs/heads/dev_ops` - ]) - expect(localScanMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/server/serve-readiness.test.ts b/src/main/server/serve-readiness.test.ts index 5d8c8f820e8..e60bd4f04db 100644 --- a/src/main/server/serve-readiness.test.ts +++ b/src/main/server/serve-readiness.test.ts @@ -60,25 +60,6 @@ describe('ServeReadinessPublisher', () => { ) }) - it('prints a verified phone link for mobile pairing and none for runtime pairing', () => { - const mobile: ServeReadiness = { - ...ready, - pairing: { - available: true, - url: 'orca://pair?code=secret', - endpoint: 'wss://orca.example.test/runtime', - deviceId: 'device-1', - webClientUrl: null, - scope: 'mobile', - qr: null - } - } - const human = renderServeReadiness(mobile, { mode: 'human' }) - expect(human).toContain('Pairing URL: orca://pair?code=secret') - expect(human).toContain('Phone pairing link: https://www.onorca.dev/pair#secret') - expect(renderServeReadiness(ready, { mode: 'human' })).not.toContain('Phone pairing link') - }) - it('publishes a versioned JSON contract with explicit endpoints and pairing availability', () => { expect(JSON.parse(renderServeReadiness(ready, { mode: 'json' }))).toEqual({ type: 'orca_server_ready', diff --git a/src/main/server/serve-readiness.ts b/src/main/server/serve-readiness.ts index 6296ba838f2..b0bdf14daba 100644 --- a/src/main/server/serve-readiness.ts +++ b/src/main/server/serve-readiness.ts @@ -1,6 +1,5 @@ import type { PairingOfferUnavailableReason } from '../runtime/runtime-rpc' import type { OrcadHealth } from '../orcad/orcad-health' -import { toVerifiedPairingLink } from '../../shared/pairing' export type ServePairingUnavailableReason = PairingOfferUnavailableReason | 'disabled_by_operator' @@ -120,12 +119,6 @@ function renderHumanReadiness(readiness: ServeReadiness): string { lines.push(`Mobile pairing QR:\n${readiness.pairing.qr}`) } lines.push(`Pairing URL: ${readiness.pairing.url}`) - // Why: tapping an `orca://` link on a phone hands the code to whichever app claimed the scheme. - const phoneLink = - readiness.pairing.scope === 'mobile' ? toVerifiedPairingLink(readiness.pairing.url) : null - if (phoneLink) { - lines.push(`Phone pairing link: ${phoneLink}`) - } } else { lines.push(`Pairing unavailable: ${readiness.pairing.reason}`) lines.push(`Pairing guidance: ${readiness.pairing.guidance}`) diff --git a/src/main/source-control/hosted-review-base-ref-suffix.test.ts b/src/main/source-control/hosted-review-base-ref-suffix.test.ts index fa20d8f94bc..ec8541fc45b 100644 --- a/src/main/source-control/hosted-review-base-ref-suffix.test.ts +++ b/src/main/source-control/hosted-review-base-ref-suffix.test.ts @@ -1,24 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn() +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('../github/gh-utils', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock -})) - import { baseRefExistsOnRemote } from './hosted-review-creation-git-state' describe('baseRefExistsOnRemote suffix fallback', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() }) it('does not answer a bare base with a nested branch of the same final segment', async () => { diff --git a/src/main/source-control/hosted-review-creation-eligibility.test.ts b/src/main/source-control/hosted-review-creation-eligibility.test.ts index fa0e02d5c55..294390b9d1f 100644 --- a/src/main/source-control/hosted-review-creation-eligibility.test.ts +++ b/src/main/source-control/hosted-review-creation-eligibility.test.ts @@ -17,7 +17,6 @@ const { glabExecFileAsyncMock, gitExecFileAsyncMock, getUpstreamStatusMock, - getSshGitProviderMock, getEnterpriseGitHubRepoSlugMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), @@ -36,7 +35,6 @@ const { glabExecFileAsyncMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), getUpstreamStatusMock: vi.fn(), - getSshGitProviderMock: vi.fn(), getEnterpriseGitHubRepoSlugMock: vi.fn() })) @@ -105,9 +103,9 @@ vi.mock('../gitlab/gl-utils', () => ({ vi.mock('../git/upstream', () => ({ getUpstreamStatus: getUpstreamStatusMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock })) - -vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) +vi.mock('./hosted-review', () => ({ + getHostedReviewForBranch: getHostedReviewForBranchMock +})) import { createHostedReview, getHostedReviewCreationEligibility } from './hosted-review-creation' import { baseRefExistsOnRemote } from './hosted-review-creation-git-state' @@ -141,7 +139,6 @@ function resetMocks(): void { glabExecFileAsyncMock, gitExecFileAsyncMock, getUpstreamStatusMock, - getSshGitProviderMock, getEnterpriseGitHubRepoSlugMock ]) { mock.mockReset() @@ -769,7 +766,6 @@ describe('getHostedReviewCreationEligibility', () => { stderr: '' })) } - getSshGitProviderMock.mockReturnValue(remoteGit) await expect( getHostedReviewCreationEligibility({ diff --git a/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts b/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts index d6ab34eaaf5..16b56b1b4c6 100644 --- a/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts +++ b/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts @@ -8,8 +8,7 @@ const { getBitbucketRepoSlugMock, getGiteaRepoSlugMock, getHostedReviewForBranchMock, - getRepoSlugMock, - getSshGitProviderMock + getRepoSlugMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), glabExecFileAsyncMock: vi.fn(), @@ -18,8 +17,7 @@ const { getBitbucketRepoSlugMock: vi.fn(), getGiteaRepoSlugMock: vi.fn(), getHostedReviewForBranchMock: vi.fn(), - getRepoSlugMock: vi.fn(), - getSshGitProviderMock: vi.fn() + getRepoSlugMock: vi.fn() })) vi.mock('../git/runner', () => ({ @@ -53,10 +51,6 @@ vi.mock('../gitea/client', () => ({ getGiteaPullRequest: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock -})) - vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) @@ -75,8 +69,7 @@ function resetMocks(): void { getBitbucketRepoSlugMock, getGiteaRepoSlugMock, getHostedReviewForBranchMock, - getRepoSlugMock, - getSshGitProviderMock + getRepoSlugMock ]) { mock.mockReset() } diff --git a/src/main/source-control/hosted-review-creation-shared-symlinks.test.ts b/src/main/source-control/hosted-review-creation-shared-symlinks.test.ts index cafe2685b5e..5be3918c1a1 100644 --- a/src/main/source-control/hosted-review-creation-shared-symlinks.test.ts +++ b/src/main/source-control/hosted-review-creation-shared-symlinks.test.ts @@ -15,8 +15,7 @@ const { ghExecFileAsyncMock, glabExecFileAsyncMock, gitExecFileAsyncMock, - getUpstreamStatusMock, - getSshGitProviderMock + getUpstreamStatusMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), getRepoSlugMock: vi.fn(), @@ -29,8 +28,7 @@ const { ghExecFileAsyncMock: vi.fn(), glabExecFileAsyncMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), - getUpstreamStatusMock: vi.fn(), - getSshGitProviderMock: vi.fn() + getUpstreamStatusMock: vi.fn() })) vi.mock('../github/client', () => ({ @@ -82,9 +80,12 @@ vi.mock('../gitlab/gl-utils', () => ({ glabExecFileAsync: glabExecFileAsyncMock, glabRepoExecOptions: (repoPath: string) => ({ cwd: repoPath }) })) -vi.mock('../git/upstream', () => ({ getUpstreamStatus: getUpstreamStatusMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock })) -vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) +vi.mock('../git/upstream', () => ({ + getUpstreamStatus: getUpstreamStatusMock +})) +vi.mock('./hosted-review', () => ({ + getHostedReviewForBranch: getHostedReviewForBranchMock +})) import { createHostedReview } from './hosted-review-creation' @@ -123,8 +124,7 @@ describe('createHostedReview with shared symlinks', () => { ghExecFileAsyncMock, glabExecFileAsyncMock, gitExecFileAsyncMock, - getUpstreamStatusMock, - getSshGitProviderMock + getUpstreamStatusMock ]) { mock.mockReset() } diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index 8ce2eae3459..eb40dd27810 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -17,7 +17,6 @@ const { glabExecFileAsyncMock, gitExecFileAsyncMock, getUpstreamStatusMock, - getSshGitProviderMock, getEnterpriseGitHubRepoSlugMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), @@ -36,7 +35,6 @@ const { glabExecFileAsyncMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), getUpstreamStatusMock: vi.fn(), - getSshGitProviderMock: vi.fn(), getEnterpriseGitHubRepoSlugMock: vi.fn() })) @@ -107,10 +105,6 @@ vi.mock('../git/upstream', () => ({ getUpstreamStatus: getUpstreamStatusMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock -})) - vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) @@ -143,7 +137,6 @@ function resetMocks(): void { glabExecFileAsyncMock, gitExecFileAsyncMock, getUpstreamStatusMock, - getSshGitProviderMock, getEnterpriseGitHubRepoSlugMock ]) { mock.mockReset() diff --git a/src/main/source-control/hosted-review-execution-host-routing.test.ts b/src/main/source-control/hosted-review-execution-host-routing.test.ts index 1a84f5909bb..2a104a0001f 100644 --- a/src/main/source-control/hosted-review-execution-host-routing.test.ts +++ b/src/main/source-control/hosted-review-execution-host-routing.test.ts @@ -11,7 +11,6 @@ import type { Repo } from '../../shared/repo-types' */ const { - getSshGitProviderMock, gitExecFileAsyncMock, ghExecFileAsyncMock, glabExecFileAsyncMock, @@ -36,7 +35,6 @@ const { probeAnyExactRefMock, assertRemoteUrlReadableMock } = vi.hoisted(() => ({ - getSshGitProviderMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), ghExecFileAsyncMock: vi.fn(), glabExecFileAsyncMock: vi.fn(), @@ -62,11 +60,6 @@ const { assertRemoteUrlReadableMock: vi.fn() })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH git provider unavailable' -})) - vi.mock('../github/gh-utils', () => ({ gitExecFileAsync: gitExecFileAsyncMock, ghExecFileAsync: ghExecFileAsyncMock, @@ -230,7 +223,6 @@ beforeEach(() => { sshProviders.clear() sshProviders.set('ssh-a', makeSshProvider()) sshProviders.set('ssh-b', makeSshProvider()) - getSshGitProviderMock.mockImplementation((id: string) => sshProviders.get(id) ?? null) gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => ({ stdout: argv[0] === 'status' ? '' : 'feature\n', @@ -310,7 +302,6 @@ describe('hosted-review lookups route on the resolved execution host', () => { await commands.getHostedReviewForBranch({ repoSelector: 'repo-1', branch: 'feature' }) expect(getBitbucketRepoSlugMock).toHaveBeenCalledWith(REPO_PATH, null) - expect(getSshGitProviderMock).not.toHaveBeenCalledWith('ssh-b') }) it('keeps a runtime row with no nested target on this process (self-addressed stamp)', async () => { diff --git a/src/main/source-control/pull-request-template.ts b/src/main/source-control/pull-request-template.ts index e5f474a4958..631c3ac0f60 100644 --- a/src/main/source-control/pull-request-template.ts +++ b/src/main/source-control/pull-request-template.ts @@ -1,8 +1,6 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import type { HostedReviewProvider } from '../../shared/hosted-review' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' const PULL_REQUEST_TEMPLATE_CANDIDATES = [ '.github/pull_request_template.md', @@ -43,21 +41,11 @@ export async function readHostedReviewTemplate( connectionId?: string | null, provider?: HostedReviewProvider | null ): Promise { - const remoteProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined - if (connectionId && !remoteProvider) { + if (connectionId) { return '' } for (const relativeCandidate of getTemplateCandidates(provider)) { try { - if (remoteProvider) { - const result = await remoteProvider.readFile( - joinWorktreeRelativePath(repoPath, relativeCandidate) - ) - if (result.isBinary) { - continue - } - return result.content - } return await readFile(join(repoPath, relativeCandidate), 'utf8') } catch { // Try the next conventional hosted-review template path. diff --git a/src/main/source-control/repo-default-branch.test.ts b/src/main/source-control/repo-default-branch.test.ts index 02ea27b1e94..5fe1862e4ac 100644 --- a/src/main/source-control/repo-default-branch.test.ts +++ b/src/main/source-control/repo-default-branch.test.ts @@ -1,19 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn() +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: vi.fn(() => 0) -})) - import { getRepoDefaultBranchName, shouldHideNonOpenReviewOnDefaultBranch, @@ -32,7 +26,6 @@ function primeLocalGitExec(defaultRef = 'refs/remotes/origin/master'): void { describe('getRepoDefaultBranchName', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() __resetRepoDefaultBranchCacheForTests() }) @@ -60,28 +53,6 @@ describe('getRepoDefaultBranchName', () => { ) }) - it('routes resolution through the SSH provider exec and never local git', async () => { - const provider = { - exec: vi.fn(async (args: string[], repoPath: string) => { - expect(repoPath).toBe('/remote/repo') - if (args[0] === 'for-each-ref') { - return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/trunk\n' } - } - return { stdout: 'oid\n' } - }) - } - getSshGitProviderMock.mockReturnValue(provider) - - await expect(getRepoDefaultBranchName('/remote/repo', 'ssh-1')).resolves.toBe('trunk') - expect(getSshGitProviderMock).toHaveBeenCalledWith('ssh-1') - expect(provider.exec).toHaveBeenCalledWith( - expect.arrayContaining(['for-each-ref', '--format=%(refname)%00%(symref)']), - '/remote/repo', - { timeoutMs: expect.any(Number) } - ) - expect(gitExecFileAsyncMock).not.toHaveBeenCalled() - }) - it('shares one wall-clock timeout budget across all fallback probes', async () => { let now = 1_000 const dateNow = vi.spyOn(Date, 'now').mockImplementation(() => now) @@ -106,8 +77,6 @@ describe('getRepoDefaultBranchName', () => { }) it('returns null without running local git when the SSH provider is unavailable', async () => { - getSshGitProviderMock.mockReturnValue(undefined) - await expect(getRepoDefaultBranchName('/remote/repo', 'ssh-gone')).resolves.toBeNull() expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -168,7 +137,6 @@ describe('getRepoDefaultBranchName', () => { describe('shouldHideNonOpenReviewOnDefaultBranch', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() __resetRepoDefaultBranchCacheForTests() }) diff --git a/src/main/source-control/repo-default-branch.ts b/src/main/source-control/repo-default-branch.ts index 8a45ba7f13a..7aa6bf02fba 100644 --- a/src/main/source-control/repo-default-branch.ts +++ b/src/main/source-control/repo-default-branch.ts @@ -1,7 +1,6 @@ import { resolveDefaultBaseRefViaExec } from '../git/repo' import { resolveRemoteHeadBranchViaExec, type GitExec } from '../../shared/git-default-base-ref' import { gitExecFileAsync } from '../git/runner' -import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { getRepoExecutionHostId } from '../../shared/execution-host' import { hostedReviewRepoScope, scopeGeneration } from './hosted-review-scope-generations' import type { HostedReviewLocalGitOptions } from './hosted-review-git-options' @@ -31,12 +30,7 @@ function getRepoDefaultBranchCacheKey( ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}` const scope = hostedReviewRepoScope(repoPath, getRepoExecutionHostId({ connectionId })) - return [ - runtimeKey, - repoPath, - scopeGeneration(scope), - connectionId ? getSshGitProviderGeneration(connectionId) : 0 - ].join('\0') + return [runtimeKey, repoPath, scopeGeneration(scope)].join('\0') } function pruneRepoDefaultBranchCache(now: number): void { @@ -54,36 +48,30 @@ function pruneRepoDefaultBranchCache(now: number): void { } } -/** Git over the transport the hosted-review call uses; null when the SSH provider is gone. */ +/** Local git for the hosted-review call; null for an SSH repo, which this process cannot reach. */ function createRepoGitExec( repoPath: string, connectionId: string | null | undefined, localGitOptions: HostedReviewLocalGitOptions ): GitExec | null { - const provider = connectionId ? getSshGitProvider(connectionId) : null - if (connectionId && !provider) { - // Why: a dropped SSH provider must not fall back to local git — the - // repoPath is remote, so a local run could answer for the wrong repo. + if (connectionId) { + // Why: the repoPath is remote, so a local run could answer for the wrong repo. return null } const resolutionDeadline = Date.now() + REPO_DEFAULT_BRANCH_RESOLUTION_BUDGET_MS // Why: the resolver can try five refs; share one deadline so an unhealthy - // local/WSL/SSH host cannot multiply the refresh delay per fallback probe. + // local/WSL host cannot multiply the refresh delay per fallback probe. return (argv) => { const timeoutMs = resolutionDeadline - Date.now() if (timeoutMs <= 0) { return Promise.reject(new Error('Default branch resolution timed out.')) } - return provider - ? provider.exec(argv, repoPath, { timeoutMs }) - : gitExecFileAsync(argv, { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - ...(localGitOptions.admissionTier - ? { admissionTier: localGitOptions.admissionTier } - : {}), - timeout: timeoutMs - }) + return gitExecFileAsync(argv, { + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + ...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {}), + timeout: timeoutMs + }) } } diff --git a/src/main/source-control/repo-remote-head-branch.test.ts b/src/main/source-control/repo-remote-head-branch.test.ts index 6c223844235..b5e05e9f419 100644 --- a/src/main/source-control/repo-remote-head-branch.test.ts +++ b/src/main/source-control/repo-remote-head-branch.test.ts @@ -1,18 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, getSshGitProviderMock, getSshGitProviderGenerationMock } = vi.hoisted( - () => ({ - gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn(), - getSshGitProviderGenerationMock: vi.fn(() => 0) - }) -) +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -vi.mock('../providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock, - getSshGitProviderGeneration: getSshGitProviderGenerationMock -})) import { bumpScopeGeneration, hostedReviewRepoScope } from './hosted-review-scope-generations' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -30,8 +22,6 @@ function remoteHead(remote: string, branch: string): { stdout: string; stderr: s describe('getRemoteHeadBranchName', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() - getSshGitProviderMock.mockReset() - getSshGitProviderGenerationMock.mockReset().mockReturnValue(0) __resetRepoDefaultBranchCacheForTests() }) @@ -90,26 +80,7 @@ describe('getRemoteHeadBranchName', () => { ]) }) - it('isolates native, WSL and SSH results for the same path and remote', async () => { - gitExecFileAsyncMock.mockImplementation(async (_args, options: { wslDistro?: string }) => - remoteHead('upstream', options.wslDistro ? 'wsl-default' : 'native-default') - ) - const exec = vi.fn().mockResolvedValue(remoteHead('upstream', 'ssh-default')) - getSshGitProviderMock.mockReturnValue({ exec }) - await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBe('native-default') - await expect( - getRemoteHeadBranchName('/repo', 'upstream', null, { wslDistro: 'Ubuntu' }) - ).resolves.toBe('wsl-default') - await expect(getRemoteHeadBranchName('/repo', 'upstream', 'ssh-1')).resolves.toBe('ssh-default') - expect(exec).toHaveBeenCalledWith( - ['for-each-ref', '--format=%(refname)%00%(symref)', 'refs/remotes/upstream/HEA[D]'], - '/repo', - { timeoutMs: expect.any(Number) } - ) - }) - it('does not run local Git for a disconnected SSH host', async () => { - getSshGitProviderMock.mockReturnValue(undefined) await expect(getRemoteHeadBranchName('/repo', 'upstream', 'ssh-gone')).resolves.toBeNull() expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -131,17 +102,6 @@ describe('getRemoteHeadBranchName', () => { } }) - it('does not reuse defaults from a replaced SSH provider', async () => { - getSshGitProviderGenerationMock.mockReturnValue(1) - const exec = vi.fn().mockResolvedValue(remoteHead('upstream', 'old-default')) - getSshGitProviderMock.mockReturnValue({ exec }) - await expect(getRemoteHeadBranchName('/repo', 'upstream', 'ssh-1')).resolves.toBe('old-default') - getSshGitProviderGenerationMock.mockReturnValue(2) - exec.mockResolvedValue(remoteHead('upstream', 'new-default')) - await expect(getRemoteHeadBranchName('/repo', 'upstream', 'ssh-1')).resolves.toBe('new-default') - expect(exec).toHaveBeenCalledTimes(2) - }) - it('refreshes defaults after the repository review scope is invalidated', async () => { gitExecFileAsyncMock.mockResolvedValue(remoteHead('upstream', 'old-default')) await expect(getRemoteHeadBranchName('/repo', 'upstream')).resolves.toBe('old-default') diff --git a/src/main/ssh/orcad-migration-cutover-coordinator.test.ts b/src/main/ssh/orcad-migration-cutover-coordinator.test.ts index 770d9cfdca3..197fcf94f42 100644 --- a/src/main/ssh/orcad-migration-cutover-coordinator.test.ts +++ b/src/main/ssh/orcad-migration-cutover-coordinator.test.ts @@ -57,8 +57,7 @@ async function setup() { targetId: TARGET.id, destinationEnvironmentId: 'env-1', destinationName: 'Managed', - terminalProof: { verdict: 'exited', provenPtyIds: [] }, - hasDirectSshAuthority: () => false + terminalProof: { verdict: 'exited', provenPtyIds: [] } }) if (fenced.outcome !== 'fenced') { throw new Error('expected a fence') diff --git a/src/main/ssh/orcad-migration-delta-move.test.ts b/src/main/ssh/orcad-migration-delta-move.test.ts index bd566295e0c..d553f1a0dd3 100644 --- a/src/main/ssh/orcad-migration-delta-move.test.ts +++ b/src/main/ssh/orcad-migration-delta-move.test.ts @@ -36,8 +36,7 @@ const mocks = vi.hoisted(() => { }) vi.mock('./ssh-target-registry', () => ({ getSshConnectionManager: () => ({}), - getSshTargetRegistryStore: () => mocks.state.targetStore, - hasRegisteredDirectSshAuthority: () => false + getSshTargetRegistryStore: () => mocks.state.targetStore })) vi.mock('./orcad-runtime-deployment', () => ({ createManagedOrcadEnvironment: mocks.deploy })) vi.mock('./orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: mocks.ensureTunnel })) @@ -120,7 +119,6 @@ async function convertedThenChangedOnOlderBuild(): Promise { name: 'Managed', censusHost: async () => ({ verdict: 'exited', count: 0 }), destinationFor: () => destination, - releaseDirectSession: async () => {}, now }) ).resolves.toMatchObject({ outcome: 'converted' }) @@ -140,7 +138,6 @@ function deltaMove(at: () => Date = now) { environment: listEnvironments(userDataPath)[0]!, destination, censusHost: async () => ({ verdict: 'exited', count: 0 }), - releaseDirectSession: async () => {}, ensureTunnel: async () => {}, runTargetLifecycle, now: at @@ -492,7 +489,6 @@ async function convertKeepingSource(): Promise { name: 'Managed', censusHost: async () => ({ verdict: 'exited', count: 0 }), destinationFor: () => destination, - releaseDirectSession: async () => {}, now }) ).resolves.toMatchObject({ outcome: 'converted' }) diff --git a/src/main/ssh/orcad-migration-delta-move.ts b/src/main/ssh/orcad-migration-delta-move.ts index e84a7da8895..b472d8587e9 100644 --- a/src/main/ssh/orcad-migration-delta-move.ts +++ b/src/main/ssh/orcad-migration-delta-move.ts @@ -55,8 +55,6 @@ export type OrcadDeltaMoveArgs = { destination: OrcadMigrationDestinationCatalog /** The census of the host's relay endpoints that must prove exit. */ censusHost?: CensusHostRelayTerminals | null - /** Releases the relay session after the terminal check, as a conversion does. */ - releaseDirectSession: (sshTargetId: string) => Promise ensureTunnel: () => Promise /** Serializes the journal write through the commit with every other change to this host. */ runTargetLifecycle: (targetId: string, operation: () => Promise) => Promise @@ -83,7 +81,6 @@ export async function runOrcadDeltaMove(args: OrcadDeltaMoveArgs): Promise { // Why re-checked: another move of this host may have journaled or finished while this waited. const changedAt = store.getSshTarget(target.id)?.orcadFence?.sourceChangedAt diff --git a/src/main/ssh/orcad-migration-delta-snapshot.test.ts b/src/main/ssh/orcad-migration-delta-snapshot.test.ts index 7818596f228..ef2a2224d98 100644 --- a/src/main/ssh/orcad-migration-delta-snapshot.test.ts +++ b/src/main/ssh/orcad-migration-delta-snapshot.test.ts @@ -24,8 +24,7 @@ const mocks = vi.hoisted(() => { }) vi.mock('./ssh-target-registry', () => ({ getSshConnectionManager: () => ({}), - getSshTargetRegistryStore: () => mocks.state.targetStore, - hasRegisteredDirectSshAuthority: () => false + getSshTargetRegistryStore: () => mocks.state.targetStore })) vi.mock('./orcad-runtime-deployment', () => ({ createManagedOrcadEnvironment: mocks.deploy })) vi.mock('./orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: mocks.ensureTunnel })) @@ -136,7 +135,6 @@ async function convertedThenAdded(addOnOlderBuild: () => string): Promise ({ verdict: 'exited', count: 0 }), destinationFor: () => destination, - releaseDirectSession: async () => {}, now }) ).resolves.toMatchObject({ outcome: 'converted' }) @@ -160,7 +158,6 @@ function deltaMoveTypingDuringTerminalCheck(whileChecking: () => void) { whileChecking() return { verdict: 'exited', count: 0 } }, - releaseDirectSession: async () => {}, ensureTunnel: async () => {}, runTargetLifecycle, now diff --git a/src/main/ssh/orcad-migration-source-fence.test.ts b/src/main/ssh/orcad-migration-source-fence.test.ts index 2c6444603ff..bf7b1019f65 100644 --- a/src/main/ssh/orcad-migration-source-fence.test.ts +++ b/src/main/ssh/orcad-migration-source-fence.test.ts @@ -60,7 +60,6 @@ function setup() { destinationEnvironmentId: 'env-1', destinationName: 'Managed', terminalProof: exited, - hasDirectSshAuthority: () => false, ...overrides }) const target = () => store.getSshTarget(TARGET.id)! @@ -180,14 +179,6 @@ describe('migration source fence', () => { expect(listOrcadMigrationSourceCutovers(harness.userDataPath)).toEqual([]) }) - it('refuses while the host is still connected directly', async () => { - const harness = setup() - await expect(harness.fence({ hasDirectSshAuthority: () => true })).resolves.toMatchObject({ - code: 'orcad_migration_direct_ssh_connected' - }) - expect(harness.target().orcadFence).toBeUndefined() - }) - it('keeps a blocker it could not read unverifiable, never live', async () => { const harness = setup() vi.spyOn(harness.store, 'inspectOrcadMigrationUntransferredDependencies').mockImplementation( diff --git a/src/main/ssh/orcad-migration-source-fence.ts b/src/main/ssh/orcad-migration-source-fence.ts index 4500d3dde69..10c0513770a 100644 --- a/src/main/ssh/orcad-migration-source-fence.ts +++ b/src/main/ssh/orcad-migration-source-fence.ts @@ -83,7 +83,6 @@ export async function fenceOrcadMigrationSource(args: { destinationName: string /** Taken by the caller while the relay could still answer; see orcad-migration-terminal-gate. */ terminalProof: OrcadMigrationTerminalVerdict - hasDirectSshAuthority: (targetId: string) => boolean now?: () => Date signal?: AbortSignal }): Promise { @@ -111,9 +110,6 @@ export async function fenceOrcadMigrationSource(args: { args.terminalProof.reason ) } - if (args.hasDirectSshAuthority(target.id)) { - return refuse('live', 'orcad_migration_direct_ssh_connected', 'Disconnect this SSH host first.') - } const preflight = preflightOrcadMigrationExport(args.store, target.id) if (!preflight.claimable) { return { diff --git a/src/main/ssh/orcad-retained-source-lists.test.ts b/src/main/ssh/orcad-retained-source-lists.test.ts index 3c9f2903e99..6ada0e73d81 100644 --- a/src/main/ssh/orcad-retained-source-lists.test.ts +++ b/src/main/ssh/orcad-retained-source-lists.test.ts @@ -6,7 +6,6 @@ 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 { SshTarget } from '../../shared/ssh-types' -import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission' import type { Store } from '../persistence' import { orcadMigrationCutoverFixture } from './orcad-migration-cutover-fixture' import { writeOrcadMigrationSourceCutover } from './orcad-migration-cutover-journal' @@ -15,13 +14,9 @@ const userData = vi.hoisted(() => ({ dir: '' })) vi.mock('../../shared/app-environment', () => ({ getAppEnvironment: () => ({ getPath: () => userData.dir }) })) -vi.mock('./ssh-provider-authority', () => ({ isCurrentSshProviderAuthority: () => true })) -const provider = {} -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: () => provider })) const { isFrozenOrcadSourceSessionPartition, visibleProjectGroups } = await import('./orcad-retained-source') -const { listReposForExecutionHost } = await import('../ipc/repos/host-repo-catalog-snapshot') const FENCED: SshTarget = { id: 'ssh-box', @@ -125,16 +120,4 @@ describe('lists while a converted host keeps its source rows', () => { rmSync(userDataPath, { recursive: true, force: true }) } }) - - it('leaves the source repos out of the host catalog the SSH bridge hydrates from', async () => { - const authority: unknown = { targetId: FENCED.id, providerEpoch: 'e1', connectionGeneration: 1 } - if (!isAdmissibleDirectSshAuthority(authority)) { - throw new Error('fixture authority rejected') - } - const snapshot = await listReposForExecutionHost(catalog([FENCED]), { - executionHostId: `ssh:${FENCED.id}`, - expectedAuthority: authority - }) - expect(snapshot).toMatchObject({ authoritative: true, repos: [] }) - }) }) diff --git a/src/main/ssh/orcad-runtime-conversion.test.ts b/src/main/ssh/orcad-runtime-conversion.test.ts index 87529c7a990..5e2caf36c71 100644 --- a/src/main/ssh/orcad-runtime-conversion.test.ts +++ b/src/main/ssh/orcad-runtime-conversion.test.ts @@ -20,12 +20,11 @@ import { SshConnectionStore } from './ssh-connection-store' const mocks = vi.hoisted(() => { const state: { targetStore: unknown } = { targetStore: null } - return { state, deploy: vi.fn(), ensureTunnel: vi.fn(), directAuthority: vi.fn() } + return { state, deploy: vi.fn(), ensureTunnel: vi.fn() } }) vi.mock('./ssh-target-registry', () => ({ getSshConnectionManager: () => ({}), - getSshTargetRegistryStore: () => mocks.state.targetStore, - hasRegisteredDirectSshAuthority: mocks.directAuthority + getSshTargetRegistryStore: () => mocks.state.targetStore })) vi.mock('./orcad-runtime-deployment', () => ({ createManagedOrcadEnvironment: mocks.deploy })) vi.mock('./orcad-managed-tunnel', () => ({ @@ -66,7 +65,6 @@ beforeEach(() => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SshConnectionStore wraps the real test store it is given. mocks.state.targetStore = new SshConnectionStore(store as never) destination = fakeOrcadMigrationDestination() - mocks.directAuthority.mockReturnValue(false) mocks.ensureTunnel.mockResolvedValue(undefined) // Registers the server the fence named, as the real deploy would after pairing. mocks.deploy.mockImplementation(async (path: string, args: { name: string }) => { diff --git a/src/main/ssh/orcad-runtime-conversion.ts b/src/main/ssh/orcad-runtime-conversion.ts index 4167ff22a7d..7691e7e5450 100644 --- a/src/main/ssh/orcad-runtime-conversion.ts +++ b/src/main/ssh/orcad-runtime-conversion.ts @@ -37,7 +37,6 @@ import { } from './orcad-migration-terminal-gate' import { createManagedOrcadEnvironment } from './orcad-runtime-deployment' import { retainOrcadMigrationSource } from './orcad-migration-source-retention' -import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' export type OrcadManagedConversionArgs = { sshTargetId: string @@ -47,7 +46,7 @@ export type OrcadManagedConversionArgs = { /** The destination's T6-9 catalog client, reached through the server's tunnel. */ destinationFor: (environment: KnownRuntimeEnvironment) => OrcadMigrationDestinationCatalog /** Releases the direct SSH session after the terminal check, before the fence. */ - releaseDirectSession: (sshTargetId: string) => Promise + releaseDirectSession?: (sshTargetId: string) => Promise now?: () => Date signal?: AbortSignal } @@ -154,7 +153,7 @@ async function fenceOrResume( return refuse(terminalProof.verdict, 'orcad_migration_terminals', terminalProof.reason) } retireProvenExitedLeases(store, target.id, terminalProof) - await args.releaseDirectSession(target.id) + await args.releaseDirectSession?.(target.id) const result = await runTargetLifecycle(target.id, () => fenceOrcadMigrationSource({ userDataPath, @@ -164,7 +163,6 @@ async function fenceOrResume( destinationEnvironmentId: randomUUID(), destinationName: args.name, terminalProof, - hasDirectSshAuthority: hasRegisteredDirectSshAuthority, now: args.now, signal: args.signal }) diff --git a/src/main/ssh/orcad-runtime-deployment.test.ts b/src/main/ssh/orcad-runtime-deployment.test.ts index ba5072dca31..f143ab80363 100644 --- a/src/main/ssh/orcad-runtime-deployment.test.ts +++ b/src/main/ssh/orcad-runtime-deployment.test.ts @@ -26,7 +26,6 @@ const mocks = vi.hoisted(() => { state, events: new Array(), connect: vi.fn(), - hasDirectAuthority: vi.fn(), resolveContext: vi.fn(), recover: vi.fn(), readRecord: vi.fn(), @@ -41,8 +40,7 @@ const mocks = vi.hoisted(() => { vi.mock('./ssh-target-registry', () => ({ getSshConnectionManager: () => ({ connect: mocks.connect }), - getSshTargetRegistryStore: () => mocks.state.store, - hasRegisteredDirectSshAuthority: mocks.hasDirectAuthority + getSshTargetRegistryStore: () => mocks.state.store })) vi.mock('./orcad-remote-context', () => ({ resolveOrcadRemoteContext: mocks.resolveContext })) vi.mock('./orcad-activation-recovery', () => ({ recoverInterruptedOrcadActivation: mocks.recover })) @@ -133,7 +131,6 @@ beforeEach(() => { userDataPath = mkdtempSync(join(tmpdir(), 'orcad-deployment-test-')) target = { id: 'ssh-1', label: 'Builder', host: 'builder', port: 22, username: 'dev' } setupStore() - mocks.hasDirectAuthority.mockReturnValue(false) mocks.connect.mockImplementation(async () => { mocks.events.push('connect') return {} @@ -312,9 +309,7 @@ describe('createManagedOrcadEnvironment', () => { expect(mocks.connect).not.toHaveBeenCalled() }) - it('refuses a connected direct SSH session and a taken server name', async () => { - mocks.hasDirectAuthority.mockReturnValueOnce(true) - await expect(deploy()).rejects.toThrow('Disconnect this SSH host') + it('refuses a taken server name', async () => { await deploy() target = { ...target, id: 'ssh-2', orcadFence: undefined } await expect( diff --git a/src/main/ssh/orcad-runtime-deployment.ts b/src/main/ssh/orcad-runtime-deployment.ts index c44440d90c9..307a266fdec 100644 --- a/src/main/ssh/orcad-runtime-deployment.ts +++ b/src/main/ssh/orcad-runtime-deployment.ts @@ -29,7 +29,6 @@ import { refuseOrcadHostDowngrade } from './orcad-host-version-admission' import { ORCAD_ACTIVATION_POLICY_REFUSED_CODE } from './orcad-installed-activation' import { pruneManagedOrcadVersions } from './orcad-managed-version-gc' import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' -import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' import { resolveOrcadMigrationFence } from './orcad-migration-source-fence' import type { SshTarget } from '../../shared/ssh-types' import { deployedOrcadTunnelChecks } from './orcad-managed-tunnel-identity' @@ -66,9 +65,6 @@ export async function createManagedOrcadEnvironment( if (environments.some((entry) => entry.id !== environmentId && entry.name === args.name)) { throw new Error(`A server named "${args.name}" already exists.`) } - if (hasRegisteredDirectSshAuthority(args.sshTargetId)) { - throw new Error('Disconnect this SSH host before converting it to a managed Orca server.') - } const current = targetStore.getTarget(args.sshTargetId) if (args.migration) { assertMigrationFence(userDataPath, current, environmentId, args.name) diff --git a/src/main/ssh/orcad-unreachable-setup-release.test.ts b/src/main/ssh/orcad-unreachable-setup-release.test.ts index b4aedd774a0..ec284b68cf9 100644 --- a/src/main/ssh/orcad-unreachable-setup-release.test.ts +++ b/src/main/ssh/orcad-unreachable-setup-release.test.ts @@ -57,8 +57,7 @@ async function strandedConversion() { targetId: TARGET.id, destinationEnvironmentId: 'env-1', destinationName: 'Managed', - terminalProof: { verdict: 'exited', provenPtyIds: [] }, - hasDirectSshAuthority: () => false + terminalProof: { verdict: 'exited', provenPtyIds: [] } }) if (fenced.outcome !== 'fenced') { throw new Error('expected a fence') diff --git a/src/main/ssh/relay-protocol.test.ts b/src/main/ssh/relay-protocol.test.ts index 6a63bef5b50..49e9f0d8f56 100644 --- a/src/main/ssh/relay-protocol.test.ts +++ b/src/main/ssh/relay-protocol.test.ts @@ -8,27 +8,10 @@ import { FrameDecoder, parseJsonRpcMessage, parseUnameToRelayPlatform, - isGitResponseStreamMarker, type JsonRpcRequest, type DecodedFrame } from './relay-protocol' -describe('git response stream marker', () => { - it('accepts only complete non-negative integer metadata', () => { - expect( - isGitResponseStreamMarker({ - __orcaGitResponseStream: { streamId: 1, totalBytes: 1024, chunkCount: 2 } - }) - ).toBe(true) - expect(isGitResponseStreamMarker({ __orcaGitResponseStream: {} })).toBe(false) - expect( - isGitResponseStreamMarker({ - __orcaGitResponseStream: { streamId: -1, totalBytes: 1024, chunkCount: 2 } - }) - ).toBe(false) - }) -}) - describe('frame encoding', () => { it('encodes a frame with 13-byte header', () => { const payload = Buffer.from('hello') diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index f675ac6ed9d..633ab126bb7 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -36,61 +36,10 @@ export const MessageType = { KeepAlive: 9 } as const -/** Keepalive/timeout (VS Code ProtocolConstants). */ +/** Keepalive/timeout. */ export const KEEPALIVE_SEND_MS = 5_000 export const TIMEOUT_MS = 20_000 -// ── Relay error codes ─────────────────────────────────────────────── - -export const RelayErrorCode = { - CommandNotFound: -33001, - PermissionDenied: -33002, - PathNotFound: -33003, - PtyAllocationFailed: -33004, - DiskFull: -33005, - TooManyStreams: -33006, - StreamProtocolError: -33007 -} as const - -export const JsonRpcErrorCode = { - MethodNotFound: -32601 -} as const - -// ── Streaming constants (see docs/relay-file-stream-design.md) ───── - -/** Per-chunk payload size for fs.readFileStream. Mirrors VS Code's - * `bufferSize: 256 * 1024` (vs/platform/files/node/diskFileSystemProvider.ts). - * 256KB raw → ~340KB base64, well under MAX_MESSAGE_SIZE. */ -export const STREAM_CHUNK_SIZE = 256 * 1024 - -// ── Git response streaming (see docs/relay-git-response-stream-design.md) ── - -/** Sentinel the relay returns as the RPC result when the real payload streams - * as git.responseChunk frames. Absent from old relays, so a new client falls - * back to the plain result they return. */ -export type GitResponseStreamMarker = { - __orcaGitResponseStream: { streamId: number; totalBytes: number; chunkCount: number } -} - -export function isGitResponseStreamMarker(value: unknown): value is GitResponseStreamMarker { - if (typeof value !== 'object' || value === null || !('__orcaGitResponseStream' in value)) { - return false - } - const marker = (value as { __orcaGitResponseStream?: unknown }).__orcaGitResponseStream - if (typeof marker !== 'object' || marker === null) { - return false - } - const fields = marker as Record - return ( - Number.isInteger(fields.streamId) && - (fields.streamId as number) > 0 && - Number.isInteger(fields.totalBytes) && - (fields.totalBytes as number) >= 0 && - Number.isInteger(fields.chunkCount) && - (fields.chunkCount as number) >= 0 - ) -} - // ── JSON-RPC types ────────────────────────────────────────────────── export type JsonRpcRequest = { diff --git a/src/main/ssh/runtime-ssh-access.test.ts b/src/main/ssh/runtime-ssh-access.test.ts index 229e261d934..8a13d00565f 100644 --- a/src/main/ssh/runtime-ssh-access.test.ts +++ b/src/main/ssh/runtime-ssh-access.test.ts @@ -21,8 +21,7 @@ const mocks = vi.hoisted(() => ({ ensure: vi.fn(), verify: vi.fn(), flush: vi.fn(), - preflight: vi.fn(), - hasDirectAuthority: vi.fn() + preflight: vi.fn() })) vi.mock('./orcad-managed-runtime-context', () => ({ requireManagedOrcadInfrastructure: mocks.infrastructure @@ -35,9 +34,6 @@ vi.mock('./orcad-managed-tunnel', () => ({ vi.mock('./runtime-ssh-access-verification', () => ({ verifyRuntimeEnvironmentSshTunnel: mocks.verify })) -vi.mock('./ssh-target-registry', () => ({ - hasRegisteredDirectSshAuthority: mocks.hasDirectAuthority -})) import { fingerprintRuntimeSshTarget, linkRuntimeSshAccess, @@ -364,15 +360,6 @@ describe('independent runtime SSH access coordinator', () => { expect(mocks.connect).not.toHaveBeenCalled() }) - it('refuses a connected direct provider even when it has no catalog or leases', async () => { - mocks.hasDirectAuthority.mockReturnValue(true) - await expect(linkRuntimeSshAccess(userDataPath, request())).rejects.toThrow( - 'Disconnect direct SSH authority' - ) - expect(mocks.connect).not.toHaveBeenCalled() - expect(current().pendingSshAccessOperation).toBeUndefined() - }) - it('treats a lost successful unlink response as an idempotent no-op', async () => { await linkRuntimeSshAccess(userDataPath, request()) const unlinked = await unlink() diff --git a/src/main/ssh/runtime-ssh-access.ts b/src/main/ssh/runtime-ssh-access.ts index 2240760da0d..9ecd3d7d110 100644 --- a/src/main/ssh/runtime-ssh-access.ts +++ b/src/main/ssh/runtime-ssh-access.ts @@ -25,7 +25,6 @@ import { startOrcadManagedTunnel } from './orcad-managed-tunnel' import { verifyRuntimeEnvironmentSshTunnel } from './runtime-ssh-access-verification' -import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' type TargetClaims = ReturnType['claims'] @@ -63,9 +62,6 @@ function requireTarget(claims: TargetClaims, targetId: string): SshTarget & { ge function requireAccessOnlyTarget(claims: TargetClaims, targetId: string, environmentId: string) { const target = requireTarget(claims, targetId) - if (hasRegisteredDirectSshAuthority(targetId)) { - throw new Error('Disconnect direct SSH authority before linking access to this paired server.') - } // Omitting the environment bypasses the same-owner shortcut and rechecks direct authority. const blockers = claims .preflight(targetId) diff --git a/src/main/ssh/ssh-file-stream-read-cap.ts b/src/main/ssh/ssh-file-stream-read-cap.ts deleted file mode 100644 index e5bdce97e5a..00000000000 --- a/src/main/ssh/ssh-file-stream-read-cap.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { FileReadLimits } from '../providers/types' - -const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 -const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024 - -export function sshFileStreamReadCap(isBinary: boolean, limits?: FileReadLimits): number { - const defaultCap = isBinary ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - const requestedCap = isBinary ? limits?.maxBinaryBytes : limits?.maxTextBytes - return requestedCap === undefined ? defaultCap : Math.min(defaultCap, requestedCap) -} diff --git a/src/main/ssh/ssh-filesystem-stream-reader.ts b/src/main/ssh/ssh-filesystem-stream-reader.ts deleted file mode 100644 index 8c5b8632fa7..00000000000 --- a/src/main/ssh/ssh-filesystem-stream-reader.ts +++ /dev/null @@ -1,331 +0,0 @@ -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { STREAM_CHUNK_SIZE, JsonRpcErrorCode, RelayErrorCode } from './relay-protocol' -import type { FileReadLimits, FileReadResult } from '../providers/types' -import { - createSshFileStreamInactivityDeadline, - SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS -} from './ssh-file-stream-inactivity-deadline' -import { sshFileStreamReadCap } from './ssh-file-stream-read-cap' - -const RESULT_ENCODING_BASE64 = 'base64' -const SENTINEL_STREAM_ID = -1 - -type StreamMetadataResponse = { - streamId?: number - totalSize: number - isBinary: boolean - isImage?: boolean - mimeType?: string - resultEncoding?: 'base64' | 'utf-8' - empty?: boolean -} - -export function isMethodNotFoundError(err: unknown): boolean { - if (!err || typeof err !== 'object') { - return false - } - const code = (err as { code?: unknown }).code - return code === JsonRpcErrorCode.MethodNotFound -} - -export class StreamProtocolError extends Error { - readonly code = RelayErrorCode.StreamProtocolError - constructor(message: string) { - super(message) - } -} - -// Why: exceeding a cap the caller itself set is a size verdict, not a protocol fault — callers -// translate it into their own too-large error rather than leaking the raw stream message. -export class FileReadCapExceededError extends StreamProtocolError {} - -export async function readFileViaStream( - mux: SshChannelMultiplexer, - filePath: string, - limits?: FileReadLimits -): Promise { - // Why: subscribe BEFORE awaiting the metadata response so a chunk arriving - // immediately after the response cannot beat the listener registration. - // streamIdRef stays at SENTINEL_STREAM_ID until metadata resolves; chunk - // handlers compare against it and drop unmatched ids cleanly. - const streamIdRef = { current: SENTINEL_STREAM_ID } - const unsubscribers: (() => void)[] = [] - const cleanup = (): void => { - while (unsubscribers.length > 0) { - const fn = unsubscribers.pop() - try { - fn?.() - } catch { - // Best-effort cleanup - } - } - } - - return new Promise((resolve, reject) => { - let buffer: Buffer | null = null - let resultEncoding: 'base64' | 'utf-8' = RESULT_ENCODING_BASE64 - let isBinary = false - let isImage: boolean | undefined - let mimeType: string | undefined - let totalSize = 0 - let expectedSeq = 0 - let receivedChunks = 0 - let totalChunks = 0 - let bytesReceived = 0 - let settled = false - - // Install metadata during response dispatch, before adjacent stream frames. - let metadataReady = false - - const inactivity = createSshFileStreamInactivityDeadline(() => { - fail( - new StreamProtocolError( - `File stream stalled (>${SSH_FILE_STREAM_INACTIVITY_TIMEOUT_MS}ms without data)` - ) - ) - }) - - const cancel = (): void => { - if (streamIdRef.current !== SENTINEL_STREAM_ID && !mux.isDisposed()) { - try { - mux.notify('fs.cancelStream', { streamId: streamIdRef.current }) - } catch { - // Best-effort - } - } - } - - const fail = (err: Error): void => { - if (settled) { - return - } - settled = true - inactivity.clear() - cancel() - cleanup() - reject(err) - } - - const succeed = (value: FileReadResult): void => { - if (settled) { - return - } - settled = true - inactivity.clear() - cleanup() - resolve(value) - } - - const handleChunk = (params: Record): void => { - if (settled) { - return - } - const id = params.streamId as number | undefined - if (id !== streamIdRef.current) { - return - } - const seq = params.seq as number - const data = params.data as string - if (typeof seq !== 'number' || typeof data !== 'string') { - fail(new StreamProtocolError(`Malformed chunk for stream ${id}`)) - return - } - if (seq !== expectedSeq) { - fail( - new StreamProtocolError( - `Out-of-order chunk for stream ${id}: expected ${expectedSeq}, got ${seq}` - ) - ) - return - } - const offset = seq * STREAM_CHUNK_SIZE - const decoded = Buffer.from(data, 'base64') - // Why: a short chunk would leave the pre-allocated buffer zero-filled and - // resolve as silently-corrupt data; validate each chunk's exact length. - const expectedLength = Math.min(STREAM_CHUNK_SIZE, totalSize - offset) - if (decoded.length !== expectedLength) { - fail( - new StreamProtocolError( - `Chunk length mismatch for stream ${id}: seq=${seq} expected=${expectedLength} got=${decoded.length}` - ) - ) - return - } - if (!buffer) { - fail(new StreamProtocolError(`Chunk arrived before metadata for stream ${id}`)) - return - } - decoded.copy(buffer, offset) - expectedSeq += 1 - receivedChunks += 1 - bytesReceived += decoded.length - inactivity.reset() - // Why: credit-based flow control — the relay caps unacked chunks so bulk - // stream frames cannot queue unbounded ahead of interactive pty.data - // frames on the shared SSH channel. Old relays ignore this notification. - mux.notify('fs.streamAck', { streamId: id, seq }) - } - - const handleEnd = (params: Record): void => { - if (settled) { - return - } - const id = params.streamId as number | undefined - if (id !== streamIdRef.current) { - return - } - if (receivedChunks !== totalChunks) { - fail( - new StreamProtocolError( - `Chunk count mismatch for stream ${id}: expected ${totalChunks}, received ${receivedChunks}` - ) - ) - return - } - // Why: redundant given the per-chunk length + count checks, but kept as a - // last-line invariant guard; never resolve with fewer bytes than declared. - if (bytesReceived !== totalSize) { - fail( - new StreamProtocolError( - `Byte count mismatch for stream ${id}: expected ${totalSize}, received ${bytesReceived}` - ) - ) - return - } - if (!buffer) { - fail(new StreamProtocolError(`Stream end before metadata for stream ${id}`)) - return - } - const content = - resultEncoding === RESULT_ENCODING_BASE64 - ? buffer.toString('base64') - : buffer.toString('utf-8') - succeed({ - content, - isBinary, - ...(isImage !== undefined ? { isImage } : {}), - ...(mimeType !== undefined ? { mimeType } : {}) - }) - } - - const handleStreamError = (params: Record): void => { - if (settled) { - return - } - const id = params.streamId as number | undefined - if (id !== streamIdRef.current) { - return - } - const message = (params.message as string | undefined) ?? 'stream error' - const code = (params.code as string | undefined) ?? 'ESTREAMERROR' - const err = new Error(message) as Error & { code: string } - err.code = code - fail(err) - } - - unsubscribers.push( - mux.onNotificationByMethod('fs.streamChunk', (params) => { - if (!metadataReady) { - return - } - handleChunk(params) - }) - ) - unsubscribers.push( - mux.onNotificationByMethod('fs.streamEnd', (params) => { - if (!metadataReady) { - return - } - handleEnd(params) - }) - ) - unsubscribers.push( - mux.onNotificationByMethod('fs.streamError', (params) => { - if (!metadataReady) { - return - } - handleStreamError(params) - }) - ) - - const onDispose = mux.onDispose((reason) => { - const message = - reason === 'connection_lost' - ? 'SSH connection lost, reconnecting...' - : 'Multiplexer disposed' - const err = new Error(message) as Error & { code: string } - err.code = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED' - fail(err) - }) - unsubscribers.push(onDispose) - - void mux - // Why: flowControl declares this client acks each chunk, letting a new - // relay pace the pump. Old relays ignore the extra param and flood. - .request( - 'fs.readFileStream', - { filePath, flowControl: 'ack' }, - { - beforeResolve: (rawMetadata) => { - if (settled) { - return - } - const metadata = rawMetadata as StreamMetadataResponse - isBinary = metadata.isBinary - isImage = metadata.isImage - mimeType = metadata.mimeType - resultEncoding = metadata.resultEncoding ?? RESULT_ENCODING_BASE64 - - if (metadata.empty) { - succeed({ - content: '', - isBinary: metadata.isBinary, - ...(metadata.isImage !== undefined ? { isImage: metadata.isImage } : {}), - ...(metadata.mimeType !== undefined ? { mimeType: metadata.mimeType } : {}) - }) - return - } - - if (typeof metadata.streamId !== 'number') { - fail(new StreamProtocolError('Metadata missing streamId for non-empty stream')) - return - } - - const cap = sshFileStreamReadCap(metadata.isBinary, limits) - if (metadata.totalSize < 0 || metadata.totalSize > cap) { - streamIdRef.current = metadata.streamId - fail( - new FileReadCapExceededError( - `Reported totalSize ${metadata.totalSize} exceeds client cap ${cap}` - ) - ) - return - } - - totalSize = metadata.totalSize - totalChunks = totalSize === 0 ? 0 : Math.ceil(totalSize / STREAM_CHUNK_SIZE) - try { - buffer = Buffer.alloc(totalSize) - } catch (err) { - streamIdRef.current = metadata.streamId - fail(new Error(`Failed to allocate ${totalSize} bytes: ${(err as Error).message}`)) - return - } - streamIdRef.current = metadata.streamId - metadataReady = true - inactivity.reset() - } - } - ) - // Why: beforeResolve is an optional hook; if a mux ever resolves without running - // it, metadata never installs and no deadline is armed. Fail instead of hanging. - .then(() => { - if (!settled && !metadataReady) { - fail(new StreamProtocolError('Metadata response resolved without stream identity')) - } - }) - .catch((err) => { - fail(err as Error) - }) - }) -} diff --git a/src/main/ssh/ssh-git-response-pending-retention.test.ts b/src/main/ssh/ssh-git-response-pending-retention.test.ts deleted file mode 100644 index 3659169b9c5..00000000000 --- a/src/main/ssh/ssh-git-response-pending-retention.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { requestGitStreamable } from './ssh-git-response-stream-reader' - -function fixture() { - const listeners = new Map) => void>>() - const replies: ((value: unknown) => void)[] = [] - const mux = { - request: vi.fn(() => new Promise((resolve) => replies.push(resolve))), - notify: vi.fn(), - isDisposed: () => false, - onDispose: () => () => {}, - onNotificationByMethod: ( - method: string, - callback: (params: Record) => void - ) => { - const callbacks = listeners.get(method) ?? new Set() - callbacks.add(callback) - listeners.set(method, callbacks) - return () => callbacks.delete(callback) - } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture implements the request, disposal and notification surface used by this reader. - const typedMux = mux as unknown as SshChannelMultiplexer - return { - mux: typedMux, - mock: mux, - replies, - emit: (method: string, params: Record) => { - for (const callback of listeners.get(method) ?? []) { - callback(params) - } - }, - listenerCount: () => [...listeners.values()].reduce((sum, callbacks) => sum + callbacks.size, 0) - } -} - -const marker = (streamId: number, totalBytes: number, chunkCount: number) => ({ - __orcaGitResponseStream: { streamId, totalBytes, chunkCount } -}) - -it('drops oversized frames before metadata and fails only their identified owner', async () => { - const f = fixture() - const own = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const foreign = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const rejected = expect(own).rejects.toThrow('retention budget') - f.emit('git.responseChunk', { streamId: 1, seq: 0, data: 'x'.repeat(1024 * 1024) }) - f.emit('git.responseChunk', { streamId: 2, seq: 0, data: Buffer.from('[]').toString('base64') }) - f.emit('git.responseEnd', { streamId: 2 }) - f.replies[1](marker(2, 2, 1)) - await expect(foreign).resolves.toEqual([]) - f.replies[0](marker(1, 64, 1)) - await rejected - expect(f.mock.notify).toHaveBeenCalledWith('git.cancelResponseStream', { streamId: 1 }) - expect(f.listenerCount()).toBe(0) -}) - -it('refuses chunked pre-metadata overflow even when the discarded data was foreign-shaped', async () => { - const f = fixture() - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const rejected = expect(result).rejects.toThrow('retention budget') - for (let seq = 0; seq < 100; seq++) { - f.emit('git.responseChunk', { streamId: 1, seq, data: 'x'.repeat(40) }) - } - f.replies[0](marker(1, 64, 100)) - await rejected - expect(f.listenerCount()).toBe(0) -}) - -it('cleans abandoned queues and cancels a sentinel that arrives after abort', async () => { - const f = fixture() - const controller = new AbortController() - const result = requestGitStreamable( - f.mux, - 'fs.readDir', - {}, - { signal: controller.signal, maxResponseBytes: 64 } - ) - const rejected = expect(result).rejects.toThrow('cancelled') - f.emit('git.responseChunk', { streamId: 1, seq: 0, data: 'e30=' }) - controller.abort() - await rejected - expect(f.listenerCount()).toBe(0) - f.replies[0](marker(1, 2, 1)) - await new Promise((resolve) => setImmediate(resolve)) - expect(f.mock.notify).toHaveBeenCalledWith('git.cancelResponseStream', { streamId: 1 }) -}) - -it('preserves honest ordered pre-metadata chunks and ignores malformed foreign params', async () => { - const f = fixture() - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - f.emit('git.responseChunk', { streamId: { large: 'x'.repeat(1024) }, data: 'x'.repeat(1024) }) - f.emit('git.responseChunk', { streamId: 1, seq: 0, data: Buffer.from('[1,').toString('base64') }) - f.emit('git.responseChunk', { streamId: 1, seq: 1, data: Buffer.from('2]').toString('base64') }) - f.emit('git.responseEnd', { streamId: 1 }) - f.replies[0](marker(1, 5, 2)) - await expect(result).resolves.toEqual([1, 2]) - expect(f.listenerCount()).toBe(0) -}) - -it('cleans the pending queue when the metadata request fails', async () => { - const f = fixture() - f.mock.request.mockRejectedValueOnce(new Error('metadata unavailable')) - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - f.emit('git.responseChunk', { streamId: 1, seq: 0, data: 'e30=' }) - await expect(result).rejects.toThrow('metadata unavailable') - expect(f.listenerCount()).toBe(0) -}) - -it('drops assembled parts and subscriptions on a stalled owned stream', async () => { - vi.useFakeTimers() - try { - const f = fixture() - const result = requestGitStreamable( - f.mux, - 'fs.readDir', - {}, - { maxResponseBytes: 64, inactivityTimeoutMs: 10 } - ) - const rejected = expect(result).rejects.toThrow('stalled') - f.replies[0](marker(1, 4, 2)) - await Promise.resolve() - f.emit('git.responseChunk', { streamId: 1, seq: 0, data: Buffer.from('[').toString('base64') }) - await vi.advanceTimersByTimeAsync(10) - await rejected - expect(f.listenerCount()).toBe(0) - expect(vi.getTimerCount()).toBe(0) - expect(f.mock.notify).toHaveBeenCalledWith('git.cancelResponseStream', { streamId: 1 }) - } finally { - vi.useRealTimers() - } -}) - -it('accepts an exact-budget honest response split across padded base64 chunks', async () => { - const f = fixture() - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const payload = JSON.stringify('x'.repeat(62)) - expect(Buffer.byteLength(payload)).toBe(64) - for (let seq = 0; seq < 4; seq++) { - f.emit('git.responseChunk', { - streamId: 1, - seq, - data: Buffer.from(payload.slice(seq * 16, (seq + 1) * 16)).toString('base64') - }) - } - f.emit('git.responseEnd', { streamId: 1 }) - f.replies[0](marker(1, 64, 4)) - await expect(result).resolves.toBe('x'.repeat(62)) -}) - -it.each(['', '====', '!'])( - 'rejects zero-progress chunks %j without retaining or ACKing them', - async (data) => { - const f = fixture() - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const rejected = expect(result).rejects.toThrow('byte progress') - f.replies[0](marker(1, 2, 1)) - await Promise.resolve() - for (let seq = 0; seq < 100_000; seq++) { - f.emit('git.responseChunk', { streamId: 1, seq, data }) - } - await rejected - expect(f.mock.notify).not.toHaveBeenCalledWith('git.responseAck', expect.anything()) - expect(f.mock.notify).toHaveBeenCalledWith('git.cancelResponseStream', { streamId: 1 }) - expect(f.listenerCount()).toBe(0) - } -) - -it.each([ - ['chunks', 2, 1, '['], - ['bytes', 2, 2, '[]'] -])('rejects excess declared %s before ACKing', async (_kind, total, chunks, text) => { - const f = fixture() - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const rejected = expect(result).rejects.toThrow('declared') - f.replies[0](marker(1, Number(total), Number(chunks))) - await Promise.resolve() - f.emit('git.responseChunk', { - streamId: 1, - seq: 0, - data: Buffer.from(String(text)).toString('base64') - }) - f.emit('git.responseChunk', { streamId: 1, seq: 1, data: Buffer.from(']').toString('base64') }) - await rejected - expect(f.mock.notify.mock.calls.filter(([method]) => method === 'git.responseAck')).toHaveLength( - 1 - ) - expect(f.listenerCount()).toBe(0) -}) - -it('rejects an enormous declared count independently of continued empty frames', async () => { - const f = fixture() - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const rejected = expect(result).rejects.toThrow('chunk count') - f.replies[0](marker(1, 2, Number.MAX_SAFE_INTEGER)) - await rejected - expect(f.listenerCount()).toBe(0) -}) - -it.each([true, false])( - 'bounds owned error diagnostics with metadata first: %s', - async (metadataFirst) => { - const f = fixture() - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const rejected = expect(result).rejects.toThrow('retention budget') - if (metadataFirst) { - f.replies[0](marker(1, 2, 1)) - await Promise.resolve() - } - f.emit('git.responseError', { streamId: 1, message: 'x'.repeat(1024 * 1024) }) - if (!metadataFirst) { - f.replies[0](marker(1, 2, 1)) - } - await rejected - expect(f.listenerCount()).toBe(0) - } -) - -it('preserves short upstream error text', async () => { - const f = fixture() - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: 64 }) - const rejected = expect(result).rejects.toThrow('permission denied') - f.replies[0](marker(1, 2, 1)) - await Promise.resolve() - f.emit('git.responseError', { streamId: 1, message: 'permission denied' }) - await rejected -}) - -it('reassembles one-byte valid chunks without a per-chunk retained buffer', async () => { - const f = fixture() - const text = JSON.stringify('x'.repeat(100_000)) - const result = requestGitStreamable(f.mux, 'fs.readDir', {}, { maxResponseBytes: text.length }) - f.replies[0](marker(1, text.length, text.length)) - await Promise.resolve() - for (let seq = 0; seq < text.length; seq++) { - f.emit('git.responseChunk', { - streamId: 1, - seq, - data: Buffer.from(text[seq]).toString('base64') - }) - } - f.emit('git.responseEnd', { streamId: 1 }) - await expect(result).resolves.toBe('x'.repeat(100_000)) - expect(f.listenerCount()).toBe(0) -}) diff --git a/src/main/ssh/ssh-git-response-stream-reader.test.ts b/src/main/ssh/ssh-git-response-stream-reader.test.ts deleted file mode 100644 index 8b08701a10d..00000000000 --- a/src/main/ssh/ssh-git-response-stream-reader.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { requestGitStreamable } from './ssh-git-response-stream-reader' -import { SshChannelMultiplexer, type MultiplexerTransport } from './ssh-channel-multiplexer' - -function createMockTransport(): MultiplexerTransport { - return { - write: () => {}, - onData: () => {}, - onClose: () => {} - } -} - -describe('requestGitStreamable on an already-dead multiplexer', () => { - it('rejects as a transient relay loss and leaves no listener on the caller signal', async () => { - const mux = new SshChannelMultiplexer(createMockTransport()) - mux.dispose('connection_lost') - const controller = new AbortController() - const addListener = vi.spyOn(controller.signal, 'addEventListener') - const removeListener = vi.spyOn(controller.signal, 'removeEventListener') - - await expect( - requestGitStreamable(mux, 'git.status', { cwd: '/repo' }, { signal: controller.signal }) - ).rejects.toThrow('SSH connection lost, reconnecting...') - - // #11953: a disposed mux fails synchronously inside onDispose, so the abort - // listener must already be registered when that cleanup runs — otherwise it - // outlives the request for the lifetime of the caller's signal. - expect(removeListener).toHaveBeenCalledTimes(addListener.mock.calls.length) - }) -}) diff --git a/src/main/ssh/ssh-git-response-stream-reader.ts b/src/main/ssh/ssh-git-response-stream-reader.ts deleted file mode 100644 index 1bf084ab0c5..00000000000 --- a/src/main/ssh/ssh-git-response-stream-reader.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { stringifyJsonWithinByteLimit } from '../../shared/node-bounded-json-stringify' -import { - SshResponsePendingFrames, - boundedSshResponseDiagnostic -} from './ssh-response-pending-frames' -import { SshResponsePayload } from './ssh-response-payload' -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { createSshDisposalError } from './ssh-channel-multiplexer' -import { RelayErrorCode, isGitResponseStreamMarker } from './relay-protocol' - -const SENTINEL_STREAM_ID = -1 - -/** Reject if no stream frame (chunk/end/error) arrives within this window, - * reset on each frame. mux.request's own timeout only bounds the fast sentinel - * response; without this, a relay pump that breaks on staleness (which sends no - * responseEnd) while the SSH channel stays up would hang the client forever. */ -const STREAM_INACTIVITY_TIMEOUT_MS = 30_000 - -export class GitResponseStreamError extends Error { - readonly code = RelayErrorCode.StreamProtocolError - constructor(message: string) { - super(message) - } -} - -/** - * Request a git method that may return a large payload, opting into response - * streaming so a big diff/exec response is chunked onto the relay's bulk lane - * instead of one JSON-RPC frame (which would head-of-line-block pty.data echo - * on the shared SSH channel). - * - * Cross-version behavior: - * - New relay + big result → returns the stream sentinel; we reassemble chunks. - * - New relay + small result, or old client → plain single-frame result. - * - Old relay (ignores `__streamResponse`) → returns the plain result; the - * marker check fails and we return it directly, i.e. today's behavior. - */ -export function requestGitStreamable( - mux: SshChannelMultiplexer, - method: string, - params: Record, - options?: { - signal?: AbortSignal - /** Bounds only the sentinel request (forwarded to mux.request), like today. */ - timeoutMs?: number - /** Bounds the post-sentinel reassembly stall; resets on each chunk. */ - maxResponseBytes?: number - inactivityTimeoutMs?: number - } -): Promise { - // Why: subscribe to chunk/end/error BEFORE awaiting the sentinel response so a - // chunk that lands in the same dispatch tick as the response is not dropped - // (mirrors readFileViaStream). streamIdRef stays SENTINEL until the sentinel - // resolves; frames are queued until then and drained. - const streamIdRef = { current: SENTINEL_STREAM_ID } - const unsubscribers: (() => void)[] = [] - const cleanup = (): void => { - while (unsubscribers.length > 0) { - try { - unsubscribers.pop()?.() - } catch { - // best-effort - } - } - } - - return new Promise((resolve, reject) => { - let payload: SshResponsePayload | undefined - let expectedSeq = 0 - let totalBytes = 0 - let chunkCount = 0 - let settled = false - let metadataReady = false - const pending = new SshResponsePendingFrames(options?.maxResponseBytes) - - const inactivityMs = options?.inactivityTimeoutMs ?? STREAM_INACTIVITY_TIMEOUT_MS - let inactivityTimer: ReturnType | null = null - const clearInactivity = (): void => { - if (inactivityTimer) { - clearTimeout(inactivityTimer) - inactivityTimer = null - } - } - // Why: reset on every stream frame so a legitimately long stream is not - // killed, but a wedged stream (no frames arriving) rejects instead of - // hanging the caller forever. - const armInactivity = (): void => { - if (inactivityTimer) { - inactivityTimer.refresh() - return - } - inactivityTimer = setTimeout(() => { - fail( - new GitResponseStreamError( - `Git response stream stalled (>${inactivityMs}ms without data)` - ) - ) - }, inactivityMs) - inactivityTimer.unref?.() - } - - const cancel = (): void => { - if (streamIdRef.current !== SENTINEL_STREAM_ID && !mux.isDisposed()) { - try { - mux.notify('git.cancelResponseStream', { streamId: streamIdRef.current }) - } catch { - // best-effort - } - } - } - const fail = (err: Error): void => { - if (settled) { - return - } - settled = true - payload?.clear() - pending.clear() - clearInactivity() - cancel() - cleanup() - reject(err) - } - const succeed = (value: unknown): void => { - if (settled) { - return - } - settled = true - payload?.clear() - pending.clear() - clearInactivity() - cleanup() - resolve(value) - } - - const handleChunk = (p: Record): void => { - if (settled || p.streamId !== streamIdRef.current) { - return - } - const seq = p.seq - const data = p.data - if (typeof seq !== 'number' || typeof data !== 'string') { - fail(new GitResponseStreamError(`Malformed chunk for git stream ${streamIdRef.current}`)) - return - } - if (seq !== expectedSeq) { - fail( - new GitResponseStreamError( - `Out-of-order chunk for git stream ${streamIdRef.current}: expected ${expectedSeq}, got ${seq}` - ) - ) - return - } - try { - payload?.append(data) - } catch (error) { - fail(new GitResponseStreamError(String(error))) - return - } - expectedSeq += 1 - armInactivity() - // Why: credit-based flow control — the relay caps unacked chunks so a big - // response cannot queue unbounded ahead of interactive pty.data frames. - if (!mux.isDisposed()) { - try { - mux.notify('git.responseAck', { streamId: streamIdRef.current, seq }) - } catch { - // Disposal can race the check; the ACK is best-effort during teardown. - } - } - } - - const handleEnd = (p: Record): void => { - if (settled || p.streamId !== streamIdRef.current) { - return - } - if (expectedSeq !== chunkCount || payload?.receivedBytes !== totalBytes) { - fail( - new GitResponseStreamError( - `Git stream ${streamIdRef.current} incomplete: chunks ${expectedSeq}/${chunkCount}, bytes ${payload?.receivedBytes}/${totalBytes}` - ) - ) - return - } - try { - succeed(JSON.parse(payload?.takeString() ?? '')) - } catch (err) { - fail( - new GitResponseStreamError( - `Git stream ${streamIdRef.current} JSON parse failed: ${boundedSshResponseDiagnostic(String(err), options?.maxResponseBytes)}` - ) - ) - } - } - - const handleStreamError = (p: Record): void => { - if (settled || p.streamId !== streamIdRef.current) { - return - } - fail(new Error(boundedSshResponseDiagnostic(p.message, options?.maxResponseBytes))) - } - - const drainPending = (): void => { - let frame = pending.shift() - while (!settled && frame) { - if (frame.kind === 'chunk') { - handleChunk(frame.params) - } else if (frame.kind === 'end') { - handleEnd(frame.params) - } else { - handleStreamError(frame.params) - } - frame = pending.shift() - } - } - - unsubscribers.push( - mux.onNotificationByMethod('git.responseChunk', (p) => { - if (!metadataReady) { - pending.push('chunk', p) - return - } - handleChunk(p) - }) - ) - unsubscribers.push( - mux.onNotificationByMethod('git.responseEnd', (p) => { - if (!metadataReady) { - pending.push('end', p) - return - } - handleEnd(p) - }) - ) - unsubscribers.push( - mux.onNotificationByMethod('git.responseError', (p) => { - if (!metadataReady) { - pending.push('error', p) - return - } - handleStreamError(p) - }) - ) - if (options?.signal) { - const signal = options.signal - if (signal.aborted) { - const err = new Error('Request was cancelled') as Error & { name: string } - err.name = 'AbortError' - fail(err) - return - } - const onAbort = (): void => { - const err = new Error('Request was cancelled') as Error & { name: string } - err.name = 'AbortError' - fail(err) - } - signal.addEventListener('abort', onAbort, { once: true }) - unsubscribers.push(() => signal.removeEventListener('abort', onAbort)) - } - - // Why: registered last because an already-disposed mux fails synchronously here, - // and that cleanup must be able to drop the abort listener above (#11953). - unsubscribers.push(mux.onDispose((reason) => fail(createSshDisposalError(reason)))) - - // Why: forward only the mux-request options (signal/timeoutMs) and omit them - // entirely when absent, so callers that previously issued a 2-arg - // mux.request keep the same call shape (and their tests). inactivityTimeoutMs - // governs reassembly here, not the sentinel request. - const streamParams = { ...params, __streamResponse: true } - const requestOptions = - options?.signal !== undefined || options?.timeoutMs !== undefined - ? { signal: options.signal, timeoutMs: options.timeoutMs } - : undefined - const requestPromise = requestOptions - ? mux.request(method, streamParams, requestOptions) - : mux.request(method, streamParams) - void requestPromise - .then((result) => { - if (settled) { - if (isGitResponseStreamMarker(result) && !mux.isDisposed()) { - mux.notify('git.cancelResponseStream', { - streamId: result.__orcaGitResponseStream.streamId - }) - } - return - } - // Old relay / small result: plain single-frame value, no stream follows. - if (!isGitResponseStreamMarker(result)) { - if (options?.maxResponseBytes !== undefined) { - stringifyJsonWithinByteLimit(result, options.maxResponseBytes) - } - succeed(result) - return - } - const marker = result.__orcaGitResponseStream - totalBytes = marker.totalBytes - chunkCount = marker.chunkCount - streamIdRef.current = marker.streamId - if (pending.lostFrames(marker.streamId)) { - fail( - new GitResponseStreamError( - 'Filesystem response exceeds the retention budget before metadata' - ) - ) - return - } - payload = new SshResponsePayload(totalBytes, chunkCount, options?.maxResponseBytes) - metadataReady = true - // Why: start the inactivity deadline now — mux.request's timeout only - // covered the sentinel; the reassembly phase needs its own guard. - armInactivity() - drainPending() - }) - .catch((err) => fail(err as Error)) - }) -} diff --git a/src/main/ssh/ssh-git-stream-idle-timer.test.ts b/src/main/ssh/ssh-git-stream-idle-timer.test.ts deleted file mode 100644 index 7cd5207c63a..00000000000 --- a/src/main/ssh/ssh-git-stream-idle-timer.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { requestGitStreamable } from './ssh-git-response-stream-reader' - -it.each(['end', 'abort', 'timeout'] as const)( - 'reuses the idle deadline across 1000 chunks and cleans up on %s', - async (finish) => { - vi.useFakeTimers() - const setTimer = vi.spyOn(globalThis, 'setTimeout') - try { - const listeners = new Map) => void>() - const controller = new AbortController() - const content = 'x'.repeat(998) - const encoded = Buffer.from(JSON.stringify(content)) - const notify = vi.fn() - const mux = { - request: vi.fn(async () => ({ - __orcaGitResponseStream: { streamId: 7, totalBytes: encoded.length, chunkCount: 1000 } - })), - isDisposed: () => false, - notify, - onDispose: () => () => {}, - onNotificationByMethod: ( - method: string, - callback: (params: Record) => void - ) => { - listeners.set(method, callback) - return () => listeners.delete(method) - } - } - const promise = requestGitStreamable( - mux as unknown as SshChannelMultiplexer, - 'git.diff', - {}, - { - signal: controller.signal - } - ) - const outcome = promise.then( - (value) => ({ value }), - (error: Error) => ({ error: error.message }) - ) - await vi.advanceTimersByTimeAsync(15_000) - for (let seq = 0; seq < encoded.length; seq++) { - listeners.get('git.responseChunk')!({ - streamId: 7, - seq, - data: encoded.subarray(seq, seq + 1).toString('base64') - }) - } - await vi.advanceTimersByTimeAsync(29_999) - expect(listeners.size).toBe(3) - expect(notify.mock.calls.filter(([method]) => method === 'git.responseAck')).toHaveLength( - 1000 - ) - const allocations = setTimer.mock.calls.filter(([, delay]) => delay === 30_000).length - if (finish === 'end') { - listeners.get('git.responseEnd')!({ streamId: 7 }) - expect(await outcome).toEqual({ value: content }) - } else if (finish === 'abort') { - controller.abort() - expect(await outcome).toEqual({ error: 'Request was cancelled' }) - } else { - await vi.advanceTimersByTimeAsync(1) - expect(await outcome).toEqual({ - error: 'Git response stream stalled (>30000ms without data)' - }) - } - expect(allocations).toBe(1) - expect(vi.getTimerCount()).toBe(0) - expect(listeners.size).toBe(0) - expect( - notify.mock.calls.filter(([method]) => method === 'git.cancelResponseStream') - ).toHaveLength(finish === 'end' ? 0 : 1) - } finally { - setTimer.mockRestore() - vi.useRealTimers() - } - } -) diff --git a/src/main/ssh/ssh-listing-response-retention.test.ts b/src/main/ssh/ssh-listing-response-retention.test.ts deleted file mode 100644 index 3846b4185b7..00000000000 --- a/src/main/ssh/ssh-listing-response-retention.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' -import { requestGitStreamable } from './ssh-git-response-stream-reader' - -function fixture(result: unknown) { - const listeners = new Map) => void>() - const mock = { - request: vi.fn().mockResolvedValue(result), - notify: vi.fn(), - isDisposed: () => false, - onDispose: () => () => {}, - onNotificationByMethod: ( - method: string, - listener: (params: Record) => void - ) => { - listeners.set(method, listener) - return () => { - listeners.delete(method) - } - } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture implements the reader's request, notification, and disposal operations. - return { mux: mock as unknown as SshChannelMultiplexer, mock, listeners } -} - -it('refuses oversized stream metadata before retaining any chunks and cancels the pump', async () => { - const { mux, mock, listeners } = fixture({ - __orcaGitResponseStream: { streamId: 1, totalBytes: 129, chunkCount: 2 } - }) - await expect( - requestGitStreamable(mux, 'fs.readDirBounded', {}, { maxResponseBytes: 128 }) - ).rejects.toThrow('retention budget') - expect(mock.notify).toHaveBeenCalledWith('git.cancelResponseStream', { streamId: 1 }) - expect(listeners.size).toBe(0) -}) - -it('rejects a chunk past the advertised retention limit and detaches listeners', async () => { - const { mux, mock, listeners } = fixture({ - __orcaGitResponseStream: { streamId: 2, totalBytes: 64, chunkCount: 1 } - }) - const result = requestGitStreamable(mux, 'fs.readDirBounded', {}, { maxResponseBytes: 64 }) - const outcome = expect(result).rejects.toThrow('retention budget') - await Promise.resolve() - listeners.get('git.responseChunk')?.({ - streamId: 2, - seq: 0, - data: Buffer.alloc(65).toString('base64') - }) - await outcome - expect(mock.notify).toHaveBeenCalledWith('git.cancelResponseStream', { streamId: 2 }) - expect(listeners.size).toBe(0) -}) - -it('validates old-peer plain replies against the same reader byte budget', async () => { - const { mux, listeners } = fixture(['x'.repeat(129)]) - await expect( - requestGitStreamable(mux, 'fs.listFiles', {}, { maxResponseBytes: 128 }) - ).rejects.toThrow('exceeds') - expect(listeners.size).toBe(0) -}) diff --git a/src/main/ssh/ssh-plain-ssh-mode.ts b/src/main/ssh/ssh-plain-ssh-mode.ts deleted file mode 100644 index ee259a52288..00000000000 --- a/src/main/ssh/ssh-plain-ssh-mode.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Design D6 rung D: no Orca runtime runs on the host, so the target connects with plain SSH - * terminals and SFTP browsing only. This module owns the per-target mode record and the - * wording every relay-only feature uses when it refuses. - */ -import type { SshPlainSshMode } from '../../shared/ssh-types' -import type { RemoteRuntimeUnavailableError } from './ssh-relay-runtime-resolution' - -const PLAIN_SSH_SUFFIX = - ' Connected with plain SSH terminals and SFTP file browsing; these terminals end when the ' + - 'connection drops and are not restored on reconnect.' - -const plainModes = new Map() - -export function plainSshModeFromRuntimeUnavailable( - error: RemoteRuntimeUnavailableError -): SshPlainSshMode { - return { reason: error.reason, message: `${error.message}${PLAIN_SSH_SUFFIX}` } -} - -export function setSshPlainSshMode(targetId: string, mode: SshPlainSshMode): void { - plainModes.set(targetId, mode) -} - -export function clearSshPlainSshMode(targetId: string): void { - plainModes.delete(targetId) -} - -export function getSshPlainSshMode(targetId: string): SshPlainSshMode | undefined { - return plainModes.get(targetId) -} - -/** Refusal for an operation only the Orca remote server can perform. */ -export class PlainSshUnsupportedError extends Error { - readonly code = 'plain_ssh_unsupported' - - constructor(feature: string, mode: SshPlainSshMode | undefined) { - const reason = mode ? ` (${mode.reason})` : '' - super(`${feature} needs the Orca remote server, which is not running on this host${reason}.`) - this.name = 'PlainSshUnsupportedError' - } -} diff --git a/src/main/ssh/ssh-target-registry.test.ts b/src/main/ssh/ssh-target-registry.test.ts deleted file mode 100644 index 660736311ea..00000000000 --- a/src/main/ssh/ssh-target-registry.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' -import { getActiveMultiplexer } from './ssh-target-registry' - -describe('getActiveMultiplexer', () => { - it('refuses with the typed not-served error, not a resolver wiring error', () => { - expect(() => getActiveMultiplexer('ssh-1')).toThrow(SshHostNotServedError) - expect(() => getActiveMultiplexer('ssh-1')).toThrow( - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' - ) - expect(() => getActiveMultiplexer('ssh-1')).not.toThrow( - 'ssh_active_multiplexer_resolver_not_installed' - ) - }) -}) diff --git a/src/main/ssh/ssh-target-registry.ts b/src/main/ssh/ssh-target-registry.ts index b03b0d88444..5008f3b9fc9 100644 --- a/src/main/ssh/ssh-target-registry.ts +++ b/src/main/ssh/ssh-target-registry.ts @@ -1,6 +1,4 @@ import type { SshConnectionStore } from './ssh-connection-store' -import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' -import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' import type { SshConnectionManager } from './ssh-connection-manager' import type { SshConnectionState, @@ -21,8 +19,8 @@ import type { * This holds only the registry: the store plus the two callbacks the handler layer * installs. `registerSshHandlers` populates it; the runtime reads it. Keeping the * indirection (rather than the runtime holding a manager directly) is deliberate — - * SSH providers register after construction and may reconnect, so callers must - * resolve the current generation rather than freeze one. + * `registerSshHandlers` may re-run, so callers must resolve the current generation + * rather than freeze one. */ let sshStore: SshConnectionStore | null = null @@ -88,16 +86,7 @@ export function getRegisteredSshTargetMutations(): SshTargetMutations { return registeredSshTargetMutations } -/** - * The relay channel multiplexer is gone, so no SSH target is served here: every caller is a dead - * SSH arm and gets the typed "not served" error, never an `exited` verdict. - */ -export function getActiveMultiplexer(connectionId: string): SshChannelMultiplexer | undefined { - throw new SshHostNotServedError(connectionId) -} - let registeredGetSshConnectionManager: (() => SshConnectionManager | null) | null = null -let registeredHasDirectSshAuthority: ((targetId: string) => boolean) | null = null export function setSshConnectionManagerResolver( resolve: (() => SshConnectionManager | null) | null @@ -116,14 +105,3 @@ export function setSshConnectionManagerResolver( export function getSshConnectionManager(): SshConnectionManager | null { return registeredGetSshConnectionManager?.() ?? null } - -export function setDirectSshAuthorityResolver( - resolve: ((targetId: string) => boolean) | null -): void { - registeredHasDirectSshAuthority = resolve -} - -/** Whether this process currently holds the target's relay session; false when unregistered. */ -export function hasRegisteredDirectSshAuthority(targetId: string): boolean { - return registeredHasDirectSshAuthority?.(targetId) ?? false -} diff --git a/src/main/startup/headless-serve-ssh-registration.test.ts b/src/main/startup/headless-serve-ssh-registration.test.ts index 715a8448814..b41fa91a167 100644 --- a/src/main/startup/headless-serve-ssh-registration.test.ts +++ b/src/main/startup/headless-serve-ssh-registration.test.ts @@ -11,11 +11,7 @@ vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore) vi.mock('../ipc/ssh-host-server-connect', () => mocks.hostServerConnect) vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager) vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer) -vi.mock('../providers/ssh-filesystem-provider', () => mocks.sshFilesystemProvider) vi.mock('../ipc/pty', () => mocks.pty) -vi.mock('../providers/ssh-filesystem-dispatch', () => mocks.sshFilesystemDispatch) -vi.mock('../providers/ssh-git-provider', () => mocks.sshGitProvider) -vi.mock('../providers/ssh-git-dispatch', () => mocks.sshGitDispatch) vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward) vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner) diff --git a/src/main/startup/main-process-quit-pending-writer.test.ts b/src/main/startup/main-process-quit-pending-writer.test.ts index 8bf383b8adf..c75d2191237 100644 --- a/src/main/startup/main-process-quit-pending-writer.test.ts +++ b/src/main/startup/main-process-quit-pending-writer.test.ts @@ -4,6 +4,7 @@ import { WILL_QUIT_TEARDOWN_DEADLINE_MS } from '../quit-teardown-deadline' const settled = () => vi.fn(async () => {}) const idle = () => vi.fn() +const stopAgentHookServer = idle() // Every teardown member settles except the profile store, so only the writer can hold quit. const dependencies: Record> = { @@ -13,7 +14,7 @@ const dependencies: Record> = { '../ipc/pty': { killAllPty: idle() }, '../daemon/daemon-provider-state': { disconnectDaemon: settled(), shutdownDaemon: settled() }, '../ipc/ssh-shutdown-drain': { beginSshShutdown: settled() }, - '../agent-hooks/server': { agentHookServer: { stop: idle() } }, + '../agent-hooks/server': { agentHookServer: { stop: stopAgentHookServer } }, '../agent-hooks/wsl-hook-relay-manager': { wslHookRelayManager: { disposeAll: idle() } }, '../agent-hooks/managed-agent-hook-controls': { removeManagedAgentHooksAsync: vi.fn(async () => []) @@ -105,3 +106,10 @@ it('releases admission only after the final flush and freeze complete', async () expect(admission.release).toHaveBeenCalledOnce() expect(app.quit).toHaveBeenCalledOnce() }) + +// The chat teardown decides which chats to offer to resume from the hook server's status store, +// and finishes after the hook server stops: that stop must leave the store readable. +it('stops the hook server without clearing the status store the chat teardown reads', async () => { + await startQuit(async () => {}) + expect(stopAgentHookServer).toHaveBeenCalledWith({ keepStatus: true }) +}) diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 7e7af4c773e..d96163ec377 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -158,7 +158,9 @@ function installWillQuitHandler(): void { state.uninstallRepoMaintenanceIdleGate = null // Why stop, not wait: a delete can run for minutes, and its record makes the next start finish it. stopBackgroundWorktreeRemovals() - agentHookServer.stop() + // Why keepStatus: the chat teardown above reads each chat's background work from that store + // after this returns; cleared, a chat whose only work was a subagent or command is not offered. + agentHookServer.stop({ keepStatus: true }) // Why Windows only: POSIX hooks short-circuit on ORCA_PANE_KEY, while Windows must register a // bare script path that cannot express the guard and would otherwise keep spawning after quit. // Why bounded here: every other teardown member carries its own ceiling, and this one reaches diff --git a/src/main/text-generation/commit-message-model-discovery.ts b/src/main/text-generation/commit-message-model-discovery.ts index 32a27e51d88..c327541c106 100644 --- a/src/main/text-generation/commit-message-model-discovery.ts +++ b/src/main/text-generation/commit-message-model-discovery.ts @@ -1,19 +1,15 @@ import { isMissingProviderExecutable } from '../provider-process/provider-executable-missing' import { mergeCommandEnvironment } from '../../shared/command-environment' import type { CommandTemplateBackslash } from '../../shared/commit-message-prompt' -import type { CommitMessagePlan } from '../../shared/commit-message-plan' import { getAgentModelProbeSpec } from '../../shared/agent-model-probe-spec' import type { TuiAgent } from '../../shared/tui-agent' import { resolveCodexHomeProcessLockKeyForSpawnEnv } from '../codex-cli/codex-home-process-lock' import { supervisedProviderSpawnFailure } from '../provider-process/provider-spawn-failure-report' -import { isSshRequestOutcomeUnverifiable } from '../ssh/ssh-channel-multiplexer' -import { WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR } from '../win32-utils' import { finalizeModelDiscoveryOutput, planModelDiscovery, staticModelDiscoveryResult } from './commit-message-model-discovery-policy' -import { userFacingUnsafeWindowsBatchArgs } from './source-control-agent-failure' import { MAX_SOURCE_CONTROL_AGENT_OUTPUT_BYTES, SOURCE_CONTROL_GENERATION_TIMEOUT_MS @@ -23,7 +19,6 @@ import { killSourceControlAgentProcess } from './source-control-local-process' import type { DiscoverCommitMessageModelsResult, LocalProcessExecution, - RemoteCommitMessageExecResult, SpawnedSourceControlAgentProcess, SpawnSourceControlAgent } from './source-control-text-generation-types' @@ -209,64 +204,3 @@ export async function discoverModelsLocal(input: { ) : startDiscovery().result } - -export async function discoverModelsRemote(input: { - agentId: TuiAgent - cwd: string - execute: ( - plan: CommitMessagePlan, - cwd: string, - timeoutMs: number - ) => Promise - agentCommandOverride?: string -}): Promise { - const spec = getAgentModelProbeSpec(input.agentId) - if (!spec) { - return { success: false, error: `Agent "${input.agentId}" does not support model discovery.` } - } - if (spec.modelSource === 'static' || !spec.modelDiscovery) { - return staticModelDiscoveryResult(spec) - } - const planned = planModelDiscovery(spec, input.agentCommandOverride) - if (!planned.ok) { - return { success: false, error: planned.error } - } - let result: RemoteCommitMessageExecResult - try { - result = await input.execute(planned.plan, input.cwd, SOURCE_CONTROL_GENERATION_TIMEOUT_MS) - } catch (error) { - console.error('[commit-message] Remote model discovery request failed:', error) - return { - success: false, - error: isSshRequestOutcomeUnverifiable(error) - ? `${spec.label} model discovery took longer than ${SOURCE_CONTROL_GENERATION_TIMEOUT_MS / 1000}s and may still be running on the remote host.` - : `${spec.label} model discovery could not be reached on the remote PATH. Try again after the SSH connection recovers.` - } - } - if (result.spawnError) { - if (result.spawnError === WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) { - return { success: false, error: userFacingUnsafeWindowsBatchArgs(spec.label) } - } - if (/ENOENT/i.test(result.spawnError)) { - return { - success: false, - error: `${planned.plan.binary} not found on the remote PATH. Install ${spec.label} there.` - } - } - console.error('[commit-message] Remote model discovery spawn failed:', result.spawnError) - return { - success: false, - error: `${spec.label} model discovery could not be started on the remote PATH. Check the agent command there and try again.` - } - } - if (result.canceled) { - return { success: false, error: 'Model discovery canceled.' } - } - if (result.timedOut) { - return { - success: false, - error: `${spec.label} model discovery timed out after ${SOURCE_CONTROL_GENERATION_TIMEOUT_MS / 1000}s.` - } - } - return finalizeModelDiscoveryOutput(spec, result.stdout, result.stderr, result.exitCode) -} diff --git a/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts b/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts index aae4fa8e13b..15844fdd3fe 100644 --- a/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts +++ b/src/main/text-generation/commit-message-text-generation-model-discovery.test.ts @@ -1,14 +1,7 @@ import { spawn } from 'node:child_process' import type * as ChildProcess from 'node:child_process' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - createSshDisposalError, - SSH_MUX_REQUEST_TIMEOUT_CODE -} from '../ssh/ssh-channel-multiplexer' -import { - discoverCommitMessageModelsLocal, - discoverCommitMessageModelsRemote -} from './commit-message-text-generation' +import { discoverCommitMessageModelsLocal } from './commit-message-text-generation' import { PROVIDER_SPAWN_FAILURE_MARKER } from '../provider-process/provider-spawn-failure-report' import { createChildTerminationExpectation, @@ -528,96 +521,3 @@ describe('discoverCommitMessageModelsLocal on the Windows direct-child path', () expect(child.listenerCount('close')).toBe(0) }) }) - -describe('generateCommitMessageFromContext on the Windows direct-child path', () => { - it('discovers dynamic models through a remote execution plan', async () => { - const execute = vi.fn(async (plan, cwd, timeoutMs) => { - expect(plan).toEqual({ - binary: 'npx', - args: ['cursor-agent', '--list-models'], - stdinPayload: null, - label: 'Cursor' - }) - expect(cwd).toBe('/remote/repo') - expect(timeoutMs).toBe(60_000) - return { - stdout: 'auto - Auto\ngpt-5.2 - GPT-5.2\n', - stderr: '', - exitCode: 0, - timedOut: false - } - }) - - const result = await discoverCommitMessageModelsRemote( - 'cursor', - '/remote/repo', - execute, - 'npx cursor-agent' - ) - - expect(result).toMatchObject({ - success: true, - defaultModelId: 'auto', - models: [ - { id: 'auto', label: 'Auto' }, - { id: 'gpt-5.2', label: 'GPT-5.2' } - ] - }) - }) - - it('reports remote model discovery transport timeouts without PATH guidance', async () => { - const transportTimeout = Object.assign( - new Error('Request "agent.execNonInteractive" timed out after 65000ms'), - { code: SSH_MUX_REQUEST_TIMEOUT_CODE } - ) - const result = await discoverCommitMessageModelsRemote( - 'cursor', - '/remote/repo', - async () => { - throw transportTimeout - }, - 'npx cursor-agent' - ) - - expect(result).toEqual({ - success: false, - error: - 'Cursor model discovery took longer than 60s and may still be running on the remote host.' - }) - }) - - it('keeps the unverifiable wording when the link is declared lost instead of timing out', async () => { - // Same regression as the exec leg: a wedged link now disposes the mux before the response - // deadline, so this branch sees CONNECTION_LOST. Reporting "could not be reached" for it - // asserts absence the client never observed (docs/reference/ssh-execution-boundary.md). - const result = await discoverCommitMessageModelsRemote( - 'cursor', - '/remote/repo', - async () => { - throw createSshDisposalError('connection_lost') - }, - 'npx cursor-agent' - ) - - expect(result).toEqual({ - success: false, - error: - 'Cursor model discovery took longer than 60s and may still be running on the remote host.' - }) - }) - - it('reports remote model discovery spawn failures with remote install guidance', async () => { - const result = await discoverCommitMessageModelsRemote('cursor', '/remote/repo', async () => ({ - stdout: '', - stderr: '', - exitCode: null, - timedOut: false, - spawnError: 'ENOENT' - })) - - expect(result).toEqual({ - success: false, - error: 'cursor-agent not found on the remote PATH. Install Cursor there.' - }) - }) -}) diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index 9e4a28de8e8..05e611b97d6 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -2,7 +2,6 @@ import type { BranchNameWorkContext } from '../../shared/branch-name-from-work' import type { ConversationNameContext } from '../../shared/conversation-name-generation' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import { LOCAL_COMMIT_MESSAGE_HOST_KEY } from '../../shared/commit-message-host-key' -import type { CommitMessagePlan } from '../../shared/commit-message-plan' import type { CommandTemplateBackslash } from '../../shared/commit-message-prompt' import type { GlobalSettings } from '../../shared/global-settings-types' import type { @@ -18,7 +17,6 @@ import type { AiTextOperation } from '../../shared/source-control-ai-types' import type { TuiAgent } from '../../shared/tui-agent' import { discoverModelsLocal, - discoverModelsRemote, type CommitMessageModelDiscoveryLocalOptions } from './commit-message-model-discovery' import { spawnSourceControlAgent } from './source-control-agent-launch' @@ -115,19 +113,6 @@ export async function discoverCommitMessageModelsLocal( }) } -export async function discoverCommitMessageModelsRemote( - agentId: TuiAgent, - cwd: string, - execute: ( - plan: CommitMessagePlan, - cwd: string, - timeoutMs: number - ) => Promise, - agentCommandOverride?: string -): Promise { - return discoverModelsRemote({ agentId, cwd, execute, agentCommandOverride }) -} - export function cancelGenerateCommitMessageLocal(cwd: string): void { cancelLocalGeneration('commit-message', cwd) } diff --git a/src/main/window/clipboard-agent-session-attachment-paste.test.ts b/src/main/window/clipboard-agent-session-attachment-paste.test.ts index ba32e82c3b9..952fc48a88b 100644 --- a/src/main/window/clipboard-agent-session-attachment-paste.test.ts +++ b/src/main/window/clipboard-agent-session-attachment-paste.test.ts @@ -26,8 +26,7 @@ vi.mock('../../shared/runtime-environment-store', () => ({ })) vi.mock('./clipboard-remote-file-copy', () => ({ cleanupExpiredRemoteClipboardFiles: vi.fn(async () => {}), - scheduleLegacyRemoteClipboardFileCleanup: vi.fn(), - writeRemoteFileToClipboard: vi.fn() + scheduleLegacyRemoteClipboardFileCleanup: vi.fn() })) vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer: () => false })) diff --git a/src/main/window/clipboard-dashboard-popout-access.test.ts b/src/main/window/clipboard-dashboard-popout-access.test.ts index 99144519d58..984e7de1698 100644 --- a/src/main/window/clipboard-dashboard-popout-access.test.ts +++ b/src/main/window/clipboard-dashboard-popout-access.test.ts @@ -39,8 +39,7 @@ vi.mock('electron', () => ({ vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer })) vi.mock('./clipboard-remote-file-copy', () => ({ cleanupExpiredRemoteClipboardFiles: vi.fn(async () => undefined), - scheduleLegacyRemoteClipboardFileCleanup: vi.fn(), - writeRemoteFileToClipboard: vi.fn() + scheduleLegacyRemoteClipboardFileCleanup: vi.fn() })) import { diff --git a/src/main/window/clipboard-image-temp-file.test.ts b/src/main/window/clipboard-image-temp-file.test.ts index 7e479a9ecba..fd4e5e0064d 100644 --- a/src/main/window/clipboard-image-temp-file.test.ts +++ b/src/main/window/clipboard-image-temp-file.test.ts @@ -1,13 +1,12 @@ import { dirname, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { writeFileMock, mkdirMock, getPathMock, writeFileBase64Mock } = vi.hoisted(() => ({ +const { writeFileMock, mkdirMock, getPathMock } = vi.hoisted(() => ({ writeFileMock: vi.fn(), mkdirMock: vi.fn(), getPathMock: vi.fn((name: string) => name === 'temp' ? '/os/temp' : '/Users/me/Library/Application Support/orca' - ), - writeFileBase64Mock: vi.fn() + ) })) vi.mock('node:fs/promises', () => ({ default: { writeFile: writeFileMock, mkdir: mkdirMock } })) @@ -15,12 +14,6 @@ vi.mock('node:crypto', () => ({ randomUUID: () => 'uuid-1' })) vi.mock('../../shared/app-environment', () => ({ getAppEnvironment: () => ({ getPath: getPathMock }) })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - requireSshFilesystemProvider: () => ({ - getTempDir: async () => '/remote/tmp', - writeFileBase64: writeFileBase64Mock - }) -})) import { saveClipboardImageBufferAsTempFile } from './clipboard-image-temp-file' @@ -53,14 +46,4 @@ describe('saveClipboardImageBufferAsTempFile', () => { ) expect(writeFileMock).toHaveBeenCalledWith(savedPath, Buffer.from([1, 2, 3])) }) - - 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(writeFileMock).not.toHaveBeenCalled() - }) }) diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts index 223b89c79a6..049f98bf03f 100644 --- a/src/main/window/clipboard-image-temp-file.ts +++ b/src/main/window/clipboard-image-temp-file.ts @@ -2,9 +2,8 @@ import fs from 'node:fs/promises' import path from 'node:path' import { randomUUID } from 'node:crypto' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { getAppEnvironment } from '../../shared/app-environment' -import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image' import type { AgentSessionAttachmentClipboardTarget } from '../../shared/agent-session-attachments' import { nativeChatPasteFolder } from './native-chat-paste-files' @@ -18,15 +17,6 @@ export type SaveClipboardImageAsTempFileArgs = { forNativeChatDraft?: boolean } -const REMOTE_CLIPBOARD_IMAGE_TEMP_DIR = '/tmp' - -function joinRemotePath(basePath: string, fileName: string): string { - if (isWindowsAbsolutePathLike(basePath)) { - return path.win32.join(basePath, fileName) - } - return path.posix.join(basePath, fileName) -} - export async function saveClipboardImageBufferAsTempFile( buffer: Buffer, args?: SaveClipboardImageAsTempFileArgs @@ -36,13 +26,7 @@ export async function saveClipboardImageBufferAsTempFile( const fileName = `orca-paste-${Date.now()}-${randomUUID()}.png` if (args?.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - const remoteTempDir = (await provider.getTempDir?.()) ?? REMOTE_CLIPBOARD_IMAGE_TEMP_DIR - const remotePath = joinRemotePath(remoteTempDir, fileName) - // Why: SSH terminal agents run on the remote host, so the pasted path must - // name a remote file. The provider's base64 path writes binary bytes via SFTP. - await provider.writeFileBase64(remotePath, buffer.toString('base64')) - return remotePath + throw new SshHostNotServedError(args.connectionId) } // Why only a composer paste goes to the paste folder: its draft can bring it back after a diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index dc7168f7d65..8cb0c850c90 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { join } from 'node:path' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import { CLIPBOARD_IMAGE_MAX_BASE64_CHARS, @@ -30,7 +29,6 @@ const { clipboardWriteBufferMock, nativeImageCreateFromBufferMock, randomUUIDMock, - getSshFilesystemProviderMock, callRuntimeEnvironmentMock } = vi.hoisted(() => ({ removeHandlerMock: vi.fn(), @@ -66,7 +64,6 @@ const { clipboardWriteBufferMock: vi.fn(), nativeImageCreateFromBufferMock: vi.fn(), randomUUIDMock: vi.fn(() => '00000000-0000-4000-8000-000000000000'), - getSshFilesystemProviderMock: vi.fn(), callRuntimeEnvironmentMock: vi.fn() })) @@ -119,19 +116,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock, - requireSshFilesystemProvider: (connectionId: string) => { - const provider = getSshFilesystemProviderMock(connectionId) - if (!provider) { - throw new Error( - 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' - ) - } - return provider - } -})) - vi.mock('../ipc/runtime-environment-transport-routing', () => ({ callRuntimeEnvironment: callRuntimeEnvironmentMock })) @@ -142,11 +126,6 @@ import { setTrustedClipboardRendererWebContentsId } from './clipboard-ipc-handlers' -const REMOTE_CLIPBOARD_STAGING_ROOT = join( - '/tmp', - `orca-clipboard-files${typeof process.getuid === 'function' ? `-${process.getuid()}` : ''}` -) - function getRegisteredHandlers(): Map unknown> { const handlers = new Map unknown>() for (const [channel, handler] of handleMock.mock.calls as [ @@ -233,7 +212,6 @@ describe('registerClipboardHandlers', () => { nativeImageCreateFromBufferMock.mockReset() randomUUIDMock.mockReset() randomUUIDMock.mockReturnValue('00000000-0000-4000-8000-000000000000') - getSshFilesystemProviderMock.mockReset() callRuntimeEnvironmentMock.mockReset() setTrustedClipboardRendererWebContentsId(null) }) @@ -305,7 +283,6 @@ describe('registerClipboardHandlers', () => { expect(nativeImageCreateFromBufferMock).not.toHaveBeenCalled() expect(clipboardWriteImageMock).not.toHaveBeenCalled() expect(clipboardWriteBufferMock).not.toHaveBeenCalled() - expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() }) it('writes local files through the trusted clipboard IPC handler', async () => { @@ -328,92 +305,6 @@ describe('registerClipboardHandlers', () => { } }) - it('materializes remote files before writing them to the OS clipboard', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 12, type: 'file', mtime: 123 }), - downloadFile: vi.fn().mockResolvedValue(undefined) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - registerClipboardHandlers({} as never) - - const handlers = getRegisteredHandlers() - const tempDir = join( - REMOTE_CLIPBOARD_STAGING_ROOT, - '1760000000000-00000000-0000-4000-8000-000000000000' - ) - const tempPath = join(tempDir, 'report.pdf') - - await expect( - handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), { - filePath: '/remote/report.pdf', - connectionId: 'ssh-1' - }) - ).resolves.toEqual({ ok: true }) - - expect(provider.stat).toHaveBeenCalledWith('/remote/report.pdf') - expect(fsMkdirMock).toHaveBeenCalledWith(REMOTE_CLIPBOARD_STAGING_ROOT, { - recursive: true, - mode: 0o700 - }) - expect(fsMkdirMock).toHaveBeenCalledWith(tempDir, { mode: 0o700 }) - expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath) - expect(fsStatMock).toHaveBeenCalledWith(tempPath) - expect(resolveAuthorizedPathMock).not.toHaveBeenCalled() - expect(fsRmMock).not.toHaveBeenCalled() - }) - - it('does not materialize remote directories for OS clipboard copy', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 0, type: 'directory', mtime: 123 }), - downloadFile: vi.fn() - } - getSshFilesystemProviderMock.mockReturnValue(provider) - registerClipboardHandlers({} as never) - - const handlers = getRegisteredHandlers() - await expect( - handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), { - filePath: '/remote/src', - connectionId: 'ssh-1' - }) - ).resolves.toEqual({ ok: false, reason: 'is-directory' }) - - expect(provider.downloadFile).not.toHaveBeenCalled() - expect(clipboardWriteBufferMock).not.toHaveBeenCalled() - }) - - it('cleans up remote clipboard temp files when transfer fails', async () => { - const provider = { - stat: vi.fn().mockResolvedValue({ size: 12, type: 'file', mtime: 123 }), - downloadFile: vi.fn().mockRejectedValue(new Error('transfer failed')) - } - getSshFilesystemProviderMock.mockReturnValue(provider) - registerClipboardHandlers({} as never) - - const handlers = getRegisteredHandlers() - const tempDir = join( - REMOTE_CLIPBOARD_STAGING_ROOT, - '1760000000000-00000000-0000-4000-8000-000000000000' - ) - const tempPath = join(tempDir, 'report.pdf') - - await expect( - handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), { - filePath: '/remote/report.pdf', - connectionId: 'ssh-1' - }) - ).rejects.toThrow('transfer failed') - - expect(provider.downloadFile).toHaveBeenCalledWith('/remote/report.pdf', tempPath) - expect(fsRmMock).toHaveBeenCalledWith(tempDir, { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 100 - }) - expect(clipboardWriteBufferMock).not.toHaveBeenCalled() - }) - it('rejects unauthorized local files before touching the OS clipboard', async () => { resolveAuthorizedPathMock.mockRejectedValue( new Error( @@ -607,12 +498,6 @@ describe('registerClipboardHandlers', () => { isEmpty: () => false, toPNG: () => png }) - const writeFileBase64 = vi.fn().mockResolvedValue(undefined) - getSshFilesystemProviderMock.mockReturnValue({ - getTempDir: vi.fn().mockResolvedValue('/var/tmp'), - writeFileBase64 - }) - try { registerClipboardHandlers({} as never) @@ -620,19 +505,20 @@ describe('registerClipboardHandlers', () => { expect(probe?.(makeClipboardEvent())).toBe(true) expect(() => probe?.(makeClipboardEvent({ id: 42 }))).toThrow() const handler = getRegisteredHandlers().get('clipboard:saveImageAsTempFile') - await expect(handler?.(makeClipboardEvent(), { connectionId: 'ssh-1' })).resolves.toBe( - '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png' + await expect(handler?.(makeClipboardEvent())).resolves.toMatch( + /orca-paste-1760000000000-00000000-0000-4000-8000-000000000000\.png$/ ) expect(clipboardReadBufferMock).toHaveBeenCalledWith('FileNameW') expect(clipboardReadBufferMock).toHaveBeenCalledWith('Shell IDList Array') expect(fsOpenMock).toHaveBeenCalledWith(sourcePath, 'r') expect(nativeImageCreateFromBufferMock).toHaveBeenCalledWith(source) expect(close).toHaveBeenCalled() - expect(writeFileBase64).toHaveBeenCalledWith( - '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png', - png.toString('base64') + expect(fsWriteFileMock).toHaveBeenCalledWith( + expect.stringMatching( + /orca-paste-1760000000000-00000000-0000-4000-8000-000000000000\.png$/ + ), + png ) - expect(fsWriteFileMock).not.toHaveBeenCalled() } finally { platformSpy.mockRestore() } @@ -717,7 +603,6 @@ describe('registerClipboardHandlers', () => { 30_000 ) expect(fsWriteFileMock).not.toHaveBeenCalled() - expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() }) it('aborts remote runtime clipboard image uploads when a chunk fails', async () => { @@ -762,63 +647,6 @@ describe('registerClipboardHandlers', () => { expect(fsWriteFileMock).not.toHaveBeenCalled() }) - it('uploads clipboard images to the SSH host when a connection is provided', async () => { - const png = Buffer.from([0, 1, 2, 3]) - const writeFileBase64 = vi.fn().mockResolvedValue(undefined) - const getTempDir = vi.fn().mockResolvedValue('/var/tmp') - clipboardReadImageMock.mockReturnValue({ - getSize: () => ({ height: 1, width: 1 }), - isEmpty: () => false, - toPNG: () => png - }) - getSshFilesystemProviderMock.mockReturnValue({ getTempDir, writeFileBase64 }) - - registerClipboardHandlers({} as never) - - const handlers = getRegisteredHandlers() - await expect( - handlers.get('clipboard:saveImageAsTempFile')?.(makeClipboardEvent(), { - connectionId: 'ssh-1' - }) - ).resolves.toBe('/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png') - expect(getSshFilesystemProviderMock).toHaveBeenCalledWith('ssh-1') - expect(getTempDir).toHaveBeenCalled() - expect(writeFileBase64).toHaveBeenCalledWith( - '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png', - png.toString('base64') - ) - expect(fsWriteFileMock).not.toHaveBeenCalled() - }) - - it('uses Windows path joining for Windows SSH temp directories', async () => { - const png = Buffer.from([0, 1, 2, 3]) - const writeFileBase64 = vi.fn().mockResolvedValue(undefined) - clipboardReadImageMock.mockReturnValue({ - getSize: () => ({ height: 1, width: 1 }), - isEmpty: () => false, - toPNG: () => png - }) - getSshFilesystemProviderMock.mockReturnValue({ - getTempDir: vi.fn().mockResolvedValue('C:\\Users\\alice\\AppData\\Local\\Temp'), - writeFileBase64 - }) - - registerClipboardHandlers({} as never) - - const handlers = getRegisteredHandlers() - await expect( - handlers.get('clipboard:saveImageAsTempFile')?.(makeClipboardEvent(), { - connectionId: 'ssh-1' - }) - ).resolves.toBe( - 'C:\\Users\\alice\\AppData\\Local\\Temp\\orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png' - ) - expect(writeFileBase64).toHaveBeenCalledWith( - 'C:\\Users\\alice\\AppData\\Local\\Temp\\orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png', - png.toString('base64') - ) - }) - it('rejects oversized clipboard image dimensions before PNG conversion', async () => { const toPNG = vi.fn(() => Buffer.from([0, 1, 2, 3])) clipboardReadImageMock.mockReturnValue({ @@ -835,7 +663,6 @@ describe('registerClipboardHandlers', () => { ).rejects.toThrow('Clipboard image is too large') expect(toPNG).not.toHaveBeenCalled() expect(fsWriteFileMock).not.toHaveBeenCalled() - expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() }) it('rejects oversized clipboard PNG bytes before SSH provider lookup', async () => { @@ -853,7 +680,6 @@ describe('registerClipboardHandlers', () => { connectionId: 'ssh-secret' }) ).rejects.toThrow('Clipboard image is too large') - expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() expect(fsWriteFileMock).not.toHaveBeenCalled() }) diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index 80dad3bd02b..3f567982b01 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -32,10 +32,10 @@ import { type ClipboardFileDeps, type ClipboardFileResult } from './clipboard-file-copy' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' import { cleanupExpiredRemoteClipboardFiles, - scheduleLegacyRemoteClipboardFileCleanup, - writeRemoteFileToClipboard + scheduleLegacyRemoteClipboardFileCleanup } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' import { uploadPastedImageToAgentSessionAttachments } from '../ipc/agent-session-attachment-upload' @@ -184,6 +184,9 @@ export function registerClipboardHandlers(store: Store): void { if (!request) { return { ok: false, reason: 'invalid-path' } } + if (request.connectionId) { + throw new SshHostNotServedError(request.connectionId) + } const deps = makeClipboardFileDeps(async (path) => { try { const authorizedPath = await resolveDesktopAuthorizedPath(path, store) @@ -196,13 +199,6 @@ export function registerClipboardHandlers(store: Store): void { return { ok: false, reason: isENOENT(error) ? 'not-found' : 'invalid-path' } } }) - if (request.connectionId) { - return writeRemoteFileToClipboard({ - remotePath: request.filePath, - connectionId: request.connectionId, - deps - }) - } return writeFileToClipboard(request.filePath, deps) } ) diff --git a/src/main/window/clipboard-macos-image-paste.test.ts b/src/main/window/clipboard-macos-image-paste.test.ts index 87ed0793b61..660d264030b 100644 --- a/src/main/window/clipboard-macos-image-paste.test.ts +++ b/src/main/window/clipboard-macos-image-paste.test.ts @@ -12,9 +12,7 @@ const mocks = vi.hoisted(() => ({ readImage: vi.fn(), readBuffer: vi.fn(), toPNG: vi.fn(), - writeFile: vi.fn(), - writeFileBase64: vi.fn(), - requireSshFilesystemProvider: vi.fn() + writeFile: vi.fn() })) vi.mock('electron', () => ({ @@ -37,13 +35,9 @@ vi.mock('node:fs/promises', async (importOriginal) => { return { ...fs, default: { ...fs, writeFile: mocks.writeFile } } }) vi.mock('../persistence', () => ({ Store: vi.fn() })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - requireSshFilesystemProvider: mocks.requireSshFilesystemProvider -})) vi.mock('./clipboard-remote-file-copy', () => ({ cleanupExpiredRemoteClipboardFiles: vi.fn(), - scheduleLegacyRemoteClipboardFileCleanup: vi.fn(), - writeRemoteFileToClipboard: vi.fn() + scheduleLegacyRemoteClipboardFileCleanup: vi.fn() })) vi.mock('./native-chat-paste-files', () => ({ restoreNativeChatPastes: vi.fn(), @@ -88,11 +82,6 @@ beforeEach(() => { mocks.has.mockImplementation((type: string) => type === 'public.png') mocks.toPNG.mockReturnValue(PNG) mocks.writeFile.mockResolvedValue(undefined) - mocks.writeFileBase64.mockResolvedValue(undefined) - mocks.requireSshFilesystemProvider.mockReturnValue({ - getTempDir: async () => '/remote/tmp', - writeFileBase64: mocks.writeFileBase64 - }) setImage() setTrustedClipboardRendererWebContentsId(17) registerClipboardHandlers(new Store()) @@ -149,21 +138,11 @@ describe('macOS clipboard image data beside a file URL', () => { expect(mocks.writeFile).not.toHaveBeenCalled() }) - it('rejects oversized PNG bytes before looking up an SSH target', async () => { + it('rejects oversized PNG bytes before refusing an SSH target', async () => { mocks.toPNG.mockReturnValue(Buffer.alloc(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)) await expect( invoke('clipboard:saveImageAsTempFile', { connectionId: 'ssh-1' }) ).rejects.toThrow('Clipboard image is too large') - expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled() - expect(mocks.writeFileBase64).not.toHaveBeenCalled() - expect(mocks.writeFile).not.toHaveBeenCalled() - }) - - it('saves native image data on the SSH host instead of the client', async () => { - const savedPath = await invoke('clipboard:saveImageAsTempFile', { connectionId: 'ssh-1' }) - expect(savedPath).toMatch(/^\/remote\/tmp\/orca-paste-.+\.png$/) - expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1') - expect(mocks.writeFileBase64).toHaveBeenCalledWith(savedPath, PNG.toString('base64')) expect(mocks.writeFile).not.toHaveBeenCalled() }) }) diff --git a/src/main/window/clipboard-remote-file-copy.test.ts b/src/main/window/clipboard-remote-file-copy.test.ts deleted file mode 100644 index 1f851e1ec09..00000000000 --- a/src/main/window/clipboard-remote-file-copy.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - cleanupLegacyRemoteClipboardStagingMock, - createRemoteClipboardTransferDirectoryMock, - downloadFileMock, - requireSshFilesystemProviderMock, - RemoteClipboardStagingRootUnsafeErrorMock, - spanFailMock, - startSpanMock -} = vi.hoisted(() => { - class RemoteClipboardStagingRootUnsafeErrorMock extends Error {} - const spanFailMock = vi.fn() - return { - cleanupLegacyRemoteClipboardStagingMock: vi.fn(async () => undefined), - createRemoteClipboardTransferDirectoryMock: vi.fn(), - downloadFileMock: vi.fn(), - requireSshFilesystemProviderMock: vi.fn(), - RemoteClipboardStagingRootUnsafeErrorMock, - spanFailMock, - startSpanMock: vi.fn(() => ({ fail: spanFailMock })) - } -}) - -vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } })) -vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - requireSshFilesystemProvider: requireSshFilesystemProviderMock -})) -vi.mock('../observability/tracer', () => ({ startSpan: startSpanMock })) -vi.mock('./clipboard-file-copy', () => ({ writeFileToClipboard: vi.fn() })) -vi.mock('./clipboard-remote-file-staging', () => ({ - cleanupExpiredRemoteClipboardStaging: vi.fn(async () => undefined), - cleanupLegacyRemoteClipboardStaging: cleanupLegacyRemoteClipboardStagingMock, - createRemoteClipboardTransferDirectory: createRemoteClipboardTransferDirectoryMock, - RemoteClipboardStagingRootUnsafeError: RemoteClipboardStagingRootUnsafeErrorMock, - removeRemoteClipboardTransferDirectory: vi.fn(), - scheduleRemoteClipboardTransferCleanup: vi.fn() -})) - -import { - scheduleLegacyRemoteClipboardFileCleanup, - writeRemoteFileToClipboard -} from './clipboard-remote-file-copy' -import { RemoteClipboardStagingRootUnsafeError } from './clipboard-remote-file-staging' - -beforeEach(() => { - vi.clearAllMocks() - requireSshFilesystemProviderMock.mockReturnValue({ - stat: vi.fn(async () => ({ type: 'file' })), - downloadFile: downloadFileMock - }) -}) - -describe('remote clipboard staging failures', () => { - it('rejects remote directories before creating staging', async () => { - requireSshFilesystemProviderMock.mockReturnValue({ - stat: vi.fn(async () => ({ type: 'directory' })), - downloadFile: downloadFileMock - }) - - await expect( - writeRemoteFileToClipboard({ - remotePath: '/repo/src', - connectionId: 'ssh-1', - deps: { - platform: 'win32', - writeBuffer: vi.fn(), - runCommand: vi.fn() - } - }) - ).resolves.toEqual({ ok: false, reason: 'is-directory' }) - - expect(createRemoteClipboardTransferDirectoryMock).not.toHaveBeenCalled() - expect(downloadFileMock).not.toHaveBeenCalled() - }) - - it.each([ - { - category: 'unsafe-root', - error: new RemoteClipboardStagingRootUnsafeError() - }, - { - category: 'permissions', - error: Object.assign(new Error('access denied'), { code: 'EACCES' }) - }, - { - category: 'permissions', - error: Object.assign(new Error('operation not permitted'), { code: 'EPERM' }) - }, - { - category: 'path-conflict', - error: Object.assign(new Error('path exists'), { code: 'EEXIST' }) - }, - { - category: 'unavailable', - error: new Error('storage offline') - } - ])( - 'returns a toast-safe reason and records $category diagnostics', - async ({ category, error }) => { - createRemoteClipboardTransferDirectoryMock.mockRejectedValueOnce(error) - - await expect( - writeRemoteFileToClipboard({ - remotePath: '/repo/readme.md', - connectionId: 'ssh-1', - deps: { - platform: 'win32', - writeBuffer: vi.fn(), - runCommand: vi.fn() - } - }) - ).resolves.toEqual({ ok: false, reason: 'staging-unavailable' }) - - expect(startSpanMock).toHaveBeenCalledWith('clipboard.remote_staging_init', { - attributes: { - operation: 'create', - platform: process.platform, - failure_category: category - } - }) - expect(spanFailMock).toHaveBeenCalledWith(error) - expect(downloadFileMock).not.toHaveBeenCalled() - } - ) -}) - -describe('legacy remote clipboard cleanup scheduling', () => { - afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() - }) - - it('runs the shared-root compatibility scan once after the startup delay', async () => { - vi.useFakeTimers() - vi.spyOn(Date, 'now').mockReturnValue(1_760_000_000_000) - - scheduleLegacyRemoteClipboardFileCleanup() - scheduleLegacyRemoteClipboardFileCleanup() - await vi.advanceTimersByTimeAsync(29_999) - - expect(cleanupLegacyRemoteClipboardStagingMock).not.toHaveBeenCalled() - - await vi.advanceTimersByTimeAsync(1) - - expect(cleanupLegacyRemoteClipboardStagingMock).toHaveBeenCalledOnce() - expect(cleanupLegacyRemoteClipboardStagingMock).toHaveBeenCalledWith('/tmp', 1_760_000_000_000) - }) -}) diff --git a/src/main/window/clipboard-remote-file-copy.ts b/src/main/window/clipboard-remote-file-copy.ts index 47bbbc0c5bb..1deac08d2da 100644 --- a/src/main/window/clipboard-remote-file-copy.ts +++ b/src/main/window/clipboard-remote-file-copy.ts @@ -1,102 +1,13 @@ -import { randomUUID } from 'node:crypto' -import { stat } from 'node:fs/promises' -import { join } from 'node:path' - import { app } from 'electron' -import { getRuntimePathBasename } from '../../shared/cross-platform-path' -import { startSpan } from '../observability/tracer' -import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { - writeFileToClipboard, - type ClipboardFileDeps, - type ClipboardFileResult -} from './clipboard-file-copy' import { cleanupExpiredRemoteClipboardStaging, - cleanupLegacyRemoteClipboardStaging, - createRemoteClipboardTransferDirectory, - RemoteClipboardStagingRootUnsafeError, - removeRemoteClipboardTransferDirectory, - scheduleRemoteClipboardTransferCleanup + cleanupLegacyRemoteClipboardStaging } from './clipboard-remote-file-staging' -type RemoteClipboardFileDeps = Omit - const REMOTE_CLIPBOARD_LEGACY_CLEANUP_DELAY_MS = 30 * 1000 -const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i -const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) let legacyCleanupScheduled = false -export async function writeRemoteFileToClipboard({ - remotePath, - connectionId, - deps -}: { - remotePath: string - connectionId: string - deps: RemoteClipboardFileDeps -}): Promise { - const provider = requireSshFilesystemProvider(connectionId) - const remoteStat = await provider.stat(remotePath) - if (remoteStat.type === 'directory') { - return { ok: false, reason: 'is-directory' } - } - if (!provider.downloadFile) { - throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') - } - - const tempRoot = app.getPath('temp') - let tempDir: string - try { - tempDir = await createRemoteClipboardTransferDirectory(tempRoot, Date.now(), randomUUID()) - } catch (error) { - const span = startSpan('clipboard.remote_staging_init', { - attributes: { - operation: 'create', - platform: process.platform, - failure_category: getStagingFailureCategory(error) - } - }) - span.fail(error instanceof Error ? error : String(error)) - return { ok: false, reason: 'staging-unavailable' } - } - const localPath = join( - tempDir, - sanitizeLocalClipboardFilename(getRuntimePathBasename(remotePath)) - ) - let keepTempFile = false - - try { - await provider.downloadFile(remotePath, localPath) - const result = await writeFileToClipboard(localPath, { - ...deps, - resolveFilePath: async (path) => { - if (path !== localPath) { - return { ok: false, reason: 'invalid-path' } - } - try { - await stat(path) - return { ok: true, path } - } catch { - return { ok: false, reason: 'not-found' } - } - } - }) - if (result.ok) { - // Why: OS file clipboards keep a path reference, so the staged copy must - // survive after this IPC call long enough for the user to paste it. - keepTempFile = true - scheduleRemoteClipboardTransferCleanup(tempRoot, tempDir) - } - return result - } finally { - if (!keepTempFile) { - await removeRemoteClipboardTransferDirectory(tempRoot, tempDir) - } - } -} - export async function cleanupExpiredRemoteClipboardFiles(nowMs = Date.now()): Promise { await cleanupExpiredRemoteClipboardStaging(app.getPath('temp'), nowMs) } @@ -116,34 +27,8 @@ export function scheduleLegacyRemoteClipboardFileCleanup(): void { unrefTimer(timer) } -function sanitizeLocalClipboardFilename(remoteBasename: string): string { - const sanitized = Array.from(remoteBasename, (char) => - char.charCodeAt(0) < 32 || LOCAL_FILENAME_REPLACEMENT_CHARS.has(char) ? '_' : char - ) - .join('') - .replace(/[. ]+$/g, '') - if (!sanitized || WINDOWS_RESERVED_LOCAL_BASENAME.test(sanitized)) { - return 'download' - } - return sanitized -} - function unrefTimer(timer: ReturnType): void { if (typeof timer === 'object' && 'unref' in timer) { timer.unref() } } - -function getStagingFailureCategory(error: unknown): string { - if (error instanceof RemoteClipboardStagingRootUnsafeError) { - return 'unsafe-root' - } - const code = error instanceof Error && 'code' in error ? String(error.code) : undefined - if (code === 'EACCES' || code === 'EPERM') { - return 'permissions' - } - if (code === 'EEXIST' || code === 'ENOTDIR') { - return 'path-conflict' - } - return 'unavailable' -} 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 e2844455c38..bd24a43fe17 100644 --- a/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts +++ b/src/main/window/clipboard-runtime-owned-ssh-paste.test.ts @@ -1,6 +1,5 @@ -// Nested Remote Orca Server -> SSH image paste (#17679). The REAL ssh-filesystem-dispatch registry -// is used on purpose: the runtime's SSH target is never registered in the client process, so any -// route that consults the local registry fails exactly the way the report did. +// Nested Remote Orca Server -> SSH image paste (#17679). This process serves no SSH filesystem, so +// any route that resolved the runtime's SSH target locally would fail the way the report did. import { beforeEach, describe, expect, it, vi } from 'vitest' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' @@ -52,12 +51,7 @@ vi.mock('../ipc/runtime-environment-transport-routing', () => ({ vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer: () => false })) import { registerClipboardHandlers } from './clipboard-ipc-handlers' -import { - getSshFilesystemProvider, - registerSshFilesystemProvider, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE, - unregisterSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' +import { SSH_HOST_NOT_SERVED_MESSAGE } from '../providers/ssh-host-not-served-error' type SaveImageHandler = (event: unknown, args?: unknown) => Promise @@ -115,13 +109,10 @@ describe('clipboard image paste for a runtime-owned SSH workspace', () => { installFakeAppEnvironment({ getPath: () => '/tmp' }) callRuntimeEnvironmentMock.mockReset() fsWriteFileMock.mockReset() - unregisterSshFilesystemProvider(RUNTIME_SSH_TARGET) - unregisterSshFilesystemProvider(CLIENT_SSH_TARGET) }) it('sends the paste to the runtime and names the runtime SSH target, never this registry', async () => { mockRuntimeUpload() - expect(getSshFilesystemProvider(RUNTIME_SSH_TARGET)).toBeUndefined() // Built the way the renderer builds it: both owner ids, verbatim. const nestedArgs = { connectionId: RUNTIME_SSH_TARGET, runtimeEnvironmentId: RUNTIME_ID } @@ -188,25 +179,10 @@ describe('clipboard image paste for a runtime-owned SSH workspace', () => { }) }) - it('keeps a client-dialed SSH paste on this process registry without touching the runtime', async () => { - const writeFileBase64 = vi.fn().mockResolvedValue(undefined) - registerSshFilesystemProvider(CLIENT_SSH_TARGET, { - getTempDir: async () => '/var/tmp', - writeFileBase64 - } as never) - + it('reports the dropped-connection verdict for a client-dialed SSH target', async () => { await expect( saveImageHandler()(rendererEvent, { connectionId: CLIENT_SSH_TARGET }) - ).resolves.toMatch(/^\/var\/tmp\/orca-paste-.*\.png$/) - - expect(writeFileBase64).toHaveBeenCalledTimes(1) - expect(callRuntimeEnvironmentMock).not.toHaveBeenCalled() - }) - - it('still reports the dropped-connection verdict for a client-dialed SSH target that is gone', async () => { - await expect( - saveImageHandler()(rendererEvent, { connectionId: CLIENT_SSH_TARGET }) - ).rejects.toThrow(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + ).rejects.toThrow(SSH_HOST_NOT_SERVED_MESSAGE) expect(callRuntimeEnvironmentMock).not.toHaveBeenCalled() }) diff --git a/src/main/workspace-space-analysis-capacity.test.ts b/src/main/workspace-space-analysis-capacity.test.ts index 42a2a2cee48..5de2931823e 100644 --- a/src/main/workspace-space-analysis-capacity.test.ts +++ b/src/main/workspace-space-analysis-capacity.test.ts @@ -37,14 +37,6 @@ vi.mock('./repo-worktrees', () => ({ listRepoWorktrees: listRepoWorktreesMock })) -vi.mock('./providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: vi.fn() -})) - -vi.mock('./providers/ssh-git-dispatch', () => ({ - getSshGitProvider: vi.fn() -})) - import { analyzeWorkspaceSpace } from './workspace-space-analysis' function createStore(repo: Repo): Store { diff --git a/src/main/workspace-space-analysis-du-timeout.test.ts b/src/main/workspace-space-analysis-du-timeout.test.ts index de467d2a7d2..da8d237d37b 100644 --- a/src/main/workspace-space-analysis-du-timeout.test.ts +++ b/src/main/workspace-space-analysis-du-timeout.test.ts @@ -31,14 +31,6 @@ vi.mock('./repo-worktrees', () => ({ listRepoWorktrees: listRepoWorktreesMock })) -vi.mock('./providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: vi.fn() -})) - -vi.mock('./providers/ssh-git-dispatch', () => ({ - getSshGitProvider: vi.fn() -})) - import { analyzeWorkspaceSpace } from './workspace-space-analysis' function createStore(repos: Repo[]): Store { diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts index d6fbc4bb2d7..98d4ead1090 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -5,16 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../shared/repo-types' import type { Store } from './persistence' -const { - listRepoWorktreesMock, - getLocalProjectWorktreeGitOptionsMock, - getSshFilesystemProviderMock, - getSshGitProviderMock -} = vi.hoisted(() => ({ +const { listRepoWorktreesMock, getLocalProjectWorktreeGitOptionsMock } = vi.hoisted(() => ({ listRepoWorktreesMock: vi.fn(), - getLocalProjectWorktreeGitOptionsMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn(), - getSshGitProviderMock: vi.fn() + getLocalProjectWorktreeGitOptionsMock: vi.fn() })) vi.mock('./repo-worktrees', () => ({ @@ -28,14 +21,6 @@ vi.mock('./repo-worktrees', () => ({ listRepoWorktrees: listRepoWorktreesMock })) -vi.mock('./providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock -})) - -vi.mock('./providers/ssh-git-dispatch', () => ({ - getSshGitProvider: getSshGitProviderMock -})) - vi.mock('./project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock })) @@ -67,8 +52,6 @@ describe('analyzeWorkspaceSpace', () => { tempDir = await mkdtemp(join(tmpdir(), 'orca-space-')) listRepoWorktreesMock.mockReset() getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) - getSshFilesystemProviderMock.mockReset() - getSshGitProviderMock.mockReset() }) afterEach(async () => { @@ -349,164 +332,6 @@ describe('analyzeWorkspaceSpace', () => { expect(result.worktrees[0]?.sizeBytes).toBe(0) }) - it('scans SSH worktrees through the remote filesystem provider without following symlinks', async () => { - const repo: Repo = { - id: 'repo-remote', - path: '/remote/repo', - displayName: 'remote', - badgeColor: '#000', - addedAt: 0, - connectionId: 'ssh-1' - } - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/feature', - head: 'c', - branch: 'refs/heads/feature', - isBare: false, - isMainWorktree: false - } - ]) - }) - - const statByPath = new Map([ - ['/remote/feature', { size: 10, type: 'directory' as const, mtime: 0 }], - ['/remote/feature/node_modules', { size: 5, type: 'directory' as const, mtime: 0 }], - ['/remote/feature/node_modules/pkg.js', { size: 1000, type: 'file' as const, mtime: 0 }], - ['/remote/feature/file.log', { size: 200, type: 'file' as const, mtime: 0 }] - ]) - const readDirMock = vi.fn(async (dirPath: string) => { - if (dirPath === '/remote/feature') { - return [ - { name: 'node_modules', isDirectory: true, isSymlink: false }, - { name: 'linked-cache', isDirectory: true, isSymlink: true }, - { name: 'file.log', isDirectory: false, isSymlink: false } - ] - } - if (dirPath === '/remote/feature/node_modules') { - return [{ name: 'pkg.js', isDirectory: false, isSymlink: false }] - } - return [] - }) - const statMock = vi.fn(async (filePath: string) => { - const stat = statByPath.get(filePath) - if (!stat) { - throw Object.assign(new Error(`missing ${filePath}`), { code: 'ENOENT' }) - } - return stat - }) - getSshFilesystemProviderMock.mockReturnValue({ - readDir: readDirMock, - stat: statMock - }) - - const result = await analyzeWorkspaceSpace(createStore([repo])) - - expect(result.worktrees[0]?.status).toBe('ok') - expect(result.worktrees[0]?.sizeBytes).toBe(1215) - expect(result.worktrees[0]?.topLevelItems.map((item) => item.name)).toEqual([ - 'node_modules', - 'file.log', - 'linked-cache' - ]) - expect(statMock).not.toHaveBeenCalledWith('/remote/feature/linked-cache') - }) - - it('uses the SSH bulk Space scan provider when available', async () => { - const repo: Repo = { - id: 'repo-remote', - path: '/remote/repo', - displayName: 'remote', - badgeColor: '#000', - addedAt: 0, - connectionId: 'ssh-1' - } - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/remote/feature', - head: 'c', - branch: 'refs/heads/feature', - isBare: false, - isMainWorktree: false - } - ]) - }) - const scanWorkspaceSpace = vi.fn().mockResolvedValue({ - sizeBytes: 4096, - skippedEntryCount: 0, - topLevelItems: [ - { - name: 'node_modules', - path: '/remote/feature/node_modules', - kind: 'directory', - sizeBytes: 4096 - } - ], - omittedTopLevelItemCount: 0, - omittedTopLevelSizeBytes: 0 - }) - const readDir = vi.fn().mockResolvedValue([]) - const stat = vi.fn() - getSshFilesystemProviderMock.mockReturnValue({ scanWorkspaceSpace, readDir, stat }) - - const result = await analyzeWorkspaceSpace(createStore([repo])) - - expect(scanWorkspaceSpace).toHaveBeenCalledWith( - '/remote/feature', - expect.objectContaining({ signal: undefined }) - ) - expect(readDir).not.toHaveBeenCalled() - expect(stat).not.toHaveBeenCalled() - expect(result.worktrees[0]?.sizeBytes).toBe(4096) - }) - - it('bounds concurrent SSH fallback traversals in the main process', async () => { - // Why: each fallback traversal holds its own admission budget, so repo × - // worktree concurrency alone would stack six of them on the desktop heap. - const repos: Repo[] = ['ssh-1', 'ssh-2'].map((connectionId) => ({ - id: `repo-${connectionId}`, - path: `/remote/${connectionId}`, - displayName: connectionId, - badgeColor: '#000', - addedAt: 0, - connectionId - })) - getSshGitProviderMock.mockReturnValue({ - listWorktrees: vi.fn(async (repoPath: string) => - [0, 1, 2].map((index) => ({ - path: `${repoPath}/worktree-${index}`, - head: 'c', - branch: `refs/heads/worktree-${index}`, - isBare: false, - isMainWorktree: false - })) - ) - }) - - const releases: (() => void)[] = [] - let active = 0 - let peak = 0 - const stat = vi.fn(async () => { - active += 1 - peak = Math.max(peak, active) - await new Promise((resolve) => releases.push(resolve)) - active -= 1 - return { size: 0, type: 'directory' as const, mtime: 0 } - }) - getSshFilesystemProviderMock.mockReturnValue({ readDir: vi.fn(async () => []), stat }) - - const scan = analyzeWorkspaceSpace(createStore(repos)) - for (let index = 0; index < 6; index += 1) { - await vi.waitFor(() => expect(releases.length).toBeGreaterThan(index)) - releases[index]!() - } - - await expect(scan).resolves.toMatchObject({ scannedWorktreeCount: 6 }) - expect(peak).toBe(2) - }) - it('reports disconnected SSH repos without failing the whole analysis', async () => { const repo: Repo = { id: 'repo-remote', @@ -516,7 +341,6 @@ describe('analyzeWorkspaceSpace', () => { addedAt: 0, connectionId: 'ssh-1' } - getSshGitProviderMock.mockReturnValue(undefined) const result = await analyzeWorkspaceSpace(createStore([repo])) diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index 6c7ad082958..08c8ab7a3bf 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -21,7 +21,6 @@ import { const REPO_SCAN_CONCURRENCY = 2 const LOCAL_WORKTREE_SCAN_CONCURRENCY = 1 -const REMOTE_FALLBACK_SCAN_CONCURRENCY = 2 const DU_TIMEOUT_MS = 120_000 const DU_MAX_BUFFER_BYTES = 16 * 1024 * 1024 @@ -129,11 +128,7 @@ export async function analyzeWorkspaceSpace( } options.onProgress?.({ ...progress }) const limiters: WorkspaceSpaceScanLimiters = { - localWorktree: createWorkspaceSpaceScanLimiter(LOCAL_WORKTREE_SCAN_CONCURRENCY, options.signal), - remoteFallbackTraversal: createWorkspaceSpaceScanLimiter( - REMOTE_FALLBACK_SCAN_CONCURRENCY, - options.signal - ) + localWorktree: createWorkspaceSpaceScanLimiter(LOCAL_WORKTREE_SCAN_CONCURRENCY, options.signal) } const repoResults = await mapWithConcurrency(reposToScan, REPO_SCAN_CONCURRENCY, (repo) => scanWorkspaceSpaceRepo({ diff --git a/src/main/workspace-space-remote-scan.ts b/src/main/workspace-space-remote-scan.ts deleted file mode 100644 index c7936127f81..00000000000 --- a/src/main/workspace-space-remote-scan.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { DirEntry } from '../shared/filesystem-entry-types' -import type { Repo } from '../shared/repo-types' -import type { Worktree } from '../shared/worktree/types' -import type { WorkspaceSpaceWorktree } from '../shared/workspace-space-types' -import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction' -import { scanWorkspaceSpaceEntryTree } from '../shared/workspace-space-entry-traversal' -import type { IFilesystemProvider } from './providers/types' -import { - WorkspaceSpaceScanCancelledError, - classifyWorkspaceSpaceError, - isWorkspaceSpaceAbortError, - isWorkspaceSpaceRelayMethodMissing, - throwIfWorkspaceSpaceScanAborted, - type AsyncLimiter -} from './workspace-space-scan-control' -import { - basenameWorkspaceFilesystemPath, - createScannedWorkspaceSpaceRow, - createUnavailableWorkspaceSpaceRow, - joinWorkspaceFilesystemPath, - toWorkspaceSpaceItem -} from './workspace-space-worktree-row' - -const REMOTE_FS_CONCURRENCY = 10 - -async function scanRemoteEntry( - entryPath: string, - name: string, - provider: IFilesystemProvider, - signal?: AbortSignal -) { - return scanWorkspaceSpaceEntryTree({ - rootPath: entryPath, - rootName: name, - concurrency: REMOTE_FS_CONCURRENCY, - signal, - entryName: (entry) => entry.name, - joinPath: joinWorkspaceFilesystemPath, - classifyEntry: async (path, sourceEntry) => { - if (sourceEntry?.isSymlink) { - return { kind: 'symlink', sizeBytes: 0 } - } - const stats = await provider.stat(path) - throwIfWorkspaceSpaceScanAborted(signal) - if (stats.type === 'symlink') { - return { kind: 'symlink', sizeBytes: stats.size } - } - return stats.type === 'directory' - ? { kind: 'directory', sizeBytes: stats.size } - : { kind: 'file', sizeBytes: stats.size } - }, - readDirectory: (path) => provider.readDir(path), - checkCancelled: () => throwIfWorkspaceSpaceScanAborted(signal), - createCancellationError: () => new WorkspaceSpaceScanCancelledError(), - isCancellationError: (error) => error instanceof WorkspaceSpaceScanCancelledError - }) -} - -export async function scanRemoteWorkspaceSpaceWorktree( - repo: Repo, - worktree: Worktree, - scannedAt: number, - provider: IFilesystemProvider, - fallbackTraversalLimit: AsyncLimiter, - signal?: AbortSignal -): Promise { - try { - if (provider.scanWorkspaceSpace) { - try { - const scan = await provider.scanWorkspaceSpace(worktree.path, { signal }) - return createScannedWorkspaceSpaceRow(repo, worktree, scannedAt, scan) - } catch (error) { - if (isWorkspaceSpaceAbortError(error)) { - throw new WorkspaceSpaceScanCancelledError() - } - if (!isWorkspaceSpaceRelayMethodMissing(error)) { - throw error - } - } - } - const root = await fallbackTraversalLimit(() => - scanRemoteEntry( - worktree.path, - basenameWorkspaceFilesystemPath(worktree.path), - provider, - signal - ) - ) - return createScannedWorkspaceSpaceRow(repo, worktree, scannedAt, { - sizeBytes: root.sizeBytes, - skippedEntryCount: root.skippedEntryCount, - ...compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) - }) - } catch (error) { - if (error instanceof WorkspaceSpaceScanCancelledError) { - throw error - } - const classified = classifyWorkspaceSpaceError(error) - return createUnavailableWorkspaceSpaceRow( - repo, - worktree, - scannedAt, - classified.status, - classified.message - ) - } -} diff --git a/src/main/workspace-space-repo-scan.ts b/src/main/workspace-space-repo-scan.ts index fe2edbcebd3..370b803f345 100644 --- a/src/main/workspace-space-repo-scan.ts +++ b/src/main/workspace-space-repo-scan.ts @@ -16,8 +16,6 @@ import { resolveFilesystemRouteForHost, resolveGitRouteForHost } from './providers/execution-host-provider-dispatch' -import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' -import { getSshGitProvider } from './providers/ssh-git-dispatch' import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees' import { mergeWorktree } from './ipc/worktree-logic' import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options' @@ -28,7 +26,6 @@ import { type AsyncLimiter } from './workspace-space-scan-control' import { createUnavailableWorkspaceSpaceRow } from './workspace-space-worktree-row' -import { scanRemoteWorkspaceSpaceWorktree } from './workspace-space-remote-scan' import { scanLocalWorkspaceSpaceWorktree, type ReadLocalDuDepthOne @@ -48,7 +45,6 @@ export type WorkspaceSpaceAnalyzeOptions = { export type WorkspaceSpaceScanLimiters = { localWorktree: AsyncLimiter - remoteFallbackTraversal: AsyncLimiter } export type WorkspaceSpaceRepoScanResult = { @@ -104,17 +100,11 @@ async function listWorktreesForSpaceScan( } } if (route.kind === 'ssh') { - const provider = getSshGitProvider(route.connectionId) - if (!provider) { - return { - ok: false, - status: 'unavailable', - error: `SSH connection "${route.connectionId}" is not connected.` - } + return { + ok: false, + status: 'unavailable', + error: `SSH connection "${route.connectionId}" is not connected.` } - const worktrees = await provider.listWorktrees(repo.path, { signal }) - throwIfWorkspaceSpaceScanAborted(signal) - return { ok: true, worktrees } } const worktrees = await listRepoWorktrees(repo, { ...getLocalProjectWorktreeGitOptions(store, repo), @@ -208,8 +198,6 @@ export async function scanWorkspaceSpaceRepo(args: { options.onProgress ) const filesystemRoute = resolveFilesystemRouteForHost(getRepoExecutionHostId(repo)) - const sshFilesystemProvider = - filesystemRoute.kind === 'ssh' ? getSshFilesystemProvider(filesystemRoute.connectionId) : null const rows = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { throwIfWorkspaceSpaceScanAborted(options.signal) reportProgress( @@ -222,24 +210,15 @@ export async function scanWorkspaceSpaceRepo(args: { ) const row = filesystemRoute.kind !== 'local' - ? sshFilesystemProvider - ? await scanRemoteWorkspaceSpaceWorktree( - repo, - worktree, - scannedAt, - sshFilesystemProvider, - limiters.remoteFallbackTraversal, - options.signal - ) - : createUnavailableWorkspaceSpaceRow( - repo, - worktree, - scannedAt, - 'unavailable', - filesystemRoute.kind === 'ssh' - ? `SSH filesystem for "${filesystemRoute.connectionId}" is not connected.` - : `Host ${filesystemRoute.hostId} is not reachable from this process.` - ) + ? createUnavailableWorkspaceSpaceRow( + repo, + worktree, + scannedAt, + 'unavailable', + filesystemRoute.kind === 'ssh' + ? `SSH filesystem for "${filesystemRoute.connectionId}" is not connected.` + : `Host ${filesystemRoute.hostId} is not reachable from this process.` + ) : await limiters.localWorktree(() => scanLocalWorkspaceSpaceWorktree( repo, diff --git a/src/main/workspace-space-scan-control.ts b/src/main/workspace-space-scan-control.ts index 3d8aa73bfb1..8235222acab 100644 --- a/src/main/workspace-space-scan-control.ts +++ b/src/main/workspace-space-scan-control.ts @@ -22,12 +22,6 @@ export function isWorkspaceSpaceAbortError(error: unknown): boolean { ) } -export function isWorkspaceSpaceRelayMethodMissing(error: unknown): boolean { - return Boolean( - error && typeof error === 'object' && (error as { code?: unknown }).code === -32601 - ) -} - export function classifyWorkspaceSpaceError(error: unknown): { status: Exclude message: string diff --git a/src/main/worktree-removal-execution-host-route.ts b/src/main/worktree-removal-execution-host-route.ts index 0de4d2a1f36..01c81c64e57 100644 --- a/src/main/worktree-removal-execution-host-route.ts +++ b/src/main/worktree-removal-execution-host-route.ts @@ -12,7 +12,7 @@ * reachable by migrating the cleanup scan, so those rows now surface as removable candidates. * * Routing is now one answer for the whole removal, taken from the host the prune already used, so - * list, remove and prune cannot disagree. The ambiguous `provider: SshGitProvider | null` carrier + * list, remove and prune cannot disagree. The ambiguous nullable SSH-provider carrier * is deleted from the callees rather than supplemented, which makes every remaining reader a * compile error in the typed modules that do the destructive work. * diff --git a/src/preload/api/mobile-api.ts b/src/preload/api/mobile-api.ts index 6fc3e31ae7c..23c40f81504 100644 --- a/src/preload/api/mobile-api.ts +++ b/src/preload/api/mobile-api.ts @@ -25,7 +25,6 @@ export type MobileApi = { /** Natural bitmap width and height in pixels. */ qrSize: number | null qrError?: 'encoding_failed' - /** Phone-facing copy link: verified https, code in the fragment. The QR keeps `orca://`. */ pairingUrl: string /** Null when no direct address was advertised — the QR pairs over Relay alone. */ endpoint: string | null diff --git a/src/preload/api/mobile-bridge.ts b/src/preload/api/mobile-bridge.ts index f8103cc0cd7..d6144409bd2 100644 --- a/src/preload/api/mobile-bridge.ts +++ b/src/preload/api/mobile-bridge.ts @@ -27,7 +27,6 @@ export const mobileApi = { /** Natural bitmap width and height in pixels. */ qrSize: number | null qrError?: 'encoding_failed' - /** Phone-facing copy link: verified https, code in the fragment. The QR keeps `orca://`. */ pairingUrl: string /** Null when no direct address was advertised — the QR pairs over Relay alone. */ endpoint: string | null diff --git a/src/preload/api/repos-bridge.ts b/src/preload/api/repos-bridge.ts index b7eae7dbb03..ddbe22a1cc0 100644 --- a/src/preload/api/repos-bridge.ts +++ b/src/preload/api/repos-bridge.ts @@ -1,8 +1,4 @@ import { ipcRenderer } from 'electron' -import type { - HostRepoCatalogSnapshot, - ListReposForExecutionHostArgs -} from '../../shared/host-repo-catalog-contract' import type { BaseRefDefaultResult, BaseRefSearchResult } from '../../shared/repo-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { PreloadApi } from '../api-types' @@ -10,13 +6,8 @@ import type { PreloadApi } from '../api-types' export const reposApi = { list: () => ipcRenderer.invoke('repos:list'), - listForExecutionHost: (args: ListReposForExecutionHostArgs): Promise => - ipcRenderer.invoke('repos:listForExecutionHost', args), - add: (args) => ipcRenderer.invoke('repos:add', args), - addRemote: (args) => ipcRenderer.invoke('repos:addRemote', args), - create: (args) => ipcRenderer.invoke('repos:create', args), isGitAvailable: (): Promise => ipcRenderer.invoke('repos:isGitAvailable'), @@ -40,10 +31,6 @@ export const reposApi = { clone: (args) => ipcRenderer.invoke('repos:clone', args), - cloneRemote: (args) => ipcRenderer.invoke('repos:cloneRemote', args), - - createRemote: (args) => ipcRenderer.invoke('repos:createRemote', args), - cloneAbort: () => ipcRenderer.invoke('repos:cloneAbort'), onCloneProgress: (callback: (data: { phase: string; percent: number }) => void): (() => void) => { diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index dd76d3f1b23..e2a29543dca 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -1,9 +1,5 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { GhAccountBinding } from '../../shared/github/account-binding' -import type { - HostRepoCatalogSnapshot, - ListReposForExecutionHostArgs -} from '../../shared/host-repo-catalog-contract' import type { NestedRepoScanResult, ProjectGroup, @@ -27,7 +23,6 @@ import type { BaseRefDefaultResult, BaseRefSearchResult, Repo } from '../../shar export type RepositoryApi = { list: () => Promise - listForExecutionHost?: (args: ListReposForExecutionHostArgs) => Promise // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. add: (args: { path: string @@ -77,22 +72,8 @@ export type RepositoryApi = { pickFolders: () => Promise pickDirectory: () => Promise clone: (args: { url: string; destination: string }) => Promise - cloneRemote: (args: { connectionId: string; url: string; destination: string }) => Promise - createRemote: (args: { - connectionId: string - parentPath: string - name: string - kind: 'git' | 'folder' - }) => Promise<{ repo: Repo } | { error: string }> cloneAbort: () => Promise // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. - addRemote: (args: { - connectionId: string - remotePath: string - displayName?: string - kind?: 'git' | 'folder' - }) => Promise<{ repo: Repo } | { error: string }> - // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. create: (args: { parentPath: string name: string diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index a0b17122722..aa4bb092182 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -15,10 +15,6 @@ import type { FolderWorkspacePathStatus, FolderWorkspacePathStatusRequest } from '../../shared/folder-workspace-path-status' -import type { - HostLineageSnapshot, - ListDesktopLineageForHostArgs -} from '../../shared/host-lineage-contract' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { WorktreeBaseStatusEvent, @@ -131,7 +127,6 @@ export type WorktreeApi = { lineage: Record workspaceLineage?: Record }> - listLineageForHost?: (args: ListDesktopLineageForHostArgs) => Promise updateLineage: (args: { worktreeId: string parentWorktreeId?: string diff --git a/src/preload/api/worktrees-bridge.ts b/src/preload/api/worktrees-bridge.ts index b141c1cd17a..1dc4dfdd67e 100644 --- a/src/preload/api/worktrees-bridge.ts +++ b/src/preload/api/worktrees-bridge.ts @@ -1,8 +1,4 @@ import { ipcRenderer } from 'electron' -import type { - HostLineageSnapshot, - ListDesktopLineageForHostArgs -} from '../../shared/host-lineage-contract' import type { WorktreeBaseStatusEvent, WorktreeRemoteBranchConflictEvent @@ -60,9 +56,6 @@ export const worktreesApi = { listLineage: () => ipcRenderer.invoke('worktrees:listLineage'), - listLineageForHost: (args: ListDesktopLineageForHostArgs): Promise => - ipcRenderer.invoke('worktrees:listLineageForHost', args), - updateLineage: (args) => ipcRenderer.invoke('worktrees:updateLineage', args), persistSortOrder: (args) => ipcRenderer.invoke('worktrees:persistSortOrder', args), diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx index 9366a83b4b1..1ebc60eb014 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx @@ -136,7 +136,6 @@ describe('new workspace card file drop ownership', () => { selectedRepo: { id: 'source-repo' }, selectedRepoPath: source, selectedRepoExecutionHostId: 'ssh:ssh-a', - selectedRepoSettings: {}, connectionId: 'ssh-a' }) const dropState = useAttachmentDropState({ @@ -146,7 +145,7 @@ describe('new workspace card file drop ownership', () => { promptTextareaRef: createRef(), connectionId: target.connectionId, selectedRepoPath: target.path ?? undefined, - selectedRepoSettings: target.settings, + selectedRepoRuntimeEnvironmentId: target.runtimeEnvironmentId, selectedWorktreeId: target.worktreeId, setAgentPrompt: () => {}, setAttachmentPaths: attach diff --git a/src/renderer/src/components/automations/use-automations-page-setup-state.test.tsx b/src/renderer/src/components/automations/use-automations-page-setup-state.test.tsx new file mode 100644 index 00000000000..47befa87f4f --- /dev/null +++ b/src/renderer/src/components/automations/use-automations-page-setup-state.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { useAutomationsPageSetupState } from './use-automations-page-setup-state' + +type HookInput = Parameters[0] + +function cacheKeyFor(repos: Partial[], focused: string | null, repoId = 'repo-1'): string { + const { result } = renderHook(() => + useAutomationsPageSetupState({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cache key reads only repos and settings. + store: { + repos, + settings: { activeRuntimeEnvironmentId: focused }, + projectHostSetups: [], + worktreesByRepo: {} + } as unknown as HookInput['store'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: inert for the cache key. + local: { + draft: { projectId: repoId }, + automationHostTargetKey: null, + selectedAutomationRuns: { rowKey: null, ownerKey: null, notice: null }, + selectedAutomationRunPageId: null + } as unknown as HookInput['local'], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: inert for the cache key. + list: { + selected: null, + selectedRow: null, + selectedAutomationRunsWithWorkspaceNames: [], + capturedAutomationOwners: {}, + hostCatalog: { entries: [] } + } as unknown as HookInput['list'] + }) + ) + return result.current.getAutomationHooksCacheKey(repoId) +} + +describe('automation hooks cache key', () => { + it.each<[string, Partial[], string | null, string]>([ + [ + 'runtime owner', + [{ id: 'repo-1', executionHostId: 'runtime:owner' }], + 'focused', + 'owner:repo-1' + ], + ['local owner', [{ id: 'repo-1', executionHostId: 'local' }], 'focused', 'local:repo-1'], + ['unstamped row follows focus', [{ id: 'repo-1' }], 'focused', 'focused:repo-1'], + ['missing row follows focus', [], 'focused', 'focused:repo-1'], + ['missing row, local focus', [], null, 'local:repo-1'], + [ + 'duplicates fall back to focus', + [ + { id: 'repo-1', executionHostId: 'local' }, + { id: 'repo-1', executionHostId: 'runtime:owner' } + ], + 'other', + 'other:repo-1' + ] + ])('%s', (_name, repos, focused, expected) => { + expect(cacheKeyFor(repos, focused)).toBe(expected) + }) +}) diff --git a/src/renderer/src/components/automations/use-automations-page-setup-state.ts b/src/renderer/src/components/automations/use-automations-page-setup-state.ts index 5ada1d683d2..bf35d6f686d 100644 --- a/src/renderer/src/components/automations/use-automations-page-setup-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-setup-state.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo } from 'react' import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import type { AutomationDraft } from './AutomationEditorDialog' import { getAutomationHostTargetFromKey, type AutomationHostTarget } from './automation-host-client' @@ -72,14 +72,7 @@ export function useAutomationsPageSetupState({ (setup) => setup.repoId !== candidate.projectId || setup.hostId === setupHostId ) : projectHostSetups - const settingsForRepo = setupHostId - ? { - ...settings, - activeRuntimeEnvironmentId: - selectedEditAuthority?.kind === 'runtime' ? selectedEditAuthority.environmentId : null - } - : getSettingsForRepoRuntimeOwner({ repos, settings }, candidate.projectId) - const hookKey = `${setupHostId ?? settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${candidate.projectId}` + const hookKey = `${setupHostId ?? getRuntimeEnvironmentIdForRepo({ repos, settings }, candidate.projectId) ?? 'local'}:${candidate.projectId}` return getVisibleAutomationSetupDecision({ createTarget, workspaceMode: candidate.workspaceMode, @@ -105,8 +98,7 @@ export function useAutomationsPageSetupState({ if (hostId) { return `${hostId}:${repoId}` } - const settingsForRepo = getSettingsForRepoRuntimeOwner({ repos, settings }, repoId) - return `${settingsForRepo.activeRuntimeEnvironmentId ?? 'local'}:${repoId}` + return `${getRuntimeEnvironmentIdForRepo({ repos, settings }, repoId) ?? 'local'}:${repoId}` }, [repos, settings] ) diff --git a/src/renderer/src/components/editor/MermaidBlock.tsx b/src/renderer/src/components/editor/MermaidBlock.tsx index 6a5c1c8bc5d..f07f6b043ce 100644 --- a/src/renderer/src/components/editor/MermaidBlock.tsx +++ b/src/renderer/src/components/editor/MermaidBlock.tsx @@ -25,6 +25,7 @@ type MermaidBlockProps = { htmlLabels?: boolean className?: string pendingContent?: React.ReactNode + renderEnabled?: boolean } // Why: mermaid.render() manipulates global DOM state (element IDs, internal @@ -58,12 +59,24 @@ export default function MermaidBlock({ isDark, htmlLabels = false, className, - pendingContent + pendingContent, + renderEnabled = true }: MermaidBlockProps): React.JSX.Element { const id = useId().replace(/:/g, '_') - const [result, setResult] = useState<{ svg: string } | { error: string } | null>(null) + const [rendered, setRendered] = useState<{ + content: string + result: { svg: string } | { error: string } + } | null>(null) + // Why: new source waits as source, but a theme change keeps the old diagram until it re-renders. + const result = + pendingContent !== undefined && rendered?.content !== content + ? null + : (rendered?.result ?? null) useEffect(() => { + if (!renderEnabled) { + return + } let cancelled = false const render = async (): Promise => { @@ -85,13 +98,17 @@ export default function MermaidBlock({ // Why: although mermaid uses DOMPurify internally, we add an explicit // sanitization pass as defense-in-depth against XSS in case upstream // behaviour changes or a mermaid version ships without sanitization. - setResult({ - svg: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } }) + setRendered({ + content, + result: { svg: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } }) } }) } } catch (err) { if (!cancelled) { - setResult({ error: err instanceof Error ? err.message : 'Invalid mermaid syntax' }) + setRendered({ + content, + result: { error: err instanceof Error ? err.message : 'Invalid mermaid syntax' } + }) // Mermaid leaves an error element in the DOM on failure — clean it up. const errorEl = document.getElementById(`d${`mermaid-${id}`}`) errorEl?.remove() @@ -105,9 +122,9 @@ export default function MermaidBlock({ return () => { cancelled = true } - }, [content, htmlLabels, isDark, id]) + }, [content, htmlLabels, isDark, id, renderEnabled]) - if (result === null && pendingContent !== undefined) { + if ((!renderEnabled || result === null) && pendingContent !== undefined) { return <>{pendingContent} } diff --git a/src/renderer/src/components/editor/markdown-anchor-scroll.ts b/src/renderer/src/components/editor/markdown-anchor-scroll.ts index daf31bc2bfe..cc780b12da4 100644 --- a/src/renderer/src/components/editor/markdown-anchor-scroll.ts +++ b/src/renderer/src/components/editor/markdown-anchor-scroll.ts @@ -1,4 +1,5 @@ import { MarkdownHeadingSlugger } from './markdown-heading-slug' +import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion' // Why: duplicate headings need the same stateful suffixes as the preview // renderer (foo, foo-1, foo-2) or anchor links can jump to the wrong heading. @@ -16,7 +17,7 @@ export function scrollToAnchorInEditor(root: HTMLElement | null, anchor: string) const slugger = new MarkdownHeadingSlugger() for (const heading of headings) { if (slugger.slug(heading.textContent ?? '') === decoded) { - heading.scrollIntoView({ behavior: 'smooth', block: 'start' }) + heading.scrollIntoView({ behavior: motionSafeScrollBehavior(), block: 'start' }) return } } diff --git a/src/renderer/src/components/editor/use-markdown-preview-review-actions.ts b/src/renderer/src/components/editor/use-markdown-preview-review-actions.ts index b2ba13f41be..8e4797049fe 100644 --- a/src/renderer/src/components/editor/use-markdown-preview-review-actions.ts +++ b/src/renderer/src/components/editor/use-markdown-preview-review-actions.ts @@ -6,6 +6,7 @@ import type { MarkdownPreviewBlockRange } from './markdown-preview-types' import { requestMarkdownPreviewEditorRevealFrame } from './markdown-preview-editor-reveal' import type { MarkdownPreviewFoundation } from './use-markdown-preview-foundation' import type { MarkdownPreviewViewport } from './use-markdown-preview-viewport' +import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion' function isMarkdownAnnotationNavigationClick(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) { @@ -190,7 +191,7 @@ export function useMarkdownPreviewReviewActions({ return } findRenderedMarkdownReviewNoteCard(comment.id)?.scrollIntoView({ - behavior: 'smooth', + behavior: motionSafeScrollBehavior(), block: 'center', inline: 'nearest' }) @@ -226,7 +227,7 @@ export function useMarkdownPreviewReviewActions({ break } } - target?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + target?.scrollIntoView({ behavior: motionSafeScrollBehavior(), block: 'center' }) }, [rootRef, scrollToSourceLine, setActiveReviewCommentId] ) diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewRailController.ts b/src/renderer/src/components/editor/useRichMarkdownReviewRailController.ts index 45603510138..4922e4e8011 100644 --- a/src/renderer/src/components/editor/useRichMarkdownReviewRailController.ts +++ b/src/renderer/src/components/editor/useRichMarkdownReviewRailController.ts @@ -13,6 +13,7 @@ import { import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout' import { measureRichMarkdownReviewNotePositions } from './rich-markdown-review-note-positioning' import type { DiffComment } from '../../../../shared/diff-comment-types' +import { motionSafeScrollBehavior } from '@/hooks/usePrefersReducedMotion' type UseRichMarkdownReviewRailControllerOptions = { canAnnotateRichMarkdown: boolean @@ -182,7 +183,7 @@ export function useRichMarkdownReviewRailController({ setActiveReviewCommentId(comment.id) container.scrollTo({ top: Math.max(0, (sourceTop + sourceBottom) / 2 - container.clientHeight / 2), - behavior: 'smooth' + behavior: motionSafeScrollBehavior() }) pulseRichMarkdownSourceRange({ from: bounds.from, to: bounds.to }) }, @@ -257,5 +258,5 @@ function centerReviewNoteCard( return } const targetTop = cardTop - Math.max(0, (container.clientHeight - cardHeight) / 2) - container.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' }) + container.scrollTo({ top: Math.max(0, targetTop), behavior: motionSafeScrollBehavior() }) } diff --git a/src/renderer/src/components/native-chat/NativeChatCodeBlock.tsx b/src/renderer/src/components/native-chat/NativeChatCodeBlock.tsx index f6c266feed3..2366cefe84d 100644 --- a/src/renderer/src/components/native-chat/NativeChatCodeBlock.tsx +++ b/src/renderer/src/components/native-chat/NativeChatCodeBlock.tsx @@ -1,7 +1,7 @@ import React from 'react' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' -import { getCodeBlockLanguageLabel } from '@/components/editor/rich-markdown-code-block-languages' +import { getCodeFenceLanguageLabel } from './code-fence-language-label' import { NativeChatCopyButton } from './NativeChatCopyButton' import { NativeChatHighlightedCode } from './NativeChatHighlightedCode' @@ -25,7 +25,7 @@ export function NativeChatCodeBlock({ data-code-language={language} className="min-w-0 font-sans text-xs text-chat-foreground-faint" > - {getCodeBlockLanguageLabel(language)} + {getCodeFenceLanguageLabel(language)} {code ? ( expect(mocks.state.fieldProps?.imageAttachments?.[0]?.pending).toBeFalsy()) }) -it.each([ - { kind: 'local', acceptsImages: false }, - { kind: 'ssh', acceptsImages: false }, - { kind: 'local', acceptsImages: true }, - { kind: 'ssh', acceptsImages: true } -] as const)( - 'can remove a stalled $kind paste after reopen with acceptsImages=$acceptsImages', - async ({ kind, acceptsImages }) => { - mocks.state.owner = - kind === 'local' - ? { kind: 'local' } - : { - kind: 'ssh', - connectionId: 'ssh-1', - worktreePath: '/remote/folder', - expectedExecutionHostId: 'ssh:ssh-1', - expectedSshTargetId: 'ssh-1', - expectedSshConnectionGeneration: 1 - } +it.each([false, true])( + 'can remove a stalled paste after reopen with acceptsImages=%s', + async (acceptsImages) => { + mocks.state.owner = { kind: 'local' } vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) const finishUpload = holdUpload() writeNativeChatDraftCache(PANE, 'look at this') @@ -338,13 +323,7 @@ it.each([ fireEvent.click(screen.getByRole('button', { name: 'Remove attachment' })) ) expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(false) - await act(async () => - finishUpload( - kind === 'local' - ? '/local/native-chat-pastes/orca-paste-1-image.png' - : '/tmp/orca-paste-1-image.png' - ) - ) + await act(async () => finishUpload('/local/native-chat-pastes/orca-paste-1-image.png')) expect(readNativeChatAttachmentCache(PANE)).toEqual([]) expect(readNativeChatDraftCache(PANE)).toBe('look at this') expect(mocks.state.fieldProps?.imageAttachments).toEqual([]) diff --git a/src/renderer/src/components/native-chat/NativeChatMarkdown.mermaid.test.tsx b/src/renderer/src/components/native-chat/NativeChatMarkdown.mermaid.test.tsx index e32622cbc76..8667044424c 100644 --- a/src/renderer/src/components/native-chat/NativeChatMarkdown.mermaid.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMarkdown.mermaid.test.tsx @@ -4,12 +4,20 @@ import { act, cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mermaid = vi.hoisted(() => ({ initialize: vi.fn(), render: vi.fn() })) +const theme = vi.hoisted(() => ({ value: 'light', listeners: new Set<() => void>() })) vi.mock('mermaid', () => ({ default: mermaid })) vi.mock('dompurify', () => ({ default: { sanitize: (svg: string) => svg } })) -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: { settings: { theme: string } }) => unknown) => - selector({ settings: { theme: 'light' } }) -})) +vi.mock('@/store', async () => { + const { useSyncExternalStore } = await import('react') + const subscribe = (listener: () => void): (() => void) => { + theme.listeners.add(listener) + return () => theme.listeners.delete(listener) + } + return { + useAppStore: (selector: (state: { settings: { theme: string } }) => unknown) => + selector({ settings: { theme: useSyncExternalStore(subscribe, () => theme.value) } }) + } +}) import { NativeChatMarkdown } from './NativeChatMarkdown' import { NativeChatCodeBlock } from './NativeChatCodeBlock' @@ -31,24 +39,58 @@ function reply(source: string, streaming: boolean): React.JSX.Element { ) } +const heldRenders: PromiseWithResolvers<{ svg: string }>[] = [] +/** Holds the next Mermaid render until the test settles it. */ +function holdNextRender(): PromiseWithResolvers<{ svg: string }> { + const held = Promise.withResolvers<{ svg: string }>() + heldRenders.push(held) + mermaid.render.mockReturnValueOnce(held.promise) + return held +} + +function setTheme(value: string): void { + theme.value = value + act(() => theme.listeners.forEach((listener) => listener())) +} + beforeEach(() => { mermaid.render.mockReset() mermaid.render.mockResolvedValue({ svg: 'Sync with main once' }) }) -afterEach(cleanup) +afterEach(() => { + cleanup() + theme.value = 'light' + // Renders share one queue: a render a failed test left held would stall the next test's. + for (const held of heldRenders.splice(0)) { + held.resolve({ svg: '' }) + } +}) describe('native chat Mermaid fences', () => { - it('preserves the source block geometry while the completed diagram renders', async () => { - const pending = Promise.withResolvers<{ svg: string }>() - mermaid.render.mockReturnValueOnce(pending.promise) - const { container, rerender } = render(reply(complete, true)) + it('preserves the source node, horizontal scroll and focus while the completed diagram renders', async () => { + const pending = holdNextRender() + const wide = `flowchart LR\n A["${'Wide diagram source '.repeat(30)}"] --> B` + const { container, rerender } = render(reply(wide, true)) const source = container.querySelector('[data-code-language="mermaid"]') expect(source).not.toBeNull() const markup = source?.outerHTML + const pre = container.querySelector('pre') + if (!pre) { + throw new Error('Missing diagram source') + } + pre.scrollLeft = 90 + const copy = screen.getByRole('button', { name: 'Copy code' }) + copy.focus() - rerender(reply(complete, false)) + rerender(reply(wide, false)) await waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(1)) expect(container.querySelector('[data-code-language="mermaid"]')?.outerHTML).toBe(markup) + expect(container.querySelector('[data-code-language="mermaid"]')).toBe(source) + expect(container.querySelector('pre')).toBe(pre) + expect(pre.isConnected).toBe(true) + expect(pre.scrollLeft).toBe(90) + expect(screen.getByRole('button', { name: 'Copy code' })).toBe(copy) + expect(copy).toHaveFocus() expect(container.querySelector('.mermaid-block')).toBeNull() await act(async () => pending.resolve({ svg: 'Finished' })) @@ -63,11 +105,9 @@ describe('native chat Mermaid fences', () => { ) expect(container.querySelector('[data-code-language="mermaid"]')).toBeInTheDocument() expect(container.querySelector('.mermaid-block')).toBeNull() - expect(mermaid.render).not.toHaveBeenCalled() rerender(reply(complete, true)) expect(container.querySelector('pre')?.textContent).toContain(complete) - expect(mermaid.render).not.toHaveBeenCalled() rerender(reply(complete, false)) await waitFor(() => @@ -85,4 +125,64 @@ describe('native chat Mermaid fences', () => { expect(container.querySelector('pre')?.textContent).toBe(partial.trimEnd()) expect(container.querySelector('svg')).toBeNull() }) + + it('keeps the source mounted until a pending diagram fails', async () => { + const pending = holdNextRender() + const view = render(reply(partial, true)) + const source = view.container.querySelector('pre') + view.rerender(reply(partial, false)) + await waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(1)) + expect(view.container.querySelector('pre')).toBe(source) + expect(screen.queryByText(/Diagram error:/)).toBeNull() + await act(async () => pending.reject(new Error('Invalid mermaid syntax'))) + expect(screen.getByText(/Diagram error: Invalid mermaid syntax/)).toBeInTheDocument() + expect(view.container.querySelector('pre')?.textContent).toBe(partial.trimEnd()) + }) + + it('waits for the updated diagram when a previously rendered fence grows again', async () => { + const view = render(reply(complete, false)) + await waitFor(() => + expect(view.container.querySelector('.mermaid-block svg')).toBeInTheDocument() + ) + const next = `${complete}\n B["Next step"]` + view.rerender(reply(next, true)) + const source = view.container.querySelector('pre') + expect(source).toHaveTextContent('Next step') + const pending = holdNextRender() + view.rerender(reply(next, false)) + await waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(2)) + expect(view.container.querySelector('pre')).toBe(source) + expect(view.container.querySelector('.mermaid-block svg')).toBeNull() + await act(async () => pending.resolve({ svg: 'Next step' })) + expect(view.container.querySelector('.mermaid-block svg')).toHaveTextContent('Next step') + }) + + it('does not run Mermaid on a reply that is still streaming', async () => { + const finished = 'flowchart TD\n S["Finished elsewhere"]' + render( + <> + {reply(partial, true)} + {reply(finished, false)} + + ) + // Renders run in order, so the finished diagram rendering means the streaming one's turn passed. + await waitFor(() => expect(mermaid.render).toHaveBeenCalledWith(expect.any(String), finished)) + expect(mermaid.render).toHaveBeenCalledTimes(1) + }) + + it('keeps the rendered diagram through a theme change until the new one is ready', async () => { + const view = render(reply(complete, false)) + await waitFor(() => + expect(view.container.querySelector('.mermaid-block svg')).toBeInTheDocument() + ) + const pending = holdNextRender() + setTheme('dark') + await waitFor(() => expect(mermaid.render).toHaveBeenCalledTimes(2)) + expect(view.container.querySelector('.mermaid-block svg')).toHaveTextContent( + 'Sync with main once' + ) + expect(view.container.querySelector('[data-code-language="mermaid"]')).toBeNull() + await act(async () => pending.resolve({ svg: 'Dark' })) + expect(view.container.querySelector('.mermaid-block svg')).toHaveTextContent('Dark') + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx index 4c44f539216..47307ff60f4 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.subagent-sections.test.tsx @@ -370,6 +370,25 @@ import { expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() }) + it("does not replay a working subagent's streaming reply when its section reopens", () => { + const items = itemsWith('working', false).filter((next) => next.itemId !== 'child-edit') + render( + + ) + expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: true })) + expect(screen.queryByText('The PR is CLEAN.')).toBeNull() + + // Back as the transcript's last, still-streaming row: already read, so it shows whole at once. + fireEvent.click(screen.getByRole('button', { name: /explore the lane/, expanded: false })) + expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument() + }) + it('names a section from the client roster when its roster row is not loaded', () => { const items = itemsWith('completed', true) const loaded = items.filter((next) => next.itemId !== 'spawn') diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 8ff0e413f44..3ae1545874c 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -157,6 +157,11 @@ export function NativeChatMessageList({ subagentDisclosure, openSubagentSections } = useNativeChatSubagentSections(messages, subagentRows, subagentRoster) + // Folded subagent rows stay loaded, so a reopened reply keeps its reveal. + const loadedMessageIds = useMemo( + () => new Set([...messages.map(({ id }) => id), ...subagentSections.pathOf.keys()]), + [messages, subagentSections] + ) const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages]) const taskListState = useMemo(() => nativeChatTaskListState(messages), [messages]) // Each row's turn, which turn is live, and the order the rows draw in, resolved once. @@ -389,6 +394,7 @@ export function NativeChatMessageList({ > diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index 943e0d28202..e1cd980a791 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx @@ -61,7 +61,7 @@ describe('MessageRow control visibility', () => { /> ) - expect(screen.getByText('ts')).toBeInTheDocument() + expect(screen.getByText('TypeScript')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Copy code' })) await waitFor(() => { diff --git a/src/renderer/src/components/native-chat/NativeChatPacedMarkdown.mermaid.test.tsx b/src/renderer/src/components/native-chat/NativeChatPacedMarkdown.mermaid.test.tsx index f706158c122..8ebfcb70f00 100644 --- a/src/renderer/src/components/native-chat/NativeChatPacedMarkdown.mermaid.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPacedMarkdown.mermaid.test.tsx @@ -12,7 +12,18 @@ const reveals: NativeChatReplyReveals = { begun: new Set(['reply']), drawn: new const mermaid = vi.hoisted(() => ({ rendered: vi.fn() })) vi.mock('@/components/sidebar/CommentMermaidBlock', () => ({ - default: ({ content: code }: { content: string }) => { + default: ({ + content: code, + pendingContent, + renderEnabled + }: { + content: string + pendingContent?: React.ReactNode + renderEnabled?: boolean + }) => { + if (renderEnabled === false) { + return <>{pendingContent} + } mermaid.rendered(code) return
{code}
} diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx index d6f94c5e618..53909e02e30 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptItems.tsx @@ -13,15 +13,17 @@ import { /** Mounted rows own their height; only unloaded gaps use cached measurements. */ export function NativeChatTranscriptItems({ slots, + loadedMessageIds, context, window }: { slots: readonly NativeChatTranscriptSlot[] + loadedMessageIds: ReadonlySet context: NativeChatTranscriptRowContext window: NativeChatTranscriptWindow }): React.JSX.Element { const rowKeys = useMemo(() => slots.map(nativeChatSlotKey), [slots]) - const replyReveals = useNativeChatReplyReveals(rowKeys) + const replyReveals = useNativeChatReplyReveals(rowKeys, loadedMessageIds) return (
diff --git a/src/renderer/src/components/native-chat/code-fence-language-label.test.ts b/src/renderer/src/components/native-chat/code-fence-language-label.test.ts new file mode 100644 index 00000000000..35978db437e --- /dev/null +++ b/src/renderer/src/components/native-chat/code-fence-language-label.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { getCodeFenceLanguageLabel } from './code-fence-language-label' + +describe('getCodeFenceLanguageLabel', () => { + it.each([ + ['typescript', 'TypeScript'], + ['ts', 'TypeScript'], + ['py', 'Python'], + ['bash', 'Bash'], + ['yml', 'YAML'], + ['vue', 'Vue'], + ['tsx', 'TSX'], + ['ps1', 'PowerShell'], + ['Python', 'Python'] + ])('names ```%s as %s', (tag, label) => { + expect(getCodeFenceLanguageLabel(tag)).toBe(label) + }) + + it('keeps a tag the catalogue does not know as written', () => { + expect(getCodeFenceLanguageLabel('my-dsl')).toBe('my-dsl') + }) +}) diff --git a/src/renderer/src/components/native-chat/code-fence-language-label.ts b/src/renderer/src/components/native-chat/code-fence-language-label.ts new file mode 100644 index 00000000000..600a70be38d --- /dev/null +++ b/src/renderer/src/components/native-chat/code-fence-language-label.ts @@ -0,0 +1,23 @@ +import { bundledLanguagesInfo } from 'shiki/langs' +import { + getCodeBlockLanguageLabel, + isKnownCodeBlockLanguage +} from '@/components/editor/rich-markdown-code-block-languages' + +/** + * A fence tag's name: the code block picker's label, else the highlighting + * catalogue's name for it or its alias (```vue, ```ts), else the tag itself. + */ +export function getCodeFenceLanguageLabel(tag: string): string { + const key = tag.trim().toLowerCase() + if (isKnownCodeBlockLanguage(key)) { + return getCodeBlockLanguageLabel(key) + } + const info = bundledLanguagesInfo.find( + (language) => language.id === key || language.aliases?.includes(key) + ) + if (!info) { + return tag + } + return isKnownCodeBlockLanguage(info.id) ? getCodeBlockLanguageLabel(info.id) : info.name +} diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts index e94dbad20d5..94d9ea6816e 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts @@ -5,25 +5,11 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { getDefaultSettings } from '../../../../shared/constants' const mocks = vi.hoisted(() => ({ - toastLoading: vi.fn(() => 'toast-1'), - toastDismiss: vi.fn(), - toastError: vi.fn(), - toastMessage: vi.fn(), - resolveDroppedPathsForAgent: vi.fn(), callRuntimeRpc: vi.fn() })) vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) -vi.mock('sonner', () => ({ - toast: { - loading: mocks.toastLoading, - dismiss: mocks.toastDismiss, - error: mocks.toastError, - message: mocks.toastMessage - } -})) - vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, values?: Record) => fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => String(values?.[name])) @@ -34,8 +20,7 @@ import { prepareNativeChatSessionAttachmentUpload, resolveNativeChatAttachmentOwner, resolveNativeChatAttachmentOwnerForWorktree, - resolveNativeChatRuntimeSessionAttachmentOwner, - uploadNativeChatAttachmentPaths + resolveNativeChatRuntimeSessionAttachmentOwner } from './native-chat-attachment-upload' import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' import { AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' @@ -95,42 +80,15 @@ describe('resolveNativeChatAttachmentOwner', () => { }) }) - it('resolves a structured SSH owner directly from its worktree', () => { - expect( - resolveNativeChatAttachmentOwnerForWorktree( - state({ - repos: [{ id: 'repo', connectionId: 'conn-1' }] as never, - sshConnectionStates: new Map([['conn-1', { connectionGeneration: 4 } as never]]) - }), - 'wt-1' - ) - ).toMatchObject({ - kind: 'ssh', - connectionId: 'conn-1', - worktreePath: '/repo/worktree' + it('refuses client files for an SSH-owned worktree, whatever its connection state', () => { + const sshState = state({ repos: [repoFixture({ connectionId: 'conn-1' })] }) + expect(resolveNativeChatAttachmentOwner(sshState, 'tab-1')).toEqual({ kind: 'remote' }) + expect(resolveNativeChatAttachmentOwnerForWorktree(sshState, 'wt-1')).toEqual({ + kind: 'remote' }) }) - it('resolves an SSH repo worktree to ssh with the worktree path', () => { - expect( - resolveNativeChatAttachmentOwner( - state({ - repos: [{ id: 'repo', connectionId: 'conn-1' }] as never, - sshConnectionStates: new Map([['conn-1', { connectionGeneration: 4 } as never]]) - }), - 'tab-1' - ) - ).toEqual({ - kind: 'ssh', - connectionId: 'conn-1', - worktreePath: '/repo/worktree', - expectedExecutionHostId: 'ssh:conn-1', - expectedSshTargetId: 'conn-1', - expectedSshConnectionGeneration: 4 - }) - }) - - it('resolves a runtime-owned repo to runtime', () => { + it('refuses client files for a runtime-owned repo', () => { expect( resolveNativeChatAttachmentOwner( state({ @@ -138,7 +96,7 @@ describe('resolveNativeChatAttachmentOwner', () => { }), 'tab-1' ) - ).toEqual({ kind: 'runtime' }) + ).toEqual({ kind: 'remote' }) }) it('keeps a recorded local workspace local when a runtime is focused', () => { @@ -161,90 +119,6 @@ describe('resolveNativeChatAttachmentOwner', () => { kind: 'not-ready' }) }) - - it('reports not-ready instead of throwing when the SSH generation is gone', () => { - expect( - resolveNativeChatAttachmentOwner( - state({ - repos: [{ id: 'repo', connectionId: 'conn-1' }] as never, - sshConnectionStates: new Map() - }), - 'tab-1' - ) - ).toEqual({ kind: 'not-ready' }) - }) - - it('reports not-ready when an SSH worktree has no known path yet', () => { - expect( - resolveNativeChatAttachmentOwner( - state({ - repos: [{ id: 'repo', connectionId: 'conn-1' }] as never, - worktreesByRepo: { repo: [{ id: 'wt-1', repoId: 'repo' } as never] }, - tabsByWorktree: { 'wt-1': [terminalTab()] } - }), - 'tab-1' - ) - ).toEqual({ kind: 'not-ready' }) - }) -}) - -describe('uploadNativeChatAttachmentPaths', () => { - const owner = { - kind: 'ssh' as const, - connectionId: 'conn-1', - worktreePath: '/remote/worktree', - expectedExecutionHostId: 'ssh:conn-1' as const, - expectedSshTargetId: 'conn-1', - expectedSshConnectionGeneration: 4 - } - - beforeEach(() => { - vi.clearAllMocks() - vi.stubGlobal('window', { - api: { fs: { resolveDroppedPathsForAgent: mocks.resolveDroppedPathsForAgent } } - }) - }) - - it('uploads through the terminal drop resolver and returns remote paths', async () => { - mocks.resolveDroppedPathsForAgent.mockResolvedValue({ - resolvedPaths: ['/remote/worktree/.orca/drops/a.txt'], - skipped: [], - failed: [] - }) - await expect(uploadNativeChatAttachmentPaths(['/local/a.txt'], owner)).resolves.toEqual([ - '/remote/worktree/.orca/drops/a.txt' - ]) - expect(mocks.resolveDroppedPathsForAgent).toHaveBeenCalledWith({ - paths: ['/local/a.txt'], - worktreePath: '/remote/worktree', - connectionId: 'conn-1', - expectedExecutionHostId: 'ssh:conn-1', - expectedSshTargetId: 'conn-1', - expectedSshConnectionGeneration: 4 - }) - expect(mocks.toastLoading).toHaveBeenCalledTimes(1) - expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1') - }) - - it('surfaces per-file skips and failures through the shared drop toasts', async () => { - mocks.resolveDroppedPathsForAgent.mockResolvedValue({ - resolvedPaths: [], - skipped: [{ sourcePath: '/local/link', reason: 'symlink' }], - failed: [{ sourcePath: '/local/b.txt', reason: 'boom' }] - }) - await expect( - uploadNativeChatAttachmentPaths(['/local/link', '/local/b.txt'], owner) - ).resolves.toEqual([]) - expect(mocks.toastMessage).toHaveBeenCalledTimes(1) - expect(mocks.toastError).toHaveBeenCalledTimes(1) - }) - - it('returns null and reports when the upload IPC fails', async () => { - mocks.resolveDroppedPathsForAgent.mockRejectedValue(new Error('sftp down')) - await expect(uploadNativeChatAttachmentPaths(['/local/a.txt'], owner)).resolves.toBeNull() - expect(mocks.toastError).toHaveBeenCalledTimes(1) - expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1') - }) }) describe('a structured chat on a paired server', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index 0ca498fa617..68a4351006c 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -1,16 +1,12 @@ // Host-aware resolution for composer attachments (STA-1465). The composer's // attach surfaces (file drop, file picker, image paste) receive client-local -// paths, but an SSH worktree's agent runs on the remote host — local paths must -// be uploaded first, exactly like terminal drops (docs/terminal-drop-ssh.md). A -// structured chat on a paired server uploads into that server's attachment store. +// paths. A structured chat on a paired server uploads into that server's +// attachment store; any other remote chat refuses client files. -import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -import { extractIpcErrorMessage } from '@/lib/ipc-error' import { findKnownWorktreeById } from '@/store/slices/worktrees/listing/detected-worktree-meta' import { getConnectionExecutionHostId, - parseExecutionHostId, toRuntimeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' @@ -30,19 +26,8 @@ import type { AgentSessionAttachmentUploadTarget } from '../../../../shared/agent-session-attachments' import type { AppState } from '@/store/types' -import { reportTerminalDropUploadSkipsAndFailures } from '../terminal-pane/terminal-drop-upload-report' import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop' import { findTerminalTabWorktreeId } from './native-chat-file-link' -import { - captureDirectSshMutationExpectation, - type DirectSshMutationExpectation -} from '@/lib/ssh-mutation-expectation' - -export type NativeChatSshAttachmentOwner = DirectSshMutationExpectation & { - kind: 'ssh' - connectionId: string - worktreePath: string -} /** A structured chat on a paired server: files upload into that server's attachment store, which * checks every stored path a message names when it admits the message. */ @@ -55,11 +40,11 @@ export type NativeChatRuntimeSessionAttachmentOwner = { export type NativeChatAttachmentOwner = | { kind: 'local' } - | NativeChatSshAttachmentOwner | NativeChatRuntimeSessionAttachmentOwner - /** A terminal-backed chat on a paired server: its agent may run on that server's SSH or WSL - * host, which the server's store cannot reach, so it keeps refusing client files. */ - | { kind: 'runtime' } + /** Refuses client files: a terminal-backed chat on a paired server (its agent may run on that + * server's SSH or WSL host, which the server's store cannot reach), or an `ssh:` host, which + * only its paired server serves. */ + | { kind: 'remote' } /** Store not hydrated / worktree unknown. Callers must not attach local * paths in this window — the worktree may turn out to be remote, and the * agent would silently receive paths it cannot read (see #6648). */ @@ -118,35 +103,10 @@ export function resolveNativeChatAttachmentOwnerForWorktree( if (!hostId) { return { kind: 'not-ready' } } - if (parseExecutionHostId(hostId)?.kind === 'runtime') { - return { kind: 'runtime' } - } if (hostId === 'local') { return { kind: 'local' } } - const connectionId = getConnectionIdFromState(state, worktreeId) - if (connectionId === undefined) { - return { kind: 'not-ready' } - } - if (connectionId === null) { - return { kind: 'not-ready' } - } - const worktreePath = findKnownWorktreeById(state, worktreeId, hostId)?.path - if (!worktreePath) { - return { kind: 'not-ready' } - } - try { - return { - kind: 'ssh', - connectionId, - worktreePath, - ...captureDirectSshMutationExpectation(state, connectionId) - } - } catch { - // The connection's generation is gone (disconnect mid-attach). That is an - // unknown owner, not a reason to throw out of the drop/IME handler. - return { kind: 'not-ready' } - } + return { kind: 'remote' } } /** The chat behind a structured composer, as the attachment owner needs it. */ @@ -220,42 +180,6 @@ export function nativeChatTooManyAttachmentsNotice(): string { export { nativeChatLocalAttachmentUnsupportedNotice } from './native-chat-composer-target' -/** - * Upload client-local paths into `${worktreePath}/.orca/drops` on the SSH - * remote and return the remote paths the agent can read (input order - * preserved). Returns null when the upload IPC itself failed; per-file - * skips/failures surface through the shared drop toasts. - */ -export async function uploadNativeChatAttachmentPaths( - paths: string[], - owner: NativeChatSshAttachmentOwner -): Promise { - const pending = toast.loading( - translate( - 'components.native-chat.composer.uploadingAttachments', - 'Uploading {{value0}} file(s) to remote…', - { value0: paths.length } - ) - ) - try { - const { resolvedPaths, skipped, failed } = await window.api.fs.resolveDroppedPathsForAgent({ - paths, - worktreePath: owner.worktreePath, - connectionId: owner.connectionId, - expectedExecutionHostId: owner.expectedExecutionHostId, - expectedSshTargetId: owner.expectedSshTargetId, - expectedSshConnectionGeneration: owner.expectedSshConnectionGeneration - }) - reportTerminalDropUploadSkipsAndFailures(skipped, failed) - return resolvedPaths - } catch (err) { - toast.error(extractIpcErrorMessage(err, 'Failed to upload files.')) - return null - } finally { - toast.dismiss(pending) - } -} - /** * Ask the chat's server, right before uploading, whether it keeps chat attachments, and which * server process will receive the bytes. An older server gets the update notice instead of an diff --git a/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts b/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts index d0fd3b9268d..1e65eb59caa 100644 --- a/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts +++ b/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts @@ -6,20 +6,17 @@ import { type NativeChatAttachmentOwner } from './native-chat-attachment-upload' -type ClipboardImageOwner = Extract< - NativeChatAttachmentOwner, - { kind: 'local' | 'ssh' | 'runtime-session' } -> +type ClipboardImageOwner = Extract /** Owners whose attachment path is a file this client can write right now. */ export function ownerAcceptsClipboardImage( owner: NativeChatAttachmentOwner ): owner is ClipboardImageOwner { - return owner.kind === 'local' || owner.kind === 'ssh' || owner.kind === 'runtime-session' + return owner.kind === 'local' || owner.kind === 'runtime-session' } -/** Where the clipboard image is saved: this machine, the SSH host, or the chat's store on its - * paired server, after asking that server whether it keeps one. */ +/** Where the clipboard image is saved: this machine, or the chat's store on its paired server, + * after asking that server whether it keeps one. */ async function clipboardImageSaveArgs( owner: ClipboardImageOwner ): Promise< @@ -29,9 +26,6 @@ async function clipboardImageSaveArgs( if (owner.kind === 'local') { return { ok: true, args: { forNativeChatDraft: true } } } - if (owner.kind === 'ssh') { - return { ok: true, args: { connectionId: owner.connectionId } } - } const prepared = await prepareNativeChatSessionAttachmentUpload(owner) if (!prepared.ok) { return { ...prepared, cause: 'serverTooOld' } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index a4a2ca227ce..a769887d443 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -25,20 +25,21 @@ const electron = vi.hoisted(() => ({ getPathForFile: vi.fn((file: File) => `/repro/${file.name}`) })) -const intake = vi.hoisted(() => ({ - owner: { kind: 'local' } as { kind: string; connectionId?: string }, - stat: vi.fn(), - readFile: vi.fn(), - upload: vi.fn(), - prepare: vi.fn(async ({ paths }: { paths: string[] }) => ({ paths, failures: [] })), - pick: vi.fn() -})) +const intake = vi.hoisted(() => { + const owner: { kind: string } = { kind: 'local' } + return { + owner, + stat: vi.fn(), + readFile: vi.fn(), + prepare: vi.fn(async ({ paths }: { paths: string[] }) => ({ paths, failures: [] })), + pick: vi.fn() + } +}) vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ tabsByWorktree: {} }) } })) // Keeps the real notice strings so the silent-failure guards assert what users see. vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ ...(await importOriginal()), - resolveNativeChatAttachmentOwner: () => intake.owner, - uploadNativeChatAttachmentPaths: intake.upload + resolveNativeChatAttachmentOwner: () => intake.owner })) vi.mock('electron', () => ({ @@ -182,7 +183,6 @@ describe('native chat composer drop scoping', () => { electron.getPathForFile.mockReset().mockImplementation((file: File) => `/repro/${file.name}`) intake.stat.mockReset().mockResolvedValue(undefined) intake.readFile.mockReset().mockResolvedValue({ content: '', isBinary: false }) - intake.upload.mockReset() intake.prepare.mockReset().mockImplementation(async ({ paths }) => ({ paths, failures: [] })) intake.pick.mockReset() vi.stubGlobal('IntersectionObserver', undefined) @@ -232,10 +232,9 @@ describe('native chat composer drop scoping', () => { intake.prepare.mockImplementationOnce(() => gate.promise) const view = render() await dropTwoImages(view.container.querySelector('.ProseMirror')!) - intake.owner = { kind: 'ssh', connectionId: 'other-host' } + intake.owner = { kind: 'remote' } await act(async () => gate.resolve({ paths: ['/prepared/a.png'], failures: [] })) expect(intake.stat).not.toHaveBeenCalled() - expect(intake.upload).not.toHaveBeenCalled() expect(readNativeChatAttachmentCache('chat-a')).toEqual([]) }) it('refuses preparation captured for a draft that changed in the same pane', async () => { @@ -281,7 +280,7 @@ describe('native chat composer drop scoping', () => { it('notices an OS drop whose owner changes while checking the files', async () => { intake.stat.mockImplementation(async () => { - intake.owner = { kind: 'ssh', connectionId: 'conn-1' } + intake.owner = { kind: 'remote' } }) const view = render() @@ -415,33 +414,10 @@ describe('native chat composer drop scoping', () => { [{ filePath: '/repro/second.png', access: { kind: 'user-file' } }] ]) expect(intake.readFile).toHaveBeenCalledTimes(2) - expect(intake.upload).not.toHaveBeenCalled() expect(readNativeChatAttachmentCache('chat-a')).toHaveLength(2) expect(readNativeChatAttachmentCache('chat-b')).toEqual([]) await expect(intake.readFile({ filePath: '/repro/sibling.png' })).rejects.toThrow( 'Access denied' ) }) - - it('uploads once for the SSH drop owner without checking remote paths locally', async () => { - intake.owner = { kind: 'ssh', connectionId: 'conn-1' } - intake.upload.mockResolvedValue(['/remote/first.png', '/remote/second.png']) - const view = render( - <> - -